diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 30e9c1c7ca59b..0000000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,149 +0,0 @@ -name: release.yml -on: - pull_request: - types: [closed] - paths: - - CHANGELOG.md - -jobs: - # Maybe double check PR source branch? - - merge_translations: - uses: ./.github/workflows/merge-translations.yml - permissions: - pull-requests: write - secrets: - PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }} - PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }} - - build_mobile: - uses: ./.github/workflows/build-mobile.yml - needs: merge_translations - permissions: - contents: read - secrets: - KEY_JKS: ${{ secrets.KEY_JKS }} - ALIAS: ${{ secrets.ALIAS }} - ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} - ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }} - # iOS secrets - APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} - APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} - APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }} - IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }} - IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} - IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }} - IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl - IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }} - FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }} - with: - ref: main - environment: production - - prepare_release: - runs-on: ubuntu-latest - needs: build_mobile - permissions: - actions: read # To download the app artifact - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 - with: - app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} - private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - token: ${{ steps.generate-token.outputs.token }} - persist-credentials: false - ref: main - - - name: Extract changelog - id: changelog - run: | - CHANGELOG_PATH=$RUNNER_TEMP/changelog.md - sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH - echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT - VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH) - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Download APK - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - name: release-apk-signed - github-token: ${{ steps.generate-token.outputs.token }} - - - name: Create draft release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 - with: - tag_name: ${{ steps.version.outputs.result }} - token: ${{ steps.generate-token.outputs.token }} - body_path: ${{ steps.changelog.outputs.path }} - draft: true - files: | - docker/docker-compose.yml - docker/docker-compose.rootless.yml - docker/example.env - docker/hwaccel.ml.yml - docker/hwaccel.transcoding.yml - docker/prometheus.yml - *.apk - - - name: Rename Outline document - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - continue-on-error: true - env: - OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }} - VERSION: ${{ steps.changelog.outputs.version }} - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - const outlineKey = process.env.OUTLINE_API_KEY; - const version = process.env.VERSION; - const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'; - const baseUrl = 'https://outline.immich.cloud'; - - const listResponse = await fetch(`${baseUrl}/api/documents.list`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${outlineKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ parentDocumentId }) - }); - - if (!listResponse.ok) { - throw new Error(`Outline list failed: ${listResponse.statusText}`); - } - - const listData = await listResponse.json(); - const allDocuments = listData.data || []; - const document = allDocuments.find(doc => doc.title === 'next'); - - if (document) { - console.log(`Found document 'next', renaming to '${version}'...`); - - const updateResponse = await fetch(`${baseUrl}/api/documents.update`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${outlineKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - id: document.id, - title: version - }) - }); - - if (!updateResponse.ok) { - throw new Error(`Failed to rename document: ${updateResponse.statusText}`); - } - } else { - console.log('No document titled "next" found to rename'); - } diff --git a/e2e/src/specs/server/api/shared-link.e2e-spec.ts b/e2e/src/specs/server/api/shared-link.e2e-spec.ts index 80232beb753e9..00c455d6cbec6 100644 --- a/e2e/src/specs/server/api/shared-link.e2e-spec.ts +++ b/e2e/src/specs/server/api/shared-link.e2e-spec.ts @@ -438,6 +438,16 @@ describe('/shared-links', () => { expect(body).toEqual(errorDto.badRequest('Invalid shared link type')); }); + it('should reject guests removing assets from an individual shared link', async () => { + const { status, body } = await request(app) + .delete(`/shared-links/${linkWithAssets.id}/assets`) + .query({ key: linkWithAssets.key }) + .send({ assetIds: [asset1.id] }); + + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + it('should remove assets from a shared link (individual)', async () => { const { status, body } = await request(app) .delete(`/shared-links/${linkWithAssets.id}/assets`) diff --git a/e2e/src/specs/web/photo-viewer.e2e-spec.ts b/e2e/src/specs/web/photo-viewer.e2e-spec.ts index 3f9bb4237a188..88b61278bc488 100644 --- a/e2e/src/specs/web/photo-viewer.e2e-spec.ts +++ b/e2e/src/specs/web/photo-viewer.e2e-spec.ts @@ -1,14 +1,13 @@ import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk'; -import { Page, expect, test } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import type { Socket } from 'socket.io-client'; import { utils } from 'src/utils'; -function imageLocator(page: Page) { - return page.getByAltText('Image taken').locator('visible=true'); -} test.describe('Photo Viewer', () => { let admin: LoginResponseDto; let asset: AssetMediaResponseDto; let rawAsset: AssetMediaResponseDto; + let websocket: Socket; test.beforeAll(async () => { utils.initSdk(); @@ -16,6 +15,11 @@ test.describe('Photo Viewer', () => { admin = await utils.adminSetup(); asset = await utils.createAsset(admin.accessToken); rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } }); + websocket = await utils.connectWebsocket(admin.accessToken); + }); + + test.afterAll(() => { + utils.disconnectWebsocket(websocket); }); test.beforeEach(async ({ context, page }) => { @@ -26,31 +30,51 @@ test.describe('Photo Viewer', () => { test('loads original photo when zoomed', async ({ page }) => { await page.goto(`/photos/${asset.id}`); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); - const box = await imageLocator(page).boundingBox(); - expect(box).toBeTruthy(); - const { x, y, width, height } = box!; - await page.mouse.move(x + width / 2, y + height / 2); + + const preview = page.getByTestId('preview').filter({ visible: true }); + await expect(preview).toHaveAttribute('src', /.+/); + + const originalResponse = page.waitForResponse((response) => response.url().includes('/original')); + + const { width, height } = page.viewportSize()!; + await page.mouse.move(width / 2, height / 2); await page.mouse.wheel(0, -1); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original'); + + await originalResponse; + + const original = page.getByTestId('original').filter({ visible: true }); + await expect(original).toHaveAttribute('src', /original/); }); test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => { await page.goto(`/photos/${rawAsset.id}`); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); - const box = await imageLocator(page).boundingBox(); - expect(box).toBeTruthy(); - const { x, y, width, height } = box!; - await page.mouse.move(x + width / 2, y + height / 2); + + const preview = page.getByTestId('preview').filter({ visible: true }); + await expect(preview).toHaveAttribute('src', /.+/); + + const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize')); + + const { width, height } = page.viewportSize()!; + await page.mouse.move(width / 2, height / 2); await page.mouse.wheel(0, -1); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize'); + + await fullsizeResponse; + + const original = page.getByTestId('original').filter({ visible: true }); + await expect(original).toHaveAttribute('src', /fullsize/); }); test('reloads photo when checksum changes', async ({ page }) => { await page.goto(`/photos/${asset.id}`); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); - const initialSrc = await imageLocator(page).getAttribute('src'); + + const preview = page.getByTestId('preview').filter({ visible: true }); + await expect(preview).toHaveAttribute('src', /.+/); + const initialSrc = await preview.getAttribute('src'); + + const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id }); await utils.replaceAsset(admin.accessToken, asset.id); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc); + await websocketEvent; + + await expect(preview).not.toHaveAttribute('src', initialSrc!); }); }); diff --git a/e2e/src/specs/web/shared-link.e2e-spec.ts b/e2e/src/specs/web/shared-link.e2e-spec.ts index f6d1ec98d45a2..8380840935b7e 100644 --- a/e2e/src/specs/web/shared-link.e2e-spec.ts +++ b/e2e/src/specs/web/shared-link.e2e-spec.ts @@ -12,15 +12,18 @@ import { asBearerAuth, utils } from 'src/utils'; test.describe('Shared Links', () => { let admin: LoginResponseDto; let asset: AssetMediaResponseDto; + let asset2: AssetMediaResponseDto; let album: AlbumResponseDto; let sharedLink: SharedLinkResponseDto; let sharedLinkPassword: SharedLinkResponseDto; + let individualSharedLink: SharedLinkResponseDto; test.beforeAll(async () => { utils.initSdk(); await utils.resetDatabase(); admin = await utils.adminSetup(); asset = await utils.createAsset(admin.accessToken); + asset2 = await utils.createAsset(admin.accessToken); album = await createAlbum( { createAlbumDto: { @@ -39,6 +42,10 @@ test.describe('Shared Links', () => { albumId: album.id, password: 'test-password', }); + individualSharedLink = await utils.createSharedLink(admin.accessToken, { + type: SharedLinkType.Individual, + assetIds: [asset.id, asset2.id], + }); }); test('download from a shared link', async ({ page }) => { @@ -109,4 +116,21 @@ test.describe('Shared Links', () => { await page.waitForURL('/photos'); await page.locator(`[data-asset-id="${asset.id}"]`).waitFor(); }); + + test('owner can remove assets from an individual shared link', async ({ context, page }) => { + await utils.setAuthCookies(context, admin.accessToken); + + await page.goto(`/share/${individualSharedLink.key}`); + await page.locator(`[data-asset="${asset.id}"]`).waitFor(); + await expect(page.locator(`[data-asset]`)).toHaveCount(2); + + await page.locator(`[data-asset="${asset.id}"]`).hover(); + await page.locator(`[data-asset="${asset.id}"] [role="checkbox"]`).click(); + + await page.getByRole('button', { name: 'Remove from shared link' }).click(); + await page.getByRole('button', { name: 'Remove', exact: true }).click(); + + await expect(page.locator(`[data-asset="${asset.id}"]`)).toHaveCount(0); + await expect(page.locator(`[data-asset="${asset2.id}"]`)).toHaveCount(1); + }); }); diff --git a/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts b/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts index fa010f0c1b9c8..2b036d3f52282 100644 --- a/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts +++ b/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts @@ -64,7 +64,9 @@ test.describe('broken-asset responsiveness', () => { test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => { await context.route( - (url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`), + (url) => + url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`) || + url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`), async (route) => { return route.fulfill({ status: 404 }); }, @@ -73,7 +75,7 @@ test.describe('broken-asset responsiveness', () => { await page.goto(`/photos/${fixture.primaryAsset.id}`); await page.waitForSelector('#immich-asset-viewer'); - const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]'); + const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]').first(); await expect(viewerBrokenAsset).toBeVisible(); await expect(viewerBrokenAsset.locator('svg')).toBeVisible(); diff --git a/i18n/en.json b/i18n/en.json index 1a63f25fde734..72cdd8ae671bb 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1020,6 +1020,8 @@ "editor_edits_applied_success": "Edits applied successfully", "editor_flip_horizontal": "Flip horizontal", "editor_flip_vertical": "Flip vertical", + "editor_handle_corner": "{corner, select, top_left {Top-left} top_right {Top-right} bottom_left {Bottom-left} bottom_right {Bottom-right} other {A}} corner handle", + "editor_handle_edge": "{edge, select, top {Top} bottom {Bottom} left {Left} right {Right} other {An}} edge handle", "editor_orientation": "Orientation", "editor_reset_all_changes": "Reset changes", "editor_rotate_left": "Rotate 90° counterclockwise", @@ -1089,7 +1091,7 @@ "failed_to_update_notification_status": "Failed to update notification status", "incorrect_email_or_password": "Incorrect email or password", "library_folder_already_exists": "This import path already exists.", - "page_not_found": "Page not found :/", + "page_not_found": "Page not found", "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation", "profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.", "quota_higher_than_disk_size": "You set a quota higher than the disk size", diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 90e5005739922..f45a06e3df27d 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -3,6 +3,7 @@ plugins { id "kotlin-android" id "dev.flutter.flutter-gradle-plugin" id 'com.google.devtools.ksp' + id 'org.jetbrains.kotlin.plugin.serialization' id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt index 37435a9f02711..180ae4735df77 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt @@ -8,11 +8,16 @@ import app.alextran.immich.BuildConfig import app.alextran.immich.NativeBuffer import okhttp3.Cache import okhttp3.ConnectionPool +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.Credentials import okhttp3.Dispatcher import okhttp3.Headers -import okhttp3.Credentials +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient -import org.json.JSONObject +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import java.io.ByteArrayInputStream import java.io.File import java.net.Socket @@ -32,7 +37,19 @@ private const val CERT_ALIAS = "client_cert" private const val PREFS_NAME = "immich.ssl" private const val PREFS_CERT_ALIAS = "immich.client_cert" private const val PREFS_HEADERS = "immich.request_headers" -private const val PREFS_SERVER_URL = "immich.server_url" +private const val PREFS_SERVER_URLS = "immich.server_urls" +private const val PREFS_COOKIES = "immich.cookies" +private const val COOKIE_EXPIRY_DAYS = 400L + +private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) { + ACCESS_TOKEN("immich_access_token", httpOnly = true), + IS_AUTHENTICATED("immich_is_authenticated", httpOnly = false), + AUTH_TYPE("immich_auth_type", httpOnly = true); + + companion object { + val names = entries.map { it.cookieName }.toSet() + } +} /** * Manages a shared OkHttpClient with SSL configuration support. @@ -58,6 +75,8 @@ object HttpClientManager { var headers: Headers = Headers.headersOf() private set + private val cookieJar = PersistentCookieJar() + val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS) fun initialize(context: Context) { @@ -69,16 +88,23 @@ object HttpClientManager { prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null) + cookieJar.init(prefs) + val savedHeaders = prefs.getString(PREFS_HEADERS, null) if (savedHeaders != null) { - val json = JSONObject(savedHeaders) + val map = Json.decodeFromString>(savedHeaders) val builder = Headers.Builder() - for (key in json.keys()) { - builder.add(key, json.getString(key)) + for ((key, value) in map) { + builder.add(key, value) } headers = builder.build() } + val serverUrlsJson = prefs.getString(PREFS_SERVER_URLS, null) + if (serverUrlsJson != null) { + cookieJar.setServerUrls(Json.decodeFromString>(serverUrlsJson)) + } + val cacheDir = File(File(context.cacheDir, "okhttp"), "api") client = build(cacheDir) initialized = true @@ -153,25 +179,50 @@ object HttpClientManager { synchronized(this) { clientChangedListeners.add(listener) } } - fun setRequestHeaders(headerMap: Map, serverUrls: List) { + fun setRequestHeaders(headerMap: Map, serverUrls: List, token: String?) { synchronized(this) { val builder = Headers.Builder() headerMap.forEach { (key, value) -> builder[key] = value } val newHeaders = builder.build() + val headersChanged = headers != newHeaders - val newUrl = serverUrls.firstOrNull() - val urlChanged = newUrl != prefs.getString(PREFS_SERVER_URL, null) - if (!headersChanged && !urlChanged) return + val urlsChanged = Json.encodeToString(serverUrls) != prefs.getString(PREFS_SERVER_URLS, null) + headers = newHeaders - prefs.edit { - if (headersChanged) putString(PREFS_HEADERS, JSONObject(headerMap).toString()) - if (urlChanged) { - if (newUrl != null) putString(PREFS_SERVER_URL, newUrl) else remove(PREFS_SERVER_URL) + cookieJar.setServerUrls(serverUrls) + + if (headersChanged || urlsChanged) { + prefs.edit { + putString(PREFS_HEADERS, Json.encodeToString(headerMap)) + putString(PREFS_SERVER_URLS, Json.encodeToString(serverUrls)) } } + + if (token != null) { + val url = serverUrls.firstNotNullOfOrNull { it.toHttpUrlOrNull() } ?: return + val expiry = System.currentTimeMillis() + COOKIE_EXPIRY_DAYS * 24 * 60 * 60 * 1000 + val values = mapOf( + AuthCookie.ACCESS_TOKEN to token, + AuthCookie.IS_AUTHENTICATED to "true", + AuthCookie.AUTH_TYPE to "password", + ) + cookieJar.saveFromResponse(url, values.map { (cookie, value) -> + Cookie.Builder().name(cookie.cookieName).value(value).domain(url.host).path("/").expiresAt(expiry) + .apply { + if (url.isHttps) secure() + if (cookie.httpOnly) httpOnly() + }.build() + }) + } } } + fun loadCookieHeader(url: String): String? { + val httpUrl = url.toHttpUrlOrNull() ?: return null + return cookieJar.loadForRequest(httpUrl).takeIf { it.isNotEmpty() } + ?.joinToString("; ") { "${it.name}=${it.value}" } + } + private fun build(cacheDir: File): OkHttpClient { val connectionPool = ConnectionPool( maxIdleConnections = KEEP_ALIVE_CONNECTIONS, @@ -188,6 +239,7 @@ object HttpClientManager { HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) return OkHttpClient.Builder() + .cookieJar(cookieJar) .addInterceptor { val request = it.request() val builder = request.newBuilder() @@ -249,4 +301,131 @@ object HttpClientManager { socket: Socket? ): String? = null } + + /** + * Persistent CookieJar that duplicates auth cookies across equivalent server URLs. + * When the server sets cookies for one domain, copies are created for all other known + * server domains (for URL switching between local/remote endpoints of the same server). + */ + private class PersistentCookieJar : CookieJar { + private val store = mutableListOf() + private var serverUrls = listOf() + private var prefs: SharedPreferences? = null + + + fun init(prefs: SharedPreferences) { + this.prefs = prefs + restore() + } + + @Synchronized + fun setServerUrls(urls: List) { + val parsed = urls.mapNotNull { it.toHttpUrlOrNull() } + if (parsed.map { it.host } == serverUrls.map { it.host }) return + serverUrls = parsed + if (syncAuthCookies()) persist() + } + + @Synchronized + override fun saveFromResponse(url: HttpUrl, cookies: List) { + val changed = cookies.any { new -> + store.none { it.name == new.name && it.domain == new.domain && it.path == new.path && it.value == new.value } + } + store.removeAll { existing -> + cookies.any { it.name == existing.name && it.domain == existing.domain && it.path == existing.path } + } + store.addAll(cookies) + val synced = serverUrls.any { it.host == url.host } && syncAuthCookies() + if (changed || synced) persist() + } + + @Synchronized + override fun loadForRequest(url: HttpUrl): List { + val now = System.currentTimeMillis() + if (store.removeAll { it.expiresAt < now }) { + syncAuthCookies() + persist() + } + return store.filter { it.matches(url) } + } + + private fun syncAuthCookies(): Boolean { + val serverHosts = serverUrls.map { it.host }.toSet() + val now = System.currentTimeMillis() + val sourceCookies = store + .filter { it.name in AuthCookie.names && it.domain in serverHosts && it.expiresAt > now } + .associateBy { it.name } + + if (sourceCookies.isEmpty()) { + return store.removeAll { it.name in AuthCookie.names && it.domain in serverHosts } + } + + var changed = false + for (url in serverUrls) { + for ((_, source) in sourceCookies) { + if (store.any { it.name == source.name && it.domain == url.host && it.value == source.value }) continue + store.removeAll { it.name == source.name && it.domain == url.host } + store.add(rebuildCookie(source, url)) + changed = true + } + } + return changed + } + + private fun rebuildCookie(source: Cookie, url: HttpUrl): Cookie { + return Cookie.Builder() + .name(source.name).value(source.value) + .domain(url.host).path("/") + .expiresAt(source.expiresAt) + .apply { + if (url.isHttps) secure() + if (source.httpOnly) httpOnly() + } + .build() + } + + private fun persist() { + val p = prefs ?: return + p.edit { putString(PREFS_COOKIES, Json.encodeToString(store.map { SerializedCookie.from(it) })) } + } + + private fun restore() { + val p = prefs ?: return + val jsonStr = p.getString(PREFS_COOKIES, null) ?: return + try { + store.addAll(Json.decodeFromString>(jsonStr).map { it.toCookie() }) + } catch (_: Exception) { + store.clear() + } + } + } + + @Serializable + private data class SerializedCookie( + val name: String, + val value: String, + val domain: String, + val path: String, + val expiresAt: Long, + val secure: Boolean, + val httpOnly: Boolean, + val hostOnly: Boolean, + ) { + fun toCookie(): Cookie = Cookie.Builder() + .name(name).value(value).path(path).expiresAt(expiresAt) + .apply { + if (hostOnly) hostOnlyDomain(domain) else domain(domain) + if (secure) secure() + if (httpOnly) httpOnly() + } + .build() + + companion object { + fun from(cookie: Cookie) = SerializedCookie( + name = cookie.name, value = cookie.value, domain = cookie.domain, + path = cookie.path, expiresAt = cookie.expiresAt, secure = cookie.secure, + httpOnly = cookie.httpOnly, hostOnly = cookie.hostOnly, + ) + } + } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt index 5e48d7fef537b..869e312515d34 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt @@ -184,7 +184,7 @@ interface NetworkApi { fun removeCertificate(callback: (Result) -> Unit) fun hasCertificate(): Boolean fun getClientPointer(): Long - fun setRequestHeaders(headers: Map, serverUrls: List) + fun setRequestHeaders(headers: Map, serverUrls: List, token: String?) companion object { /** The codec used by NetworkApi. */ @@ -287,8 +287,9 @@ interface NetworkApi { val args = message as List val headersArg = args[0] as Map val serverUrlsArg = args[1] as List + val tokenArg = args[2] as String? val wrapped: List = try { - api.setRequestHeaders(headersArg, serverUrlsArg) + api.setRequestHeaders(headersArg, serverUrlsArg, tokenArg) listOf(null) } catch (exception: Throwable) { NetworkPigeonUtils.wrapError(exception) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt index 384c94cce9735..85b7a6c730f19 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt @@ -39,7 +39,7 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware { } } -private class NetworkApiImpl() : NetworkApi { +private class NetworkApiImpl : NetworkApi { var activity: Activity? = null override fun addCertificate(clientData: ClientCertData, callback: (Result) -> Unit) { @@ -79,7 +79,7 @@ private class NetworkApiImpl() : NetworkApi { return HttpClientManager.getClientPointer() } - override fun setRequestHeaders(headers: Map, serverUrls: List) { - HttpClientManager.setRequestHeaders(headers, serverUrls) + override fun setRequestHeaders(headers: Map, serverUrls: List, token: String?) { + HttpClientManager.setRequestHeaders(headers, serverUrls, token) } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index 21e3c603e612e..b820b454254dc 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -192,6 +192,7 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche val callback = FetchCallback(onSuccess, onFailure, ::onComplete) val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor) HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) } + HttpClientManager.loadCookieHeader(url)?.let { requestBuilder.addHeader("Cookie", it) } url.toHttpUrlOrNull()?.let { httpUrl -> if (httpUrl.username.isNotEmpty()) { requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password)) diff --git a/mobile/ios/Runner/Core/Network.g.swift b/mobile/ios/Runner/Core/Network.g.swift index 96294c1cd4737..5a8075f91ae27 100644 --- a/mobile/ios/Runner/Core/Network.g.swift +++ b/mobile/ios/Runner/Core/Network.g.swift @@ -225,7 +225,7 @@ protocol NetworkApi { func removeCertificate(completion: @escaping (Result) -> Void) func hasCertificate() throws -> Bool func getClientPointer() throws -> Int64 - func setRequestHeaders(headers: [String: String], serverUrls: [String]) throws + func setRequestHeaders(headers: [String: String], serverUrls: [String], token: String?) throws } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -315,8 +315,9 @@ class NetworkApiSetup { let args = message as! [Any?] let headersArg = args[0] as! [String: String] let serverUrlsArg = args[1] as! [String] + let tokenArg: String? = nilOrValue(args[2]) do { - try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg) + try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg, token: tokenArg) reply(wrapResult(nil)) } catch { reply(wrapError(error)) diff --git a/mobile/ios/Runner/Core/NetworkApiImpl.swift b/mobile/ios/Runner/Core/NetworkApiImpl.swift index 480286b2af036..3c4be8e71831d 100644 --- a/mobile/ios/Runner/Core/NetworkApiImpl.swift +++ b/mobile/ios/Runner/Core/NetworkApiImpl.swift @@ -58,42 +58,39 @@ class NetworkApiImpl: NetworkApi { return Int64(Int(bitPattern: pointer)) } - func setRequestHeaders(headers: [String : String], serverUrls: [String]) throws { - var headers = headers - if let token = headers.removeValue(forKey: "x-immich-user-token") { + func setRequestHeaders(headers: [String : String], serverUrls: [String], token: String?) throws { + URLSessionManager.setServerUrls(serverUrls) + + if let token = token { + let expiry = Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60) for serverUrl in serverUrls { guard let url = URL(string: serverUrl), let domain = url.host else { continue } let isSecure = serverUrl.hasPrefix("https") - let cookies: [(String, String, Bool)] = [ - ("immich_access_token", token, true), - ("immich_is_authenticated", "true", false), - ("immich_auth_type", "password", true), + let values: [AuthCookie: String] = [ + .accessToken: token, + .isAuthenticated: "true", + .authType: "password", ] - let expiry = Date().addingTimeInterval(400 * 24 * 60 * 60) - for (name, value, httpOnly) in cookies { + for (cookie, value) in values { var properties: [HTTPCookiePropertyKey: Any] = [ - .name: name, + .name: cookie.name, .value: value, .domain: domain, .path: "/", .expires: expiry, ] if isSecure { properties[.secure] = "TRUE" } - if httpOnly { properties[.init("HttpOnly")] = "TRUE" } - if let cookie = HTTPCookie(properties: properties) { - URLSessionManager.cookieStorage.setCookie(cookie) + if cookie.httpOnly { properties[.init("HttpOnly")] = "TRUE" } + if let httpCookie = HTTPCookie(properties: properties) { + URLSessionManager.cookieStorage.setCookie(httpCookie) } } } } - if serverUrls.first != UserDefaults.group.string(forKey: SERVER_URL_KEY) { - UserDefaults.group.set(serverUrls.first, forKey: SERVER_URL_KEY) - } - if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] { UserDefaults.group.set(headers, forKey: HEADERS_KEY) - URLSessionManager.shared.recreateSession() // Recreate session to apply custom headers without app restart + URLSessionManager.shared.recreateSession() } } } diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift index 411b828ea1b6e..9868d4eb59769 100644 --- a/mobile/ios/Runner/Core/URLSessionManager.swift +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -3,8 +3,30 @@ import native_video_player let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity" let HEADERS_KEY = "immich.request_headers" -let SERVER_URL_KEY = "immich.server_url" +let SERVER_URLS_KEY = "immich.server_urls" let APP_GROUP = "group.app.immich.share" +let COOKIE_EXPIRY_DAYS: TimeInterval = 400 + +enum AuthCookie: CaseIterable { + case accessToken, isAuthenticated, authType + + var name: String { + switch self { + case .accessToken: return "immich_access_token" + case .isAuthenticated: return "immich_is_authenticated" + case .authType: return "immich_auth_type" + } + } + + var httpOnly: Bool { + switch self { + case .accessToken, .authType: return true + case .isAuthenticated: return false + } + } + + static let names: Set = Set(allCases.map(\.name)) +} extension UserDefaults { static let group = UserDefaults(suiteName: APP_GROUP)! @@ -34,21 +56,94 @@ class URLSessionManager: NSObject { return "Immich_iOS_\(version)" }() static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP) - + private static var serverUrls: [String] = [] + private static var isSyncing = false + var sessionPointer: UnsafeMutableRawPointer { Unmanaged.passUnretained(session).toOpaque() } - + private override init() { delegate = URLSessionManagerDelegate() session = Self.buildSession(delegate: delegate) super.init() + Self.serverUrls = UserDefaults.group.stringArray(forKey: SERVER_URLS_KEY) ?? [] + NotificationCenter.default.addObserver( + Self.self, + selector: #selector(Self.cookiesDidChange), + name: NSNotification.Name.NSHTTPCookieManagerCookiesChanged, + object: Self.cookieStorage + ) } func recreateSession() { session = Self.buildSession(delegate: delegate) } + static func setServerUrls(_ urls: [String]) { + guard urls != serverUrls else { return } + serverUrls = urls + UserDefaults.group.set(urls, forKey: SERVER_URLS_KEY) + syncAuthCookies() + } + + @objc private static func cookiesDidChange(_ notification: Notification) { + guard !isSyncing, !serverUrls.isEmpty else { return } + syncAuthCookies() + } + + private static func syncAuthCookies() { + let serverHosts = Set(serverUrls.compactMap { URL(string: $0)?.host }) + let allCookies = cookieStorage.cookies ?? [] + let now = Date() + + let serverAuthCookies = allCookies.filter { + AuthCookie.names.contains($0.name) && serverHosts.contains($0.domain) + } + + var sourceCookies: [String: HTTPCookie] = [:] + for cookie in serverAuthCookies { + if cookie.expiresDate.map({ $0 > now }) ?? true { + sourceCookies[cookie.name] = cookie + } + } + + isSyncing = true + defer { isSyncing = false } + + if sourceCookies.isEmpty { + for cookie in serverAuthCookies { + cookieStorage.deleteCookie(cookie) + } + return + } + + for serverUrl in serverUrls { + guard let url = URL(string: serverUrl), let domain = url.host else { continue } + let isSecure = serverUrl.hasPrefix("https") + + for (_, source) in sourceCookies { + if allCookies.contains(where: { $0.name == source.name && $0.domain == domain && $0.value == source.value }) { + continue + } + + var properties: [HTTPCookiePropertyKey: Any] = [ + .name: source.name, + .value: source.value, + .domain: domain, + .path: "/", + .expires: source.expiresDate ?? Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60), + ] + if isSecure { properties[.secure] = "TRUE" } + if source.isHTTPOnly { properties[.init("HttpOnly")] = "TRUE" } + + if let cookie = HTTPCookie(properties: properties) { + cookieStorage.setCookie(cookie) + } + } + } + } + private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession { let config = URLSessionConfiguration.default config.urlCache = urlCache diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart index 5dd34c04ba4a9..cb40c8f76a904 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -46,6 +46,7 @@ sealed class BaseAsset { bool get isVideo => type == AssetType.video; bool get isMotionPhoto => livePhotoVideoId != null; + bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated; AssetPlaybackStyle get playbackStyle { if (isVideo) return AssetPlaybackStyle.video; diff --git a/mobile/lib/infrastructure/repositories/network.repository.dart b/mobile/lib/infrastructure/repositories/network.repository.dart index adf1ee5694e8d..bb5796e2202e8 100644 --- a/mobile/lib/infrastructure/repositories/network.repository.dart +++ b/mobile/lib/infrastructure/repositories/network.repository.dart @@ -26,8 +26,8 @@ class NetworkRepository { } } - static Future setHeaders(Map headers, List serverUrls) async { - await networkApi.setRequestHeaders(headers, serverUrls); + static Future setHeaders(Map headers, List serverUrls, {String? token}) async { + await networkApi.setRequestHeaders(headers, serverUrls, token); if (Platform.isIOS) { await init(); } diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index c5084c0236167..3ba3389eeaa2d 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -148,10 +148,12 @@ class _DriftBackupPageState extends ConsumerState { children: [ Icon(Icons.warning_rounded, color: context.colorScheme.error, fill: 1), const SizedBox(width: 8), - Text( - context.t.backup_error_sync_failed, - style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error), - textAlign: TextAlign.center, + Flexible( + child: Text( + context.t.backup_error_sync_failed, + style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error), + textAlign: TextAlign.center, + ), ), ], ), @@ -344,6 +346,7 @@ class _RemainderCard extends ConsumerWidget { remainderCount.toString(), style: context.textTheme.titleLarge?.copyWith( color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255), + fontFeatures: [const FontFeature.tabularFigures()], ), ), if (syncStatus.isRemoteSyncing) @@ -483,6 +486,7 @@ class _PreparingStatusState extends ConsumerState { style: context.textTheme.titleMedium?.copyWith( color: context.colorScheme.primary, fontWeight: FontWeight.w600, + fontFeatures: [const FontFeature.tabularFigures()], ), ), ], @@ -507,6 +511,7 @@ class _PreparingStatusState extends ConsumerState { style: context.textTheme.titleMedium?.copyWith( color: context.primaryColor, fontWeight: FontWeight.w600, + fontFeatures: [const FontFeature.tabularFigures()], ), ), ], diff --git a/mobile/lib/platform/network_api.g.dart b/mobile/lib/platform/network_api.g.dart index 314a943f7d64d..0ecbb430d3de7 100644 --- a/mobile/lib/platform/network_api.g.dart +++ b/mobile/lib/platform/network_api.g.dart @@ -281,7 +281,7 @@ class NetworkApi { } } - Future setRequestHeaders(Map headers, List serverUrls) async { + Future setRequestHeaders(Map headers, List serverUrls, String? token) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( @@ -289,7 +289,7 @@ class NetworkApi { pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([headers, serverUrls]); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([headers, serverUrls, token]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index 397cd98acef16..ae7dd8539602c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -113,17 +113,14 @@ class _AppBarBackButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); - final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black; - final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white; - return Padding( padding: const EdgeInsets.only(left: 12.0), child: ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: backgroundColor, + backgroundColor: showingDetails ? context.colorScheme.surface : Colors.transparent, shape: const CircleBorder(), iconSize: 22, - iconColor: foregroundColor, + iconColor: showingDetails ? context.colorScheme.onSurface : Colors.white, padding: EdgeInsets.zero, elevation: showingDetails ? 4 : 0, ), diff --git a/mobile/lib/presentation/widgets/images/animated_image_stream_completer.dart b/mobile/lib/presentation/widgets/images/animated_image_stream_completer.dart new file mode 100644 index 0000000000000..be4fbff8cf334 --- /dev/null +++ b/mobile/lib/presentation/widgets/images/animated_image_stream_completer.dart @@ -0,0 +1,96 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart' show InformationCollector; +import 'package:flutter/painting.dart'; + +/// A [MultiFrameImageStreamCompleter] with support for listener tracking +/// which makes resource cleanup possible when no longer needed. +/// Codec is disposed through the MultiFrameImageStreamCompleter's internals onDispose method +class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter { + void Function()? _onLastListenerRemoved; + int _listenerCount = 0; + // True once any image or the codec has been provided. + // Until then the image cache holds one listener, so "last real listener gone" + // is _listenerCount == 1, not 0. + bool didProvideImage = false; + + AnimatedImageStreamCompleter._({ + required super.codec, + required super.scale, + super.informationCollector, + void Function()? onLastListenerRemoved, + }) : _onLastListenerRemoved = onLastListenerRemoved; + + factory AnimatedImageStreamCompleter({ + required Stream stream, + required double scale, + ImageInfo? initialImage, + InformationCollector? informationCollector, + void Function()? onLastListenerRemoved, + }) { + final codecCompleter = Completer(); + final self = AnimatedImageStreamCompleter._( + codec: codecCompleter.future, + scale: scale, + informationCollector: informationCollector, + onLastListenerRemoved: onLastListenerRemoved, + ); + + if (initialImage != null) { + self.didProvideImage = true; + self.setImage(initialImage); + } + + stream.listen( + (item) { + if (item is ImageInfo) { + self.didProvideImage = true; + self.setImage(item); + } else if (item is ui.Codec) { + if (!codecCompleter.isCompleted) { + self.didProvideImage = true; + codecCompleter.complete(item); + } + } + }, + onError: (Object error, StackTrace stack) { + if (!codecCompleter.isCompleted) { + codecCompleter.completeError(error, stack); + } + }, + onDone: () { + // also complete if we are done but no error occurred, and we didn't call complete yet + // could happen on cancellation + if (!codecCompleter.isCompleted) { + codecCompleter.completeError(StateError('Stream closed without providing a codec')); + } + }, + ); + + return self; + } + + @override + void addListener(ImageStreamListener listener) { + super.addListener(listener); + _listenerCount++; + } + + @override + void removeListener(ImageStreamListener listener) { + super.removeListener(listener); + _listenerCount--; + + final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage; + final bool noListenersAfterCodec = _listenerCount == 0 && didProvideImage; + + if (onlyCacheListenerLeft || noListenersAfterCodec) { + final onLastListenerRemoved = _onLastListenerRemoved; + if (onLastListenerRemoved != null) { + _onLastListenerRemoved = null; + onLastListenerRemoved(); + } + } + } +} diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index 259ac824bb2a5..bf29f9482f279 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -140,7 +140,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080 final ImageProvider provider; if (_shouldUseLocalAsset(asset)) { final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; - provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type); + provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage); } else { final String assetId; final String thumbhash; @@ -153,7 +153,12 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080 } else { throw ArgumentError("Unsupported asset type: ${asset.runtimeType}"); } - provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash, assetType: asset.type); + provider = RemoteFullImageProvider( + assetId: assetId, + thumbhash: thumbhash, + assetType: asset.type, + isAnimated: asset.isAnimatedImage, + ); } return provider; diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index 1c7d102239e94..1ed2c361ff1f3 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -1,11 +1,10 @@ -import 'dart:ui'; - import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; +import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; @@ -58,8 +57,9 @@ class LocalFullImageProvider extends CancellableImageProvider obtainKey(ImageConfiguration configuration) { @@ -68,6 +68,21 @@ class LocalFullImageProvider extends CancellableImageProvider [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Id', key.id), + DiagnosticsProperty('Size', key.size), + DiagnosticsProperty('isAnimated', key.isAnimated), + ], + onLastListenerRemoved: cancel, + ); + } + return OneFramePlaceholderImageStreamCompleter( _codec(key, decode), initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)), @@ -75,6 +90,7 @@ class LocalFullImageProvider extends CancellableImageProvider('Image provider', this), DiagnosticsProperty('Id', key.id), DiagnosticsProperty('Size', key.size), + DiagnosticsProperty('isAnimated', key.isAnimated), ], onLastListenerRemoved: cancel, ); @@ -110,15 +126,45 @@ class LocalFullImageProvider extends CancellableImageProvider _animatedCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* { + yield* initialImageStream(); + + if (isCancelled) { + PaintingBinding.instance.imageCache.evict(this); + return; + } + + final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio; + final previewRequest = request = LocalImageRequest( + localId: key.id, + size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio), + assetType: key.assetType, + ); + yield* loadRequest(previewRequest, decode); + + if (isCancelled) { + PaintingBinding.instance.imageCache.evict(this); + return; + } + + // always try original for animated, since previews don't support animation + final originalRequest = request = LocalImageRequest(localId: key.id, size: Size.zero, assetType: key.assetType); + final codec = await loadCodecRequest(originalRequest); + if (codec == null) { + throw StateError('Failed to load animated codec for local asset ${key.id}'); + } + yield codec; + } + @override bool operator ==(Object other) { if (identical(this, other)) return true; if (other is LocalFullImageProvider) { - return id == other.id && size == other.size; + return id == other.id && size == other.size && isAnimated == other.isAnimated; } return false; } @override - int get hashCode => id.hashCode ^ size.hashCode; + int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode; } diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index f3877f2ad2c05..65ef4e28eb1a5 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -4,6 +4,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; +import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; @@ -58,8 +59,14 @@ class RemoteFullImageProvider extends CancellableImageProvider obtainKey(ImageConfiguration configuration) { @@ -68,12 +75,27 @@ class RemoteFullImageProvider extends CancellableImageProvider [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Asset Id', key.assetId), + DiagnosticsProperty('isAnimated', key.isAnimated), + ], + onLastListenerRemoved: cancel, + ); + } + return OneFramePlaceholderImageStreamCompleter( _codec(key, decode), initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)), informationCollector: () => [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Asset Id', key.assetId), + DiagnosticsProperty('isAnimated', key.isAnimated), ], onLastListenerRemoved: cancel, ); @@ -106,16 +128,43 @@ class RemoteFullImageProvider extends CancellableImageProvider _animatedCodec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* { + yield* initialImageStream(); + + if (isCancelled) { + PaintingBinding.instance.imageCache.evict(this); + return; + } + + final previewRequest = request = RemoteImageRequest( + uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash), + ); + yield* loadRequest(previewRequest, decode, evictOnError: false); + + if (isCancelled) { + PaintingBinding.instance.imageCache.evict(this); + return; + } + + // always try original for animated, since previews don't support animation + final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId)); + final codec = await loadCodecRequest(originalRequest); + if (codec == null) { + throw StateError('Failed to load animated codec for asset ${key.assetId}'); + } + yield codec; + } + @override bool operator ==(Object other) { if (identical(this, other)) return true; if (other is RemoteFullImageProvider) { - return assetId == other.assetId && thumbhash == other.thumbhash; + return assetId == other.assetId && thumbhash == other.thumbhash && isAnimated == other.isAnimated; } return false; } @override - int get hashCode => assetId.hashCode ^ thumbhash.hashCode; + int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ isAnimated.hashCode; } diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 3593fc75e8dbe..2ceaf80db02a8 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -305,6 +305,8 @@ class _AssetTypeIcons extends StatelessWidget { padding: EdgeInsets.only(right: 10.0, top: 6.0), child: _TileOverlayIcon(Icons.motion_photos_on_rounded), ), + if (asset.isAnimatedImage) + const Padding(padding: EdgeInsets.only(right: 10.0, top: 6.0), child: _TileOverlayIcon(Icons.gif_rounded)), ], ); } diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index ee3367eef2ae3..825d9e7bc825e 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -123,7 +123,7 @@ class AuthNotifier extends StateNotifier { } Future saveAuthInfo({required String accessToken}) async { - await _apiService.setAccessToken(accessToken); + await Store.put(StoreKey.accessToken, accessToken); await _apiService.updateHeaders(); final serverEndpoint = Store.get(StoreKey.serverEndpoint); @@ -145,7 +145,6 @@ class AuthNotifier extends StateNotifier { user = serverUser; await Store.put(StoreKey.deviceId, deviceId); await Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); - await Store.put(StoreKey.accessToken, accessToken); } } on ApiException catch (error, stackTrace) { if (error.code == 401) { diff --git a/mobile/lib/repositories/auth.repository.dart b/mobile/lib/repositories/auth.repository.dart index ba978b0df0f68..a8544ef6c075b 100644 --- a/mobile/lib/repositories/auth.repository.dart +++ b/mobile/lib/repositories/auth.repository.dart @@ -38,10 +38,6 @@ class AuthRepository extends DatabaseRepository { }); } - String getAccessToken() { - return Store.get(StoreKey.accessToken); - } - bool getEndpointSwitchingFeature() { return Store.tryGet(StoreKey.autoEndpointSwitching) ?? false; } diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 28cb303c06b3f..d979198849217 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -11,7 +11,7 @@ import 'package:immich_mobile/utils/url_helper.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; -class ApiService implements Authentication { +class ApiService { late ApiClient _apiClient; late UsersApi usersApi; @@ -47,7 +47,6 @@ class ApiService implements Authentication { setEndpoint(endpoint); } } - String? _accessToken; final _log = Logger("ApiService"); Future updateHeaders() async { @@ -56,11 +55,8 @@ class ApiService implements Authentication { } setEndpoint(String endpoint) { - _apiClient = ApiClient(basePath: endpoint, authentication: this); + _apiClient = ApiClient(basePath: endpoint); _apiClient.client = NetworkRepository.client; - if (_accessToken != null) { - setAccessToken(_accessToken!); - } usersApi = UsersApi(_apiClient); authenticationApi = AuthenticationApi(_apiClient); oAuthApi = AuthenticationApi(_apiClient); @@ -161,11 +157,6 @@ class ApiService implements Authentication { return ""; } - Future setAccessToken(String accessToken) async { - _accessToken = accessToken; - await Store.put(StoreKey.accessToken, accessToken); - } - Future setDeviceInfoHeader() async { DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); @@ -209,28 +200,12 @@ class ApiService implements Authentication { } static Map getRequestHeaders() { - var accessToken = Store.get(StoreKey.accessToken, ""); var customHeadersStr = Store.get(StoreKey.customHeaders, ""); - var header = {}; - if (accessToken.isNotEmpty) { - header['x-immich-user-token'] = accessToken; - } - if (customHeadersStr.isEmpty) { - return header; + return const {}; } - var customHeaders = jsonDecode(customHeadersStr) as Map; - customHeaders.forEach((key, value) { - header[key] = value; - }); - - return header; - } - - @override - Future applyToParams(List queryParams, Map headerParams) { - return Future.value(); + return (jsonDecode(customHeadersStr) as Map).cast(); } ApiClient get apiClient => _apiClient; diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index d022d9a5cf748..03278d25fc4d7 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -340,7 +340,6 @@ class BackgroundService { ], ); - await ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken)); await ref.read(authServiceProvider).setOpenApiServiceEndpoint(); dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}"); diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index 1e8d426df8f21..2efd52cc81e0d 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -74,7 +74,6 @@ class BackupVerificationService { final lower = compute(_computeSaveToDelete, ( deleteCandidates: deleteCandidates.slice(0, half), originals: originals.slice(0, half), - auth: Store.get(StoreKey.accessToken), endpoint: Store.get(StoreKey.serverEndpoint), rootIsolateToken: isolateToken, fileMediaRepository: _fileMediaRepository, @@ -82,7 +81,6 @@ class BackupVerificationService { final upper = compute(_computeSaveToDelete, ( deleteCandidates: deleteCandidates.slice(half), originals: originals.slice(half), - auth: Store.get(StoreKey.accessToken), endpoint: Store.get(StoreKey.serverEndpoint), rootIsolateToken: isolateToken, fileMediaRepository: _fileMediaRepository, @@ -92,7 +90,6 @@ class BackupVerificationService { toDelete = await compute(_computeSaveToDelete, ( deleteCandidates: deleteCandidates, originals: originals, - auth: Store.get(StoreKey.accessToken), endpoint: Store.get(StoreKey.serverEndpoint), rootIsolateToken: isolateToken, fileMediaRepository: _fileMediaRepository, @@ -105,7 +102,6 @@ class BackupVerificationService { ({ List deleteCandidates, List originals, - String auth, String endpoint, RootIsolateToken rootIsolateToken, FileMediaRepository fileMediaRepository, @@ -120,7 +116,6 @@ class BackupVerificationService { await tuple.fileMediaRepository.enableBackgroundAccess(); final ApiService apiService = ApiService(); apiService.setEndpoint(tuple.endpoint); - await apiService.setAccessToken(tuple.auth); for (int i = 0; i < tuple.deleteCandidates.length; i++) { if (await _compareAssets(tuple.deleteCandidates[i], tuple.originals[i], apiService)) { result.add(tuple.deleteCandidates[i]); diff --git a/mobile/lib/theme/theme_data.dart b/mobile/lib/theme/theme_data.dart index 3837d6337c7fa..69b8596490a26 100644 --- a/mobile/lib/theme/theme_data.dart +++ b/mobile/lib/theme/theme_data.dart @@ -62,8 +62,6 @@ ThemeData getThemeData({required ColorScheme colorScheme, required Locale locale ), chipTheme: const ChipThemeData(side: BorderSide.none), sliderTheme: const SliderThemeData( - thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7), - trackHeight: 2.0, // ignore: deprecated_member_use year2023: false, ), diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 6b6f1b251b0c6..76916cee1e720 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -25,8 +25,10 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/platform/network_api.g.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/datetime_helpers.dart'; import 'package:immich_mobile/utils/debug_print.dart'; @@ -35,7 +37,7 @@ import 'package:isar/isar.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 24; +const int targetVersion = 25; Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { final hasVersion = Store.tryGet(StoreKey.version) != null; @@ -109,6 +111,16 @@ Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { await _applyLocalAssetOrientation(drift); } + if (version < 25) { + final accessToken = Store.tryGet(StoreKey.accessToken); + if (accessToken != null && accessToken.isNotEmpty) { + final serverUrls = ApiService.getServerUrls(); + if (serverUrls.isNotEmpty) { + await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), serverUrls, token: accessToken); + } + } + } + if (version < 22 && !Store.isBetaTimelineEnabled) { await Store.put(StoreKey.needBetaMigration, true); } diff --git a/mobile/lib/widgets/asset_viewer/animated_play_pause.dart b/mobile/lib/widgets/asset_viewer/animated_play_pause.dart index e7ceac6105423..4be7f49b5af58 100644 --- a/mobile/lib/widgets/asset_viewer/animated_play_pause.dart +++ b/mobile/lib/widgets/asset_viewer/animated_play_pause.dart @@ -1,12 +1,15 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; /// A widget that animates implicitly between a play and a pause icon. class AnimatedPlayPause extends StatefulWidget { - const AnimatedPlayPause({super.key, required this.playing, this.size, this.color}); + const AnimatedPlayPause({super.key, required this.playing, this.size, this.color, this.shadows}); final double? size; final bool playing; final Color? color; + final List? shadows; @override State createState() => AnimatedPlayPauseState(); @@ -39,12 +42,32 @@ class AnimatedPlayPauseState extends State with SingleTickerP @override Widget build(BuildContext context) { + final icon = AnimatedIcon( + color: widget.color, + size: widget.size, + icon: AnimatedIcons.play_pause, + progress: animationController, + ); + return Center( - child: AnimatedIcon( - color: widget.color, - size: widget.size, - icon: AnimatedIcons.play_pause, - progress: animationController, + child: Stack( + alignment: Alignment.center, + children: [ + for (final shadow in widget.shadows ?? const []) + Transform.translate( + offset: shadow.offset, + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: shadow.blurRadius / 2, sigmaY: shadow.blurRadius / 2), + child: AnimatedIcon( + color: shadow.color, + size: widget.size, + icon: AnimatedIcons.play_pause, + progress: animationController, + ), + ), + ), + icon, + ], ), ); } diff --git a/mobile/lib/widgets/asset_viewer/video_controls.dart b/mobile/lib/widgets/asset_viewer/video_controls.dart index 29e877b3dc69d..85707c82ea428 100644 --- a/mobile/lib/widgets/asset_viewer/video_controls.dart +++ b/mobile/lib/widgets/asset_viewer/video_controls.dart @@ -14,6 +14,8 @@ import 'package:immich_mobile/widgets/asset_viewer/animated_play_pause.dart'; class VideoControls extends HookConsumerWidget { final String videoPlayerName; + static const List _controlShadows = [Shadow(color: Colors.black87, blurRadius: 6, offset: Offset(0, 1))]; + const VideoControls({super.key, required this.videoPlayerName}); void _toggle(WidgetRef ref, bool isCasting) { @@ -75,8 +77,8 @@ class VideoControls extends HookConsumerWidget { padding: const EdgeInsets.all(12), constraints: const BoxConstraints(), icon: isFinished - ? const Icon(Icons.replay, color: Colors.white, size: 32) - : AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying), + ? const Icon(Icons.replay, color: Colors.white, size: 32, shadows: _controlShadows) + : AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying, shadows: _controlShadows), onPressed: () => _toggle(ref, isCasting), ), const Spacer(), @@ -86,6 +88,7 @@ class VideoControls extends HookConsumerWidget { color: Colors.white, fontWeight: FontWeight.w500, fontFeatures: [FontFeature.tabularFigures()], + shadows: _controlShadows, ), ), const SizedBox(width: 16), diff --git a/mobile/lib/widgets/common/immich_image.dart b/mobile/lib/widgets/common/immich_image.dart index 141a2ac7d48ac..57978e83ff19f 100644 --- a/mobile/lib/widgets/common/immich_image.dart +++ b/mobile/lib/widgets/common/immich_image.dart @@ -35,7 +35,12 @@ class ImmichImage extends StatelessWidget { } if (asset == null) { - return RemoteFullImageProvider(assetId: assetId!, thumbhash: '', assetType: base_asset.AssetType.video); + return RemoteFullImageProvider( + assetId: assetId!, + thumbhash: '', + assetType: base_asset.AssetType.video, + isAnimated: false, + ); } if (useLocal(asset)) { @@ -43,12 +48,14 @@ class ImmichImage extends StatelessWidget { id: asset.localId!, assetType: base_asset.AssetType.video, size: Size(width, height), + isAnimated: false, ); } else { return RemoteFullImageProvider( assetId: asset.remoteId!, thumbhash: asset.thumbhash ?? '', assetType: base_asset.AssetType.video, + isAnimated: false, ); } } diff --git a/mobile/openapi/lib/api/shared_links_api.dart b/mobile/openapi/lib/api/shared_links_api.dart index 37eeffcf4613c..084662ace8942 100644 --- a/mobile/openapi/lib/api/shared_links_api.dart +++ b/mobile/openapi/lib/api/shared_links_api.dart @@ -427,11 +427,7 @@ class SharedLinksApi { /// * [String] id (required): /// /// * [AssetIdsDto] assetIdsDto (required): - /// - /// * [String] key: - /// - /// * [String] slug: - Future removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async { + Future removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async { // ignore: prefer_const_declarations final apiPath = r'/shared-links/{id}/assets' .replaceAll('{id}', id); @@ -443,13 +439,6 @@ class SharedLinksApi { final headerParams = {}; final formParams = {}; - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - if (slug != null) { - queryParams.addAll(_queryParams('', 'slug', slug)); - } - const contentTypes = ['application/json']; @@ -473,12 +462,8 @@ class SharedLinksApi { /// * [String] id (required): /// /// * [AssetIdsDto] assetIdsDto (required): - /// - /// * [String] key: - /// - /// * [String] slug: - Future?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async { - final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto, key: key, slug: slug, ); + Future?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto,) async { + final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/pigeon/network_api.dart b/mobile/pigeon/network_api.dart index 3ea29052d9b14..704efed7701ae 100644 --- a/mobile/pigeon/network_api.dart +++ b/mobile/pigeon/network_api.dart @@ -43,5 +43,5 @@ abstract class NetworkApi { int getClientPointer(); - void setRequestHeaders(Map headers, List serverUrls); + void setRequestHeaders(Map headers, List serverUrls, String? token); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1011c029858ae..0b90c56cfc6b6 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11615,22 +11615,6 @@ "format": "uuid", "type": "string" } - }, - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "slug", - "required": false, - "in": "query", - "schema": { - "type": "string" - } } ], "requestBody": { @@ -11687,6 +11671,7 @@ "state": "Stable" } ], + "x-immich-permission": "sharedLink.update", "x-immich-state": "Stable" }, "put": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c83d22ad197eb..324ead7b17dd1 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -6221,19 +6221,14 @@ export function updateSharedLink({ id, sharedLinkEditDto }: { /** * Remove assets from a shared link */ -export function removeSharedLinkAssets({ id, key, slug, assetIdsDto }: { +export function removeSharedLinkAssets({ id, assetIdsDto }: { id: string; - key?: string; - slug?: string; assetIdsDto: AssetIdsDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetIdsResponseDto[]; - }>(`/shared-links/${encodeURIComponent(id)}/assets${QS.query(QS.explode({ - key, - slug - }))}`, oazapfts.json({ + }>(`/shared-links/${encodeURIComponent(id)}/assets`, oazapfts.json({ ...opts, method: "DELETE", body: assetIdsDto diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48ea252111024..98bfa1237f925 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,10 +64,10 @@ importers: version: 4.13.4 '@types/node': specifier: ^24.11.0 - version: 24.11.0 + version: 24.12.0 '@vitest/coverage-v8': specifier: ^4.0.0 - version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(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: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) byte-size: specifier: ^9.0.0 version: 9.0.1 @@ -91,7 +91,7 @@ importers: version: 63.0.0(eslint@10.0.2(jiti@2.6.1)) globals: specifier: ^17.0.0 - version: 17.3.0 + version: 17.4.0 mock-fs: specifier: ^5.2.0 version: 5.5.0 @@ -109,16 +109,16 @@ importers: version: 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.0.0 - version: 7.3.1(@types/node@24.11.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: 7.3.1(@types/node@24.12.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-tsconfig-paths: specifier: ^6.0.0 - version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.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.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.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: specifier: ^4.0.0 - version: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(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: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest-fetch-mock: specifier: ^0.4.0 - version: 0.4.5(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(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: 0.4.5(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) yaml: specifier: ^2.3.1 version: 2.8.2 @@ -221,10 +221,10 @@ importers: version: 3.7.1 '@types/node': specifier: ^24.11.0 - version: 24.11.0 + version: 24.12.0 '@types/pg': specifier: ^8.15.1 - version: 8.16.0 + version: 8.18.0 '@types/pngjs': specifier: ^6.0.4 version: 6.0.5 @@ -248,16 +248,16 @@ importers: version: 63.0.0(eslint@10.0.2(jiti@2.6.1)) exiftool-vendored: specifier: ^35.0.0 - version: 35.10.1 + version: 35.13.1 globals: specifier: ^17.0.0 - version: 17.3.0 + version: 17.4.0 luxon: specifier: ^3.4.4 version: 3.7.2 pg: specifier: ^8.11.3 - version: 8.19.0 + version: 8.20.0 pngjs: specifier: ^7.0.0 version: 7.0.0 @@ -290,10 +290,10 @@ importers: version: 5.2.1(encoding@0.1.13) vite-tsconfig-paths: specifier: ^6.1.1 - version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.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.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.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: specifier: ^4.0.0 - version: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) e2e-auth-server: devDependencies: @@ -327,7 +327,7 @@ importers: devDependencies: '@types/node': specifier: ^24.11.0 - version: 24.11.0 + version: 24.12.0 typescript: specifier: ^5.3.3 version: 5.9.3 @@ -348,13 +348,13 @@ importers: dependencies: '@aws-sdk/client-s3': specifier: ^3.1000.0 - version: 3.1002.0 + version: 3.1008.0 '@aws-sdk/lib-storage': specifier: ^3.1000.0 - version: 3.1002.0(@aws-sdk/client-s3@3.1002.0) + version: 3.1008.0(@aws-sdk/client-s3@3.1008.0) '@aws-sdk/s3-request-presigner': specifier: ^3.1000.0 - version: 3.1002.0 + version: 3.1008.0 '@extism/extism': specifier: 2.0.0-rc13 version: 2.0.0-rc13 @@ -363,58 +363,58 @@ importers: version: 0.3.2 '@nestjs/bullmq': specifier: ^11.0.1 - version: 11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(bullmq@5.70.1) + version: 11.0.4(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(bullmq@5.70.4) '@nestjs/common': specifier: ^11.0.4 - version: 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.4 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^11.0.4 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) '@nestjs/platform-socket.io': specifier: ^11.0.4 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(rxjs@7.8.2) + version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.16)(rxjs@7.8.2) '@nestjs/schedule': specifier: ^6.0.0 - version: 6.1.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + version: 6.1.1(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) '@nestjs/swagger': specifier: ^11.0.2 - version: 11.2.6(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + version: 11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) '@nestjs/websockets': specifier: ^11.0.4 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-socket.io@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 '@opentelemetry/context-async-hooks': specifier: ^2.0.0 - version: 2.5.1(@opentelemetry/api@1.9.0) + version: 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-prometheus': - specifier: ^0.212.0 - version: 0.212.0(@opentelemetry/api@1.9.0) + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-http': - specifier: ^0.212.0 - version: 0.212.0(@opentelemetry/api@1.9.0) + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-ioredis': - specifier: ^0.60.0 - version: 0.60.0(@opentelemetry/api@1.9.0) + specifier: ^0.61.0 + version: 0.61.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-nestjs-core': - specifier: ^0.58.0 - version: 0.58.0(@opentelemetry/api@1.9.0) + specifier: ^0.59.0 + version: 0.59.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-pg': - specifier: ^0.64.0 - version: 0.64.0(@opentelemetry/api@1.9.0) + specifier: ^0.65.0 + version: 0.65.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': specifier: ^2.0.1 - version: 2.5.1(@opentelemetry/api@1.9.0) + version: 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': specifier: ^2.0.1 - version: 2.5.1(@opentelemetry/api@1.9.0) + version: 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-node': - specifier: ^0.212.0 - version: 0.212.0(@opentelemetry/api@1.9.0) + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': specifier: ^1.34.0 version: 1.40.0 @@ -444,7 +444,7 @@ importers: version: 2.2.2 bullmq: specifier: ^5.51.0 - version: 5.70.1 + version: 5.70.4 chokidar: specifier: ^4.0.3 version: 4.0.3 @@ -452,8 +452,8 @@ importers: specifier: ^0.5.1 version: 0.5.1 class-validator: - specifier: ^0.14.0 - version: 0.14.4 + specifier: ^0.15.0 + version: 0.15.1 compression: specifier: ^1.8.0 version: 1.8.1 @@ -468,7 +468,7 @@ importers: version: 4.4.0 exiftool-vendored: specifier: ^35.0.0 - version: 35.10.1 + version: 35.13.1 express: specifier: ^5.1.0 version: 5.2.1 @@ -489,7 +489,7 @@ importers: version: 7.14.0 ioredis: specifier: ^5.8.2 - version: 5.9.3 + version: 5.10.0 jose: specifier: ^5.10.0 version: 5.10.0 @@ -500,11 +500,11 @@ importers: specifier: ^9.0.2 version: 9.0.3 kysely: - specifier: 0.28.2 - version: 0.28.2 + specifier: 0.28.11 + version: 0.28.11 kysely-postgres-js: specifier: ^3.0.0 - version: 3.0.0(kysely@0.28.2)(postgres@3.4.8) + version: 3.0.0(kysely@0.28.11)(postgres@3.4.8) lodash: specifier: ^4.17.21 version: 4.17.23 @@ -519,16 +519,16 @@ importers: version: 2.1.1 nest-commander: specifier: ^3.16.0 - version: 3.20.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@types/inquirer@8.2.12)(@types/node@24.11.0)(typescript@5.9.3) + version: 3.20.1(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@types/inquirer@8.2.12)(@types/node@24.12.0)(typescript@5.9.3) nestjs-cls: specifier: ^5.0.0 - version: 5.4.3(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 5.4.3(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) nestjs-kysely: specifier: 3.1.2 - version: 3.1.2(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(kysely@0.28.2)(reflect-metadata@0.2.2) + version: 3.1.2(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(kysely@0.28.11)(reflect-metadata@0.2.2) nestjs-otel: specifier: ^7.0.0 - version: 7.0.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + version: 7.0.1(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) nodemailer: specifier: ^7.0.0 version: 7.0.13 @@ -537,10 +537,10 @@ importers: version: 6.8.2 pg: specifier: ^8.11.3 - version: 8.19.0 + version: 8.20.0 pg-connection-string: specifier: ^2.9.1 - version: 2.11.0 + version: 2.12.0 picomatch: specifier: ^4.0.2 version: 4.0.3 @@ -604,16 +604,16 @@ importers: version: 10.0.1(eslint@10.0.2(jiti@2.6.1)) '@nestjs/cli': specifier: ^11.0.2 - version: 11.0.16(@swc/core@1.15.13(@swc/helpers@0.5.17))(@types/node@24.11.0) + version: 11.0.16(@swc/core@1.15.18(@swc/helpers@0.5.17))(@types/node@24.12.0) '@nestjs/schematics': specifier: ^11.0.0 version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.0.4 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-express@11.1.14) + version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16) '@swc/core': specifier: ^1.4.14 - version: 1.15.13(@swc/helpers@0.5.17) + version: 1.15.18(@swc/helpers@0.5.17) '@types/archiver': specifier: ^7.0.0 version: 7.0.0 @@ -655,10 +655,10 @@ importers: version: 4.13.4 '@types/multer': specifier: ^2.0.0 - version: 2.0.0 + version: 2.1.0 '@types/node': specifier: ^24.11.0 - version: 24.11.0 + version: 24.12.0 '@types/nodemailer': specifier: ^7.0.0 version: 7.0.11 @@ -673,7 +673,7 @@ importers: version: 19.2.14 '@types/sanitize-html': specifier: ^2.13.0 - version: 2.16.0 + version: 2.16.1 '@types/semver': specifier: ^7.5.8 version: 7.7.1 @@ -688,7 +688,7 @@ importers: version: 13.15.10 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(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: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) eslint: specifier: ^10.0.0 version: 10.0.2(jiti@2.6.1) @@ -703,7 +703,7 @@ importers: version: 63.0.0(eslint@10.0.2(jiti@2.6.1)) globals: specifier: ^17.0.0 - version: 17.3.0 + version: 17.4.0 mock-fs: specifier: ^5.2.0 version: 5.5.0 @@ -739,13 +739,13 @@ importers: version: 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) unplugin-swc: specifier: ^1.4.5 - version: 1.5.9(@swc/core@1.15.13(@swc/helpers@0.5.17))(rollup@4.55.1) + version: 1.5.9(@swc/core@1.15.18(@swc/helpers@0.5.17))(rollup@4.55.1) vite-tsconfig-paths: specifier: ^6.0.0 - version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.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.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.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: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(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: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) web: dependencies: @@ -760,7 +760,7 @@ importers: version: link:../open-api/typescript-sdk '@immich/ui': specifier: ^0.64.0 - version: 0.64.0(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(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.5) + 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) '@mapbox/mapbox-gl-rtl-text': specifier: 0.3.0 version: 0.3.0 @@ -796,13 +796,13 @@ importers: version: 0.42.0 '@zoom-image/svelte': specifier: ^0.3.0 - version: 0.3.9(svelte@5.53.5) + version: 0.3.9(svelte@5.53.7) dom-to-image: specifier: ^2.6.0 version: 2.6.0 fabric: specifier: ^7.0.0 - version: 7.2.0 + version: 7.2.0(encoding@0.1.13) geo-coordinates-parser: specifier: ^1.7.4 version: 1.7.4 @@ -814,7 +814,7 @@ importers: version: 4.7.8 happy-dom: specifier: ^20.0.0 - version: 20.7.0 + version: 20.8.3 intl-messageformat: specifier: ^11.0.0 version: 11.1.2 @@ -847,19 +847,25 @@ importers: version: 5.2.2 svelte-i18n: specifier: ^4.0.1 - version: 4.0.1(svelte@5.53.5) + version: 4.0.1(svelte@5.53.7) svelte-jsoneditor: specifier: ^3.10.0 - version: 3.11.0(svelte@5.53.5) + version: 3.11.0(svelte@5.53.7) svelte-maplibre: specifier: ^1.2.5 - version: 1.2.6(svelte@5.53.5) + version: 1.2.6(svelte@5.53.7) svelte-persisted-store: specifier: ^0.12.0 - version: 0.12.0(svelte@5.53.5) + version: 0.12.0(svelte@5.53.7) tabbable: specifier: ^6.2.0 version: 6.4.0 + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + tailwind-variants: + specifier: ^3.2.2 + version: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1) thumbhash: specifier: ^0.1.1 version: 0.1.1 @@ -884,25 +890,25 @@ importers: version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(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.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/enhanced-img': specifier: ^0.10.0 - version: 0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.5)(vite@7.3.1(@types/node@25.3.3)(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.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/kit': specifier: ^2.27.1 - version: 2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(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.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/vite-plugin-svelte': specifier: 6.2.4 - version: 6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.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)) '@tailwindcss/vite': specifier: ^4.1.7 - version: 4.2.1(vite@7.3.1(@types/node@25.3.3)(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: 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)) '@testing-library/jest-dom': specifier: ^6.4.2 version: 6.9.1 '@testing-library/svelte': specifier: ^5.2.8 - version: 5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(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.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(encoding@0.1.13)))(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) @@ -926,7 +932,7 @@ importers: version: 1.5.6 '@vitest/coverage-v8': specifier: ^4.0.0 - version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(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: 4.0.18(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(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) dotenv: specifier: ^17.0.0 version: 17.3.1 @@ -941,7 +947,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.5) + version: 3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.7) eslint-plugin-unicorn: specifier: ^63.0.0 version: 63.0.0(eslint@10.0.2(jiti@2.6.1)) @@ -950,7 +956,7 @@ importers: version: 1.4.2 globals: specifier: ^17.0.0 - version: 17.3.0 + version: 17.4.0 prettier: specifier: ^3.7.4 version: 3.8.1 @@ -962,19 +968,19 @@ importers: version: 4.2.0(prettier@3.8.1) prettier-plugin-svelte: specifier: ^3.3.3 - version: 3.5.0(prettier@3.8.1)(svelte@5.53.5) + version: 3.5.1(prettier@3.8.1)(svelte@5.53.7) rollup-plugin-visualizer: specifier: ^6.0.0 - version: 6.0.5(rollup@4.55.1) + version: 6.0.11(rollup@4.55.1) svelte: - specifier: 5.53.5 - version: 5.53.5 + specifier: 5.53.7 + version: 5.53.7 svelte-check: specifier: ^4.1.5 - version: 4.4.3(picomatch@4.0.3)(svelte@5.53.5)(typescript@5.9.3) + version: 4.4.4(picomatch@4.0.3)(svelte@5.53.7)(typescript@5.9.3) svelte-eslint-parser: specifier: ^1.3.3 - version: 1.5.1(svelte@5.53.5) + version: 1.6.0(svelte@5.53.7) tailwindcss: specifier: ^4.1.7 version: 4.2.1 @@ -986,10 +992,10 @@ importers: version: 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.1.2 - version: 7.3.1(@types/node@25.3.3)(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: 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: specifier: ^4.0.0 - version: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(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: 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(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -1163,141 +1169,141 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-s3@3.1002.0': - resolution: {integrity: sha512-tc+vZgvjcm+1Ot+YhQjXZxVELKGGGO3D5cuR4p5xaeitXYX2+RRiz4/WdSak9slumIClnlXsdqhJ0OHognUT+w==} + '@aws-sdk/client-s3@3.1008.0': + resolution: {integrity: sha512-w/SIRD25v2zVMbkn8CYIxUsac8yf5Jghkhw5j7EsNWdJhl56m/nWpUX7t1etFUW1cnzpFjZV0lXt0dNFSnbXwA==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.973.17': - resolution: {integrity: sha512-VtgGP0TjbCeyp6DQpiBqJKbemTSIaN2bZc3UbeTDCani3lBCyxn75ouJYD6koSSp0bh7rKLEbUpiFsNCI7tr0w==} + '@aws-sdk/core@3.973.19': + resolution: {integrity: sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/crc64-nvme@3.972.3': - resolution: {integrity: sha512-UExeK+EFiq5LAcbHm96CQLSia+5pvpUVSAsVApscBzayb7/6dJBJKwV4/onsk4VbWSmqxDMcfuTD+pC4RxgZHg==} + '@aws-sdk/crc64-nvme@3.972.4': + resolution: {integrity: sha512-HKZIZLbRyvzo/bXZU7Zmk6XqU+1C9DjI56xd02vwuDIxedxBEqP17t9ExhbP9QFeNq/a3l9GOcyirFXxmbDhmw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.15': - resolution: {integrity: sha512-RhHQG1lhkWHL4tK1C/KDjaOeis+9U0tAMnWDiwiSVQZMC7CsST9Xin+sK89XywJ5g/tyABtb7TvFePJ4Te5XSQ==} + '@aws-sdk/credential-provider-env@3.972.17': + resolution: {integrity: sha512-MBAMW6YELzE1SdkOniqr51mrjapQUv8JXSGxtwRjQV0mwVDutVsn22OPAUt4RcLRvdiHQmNBDEFP9iTeSVCOlA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.17': - resolution: {integrity: sha512-b/bDL76p51+yQ+0O9ZDH5nw/ioE0sRYkjwjOwFWAWZXo6it2kQZUOXhVpjohx3ldKyUxt/SwAivjUu1Nr/PWlQ==} + '@aws-sdk/credential-provider-http@3.972.19': + resolution: {integrity: sha512-9EJROO8LXll5a7eUFqu48k6BChrtokbmgeMWmsH7lBb6lVbtjslUYz/ShLi+SHkYzTomiGBhmzTW7y+H4BxsnA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.15': - resolution: {integrity: sha512-qWnM+wB8MmU2kKY7f4KowKjOjkwRosaFxrtseEEIefwoXn1SjN+CbHzXBVdTAQxxkbBiqhPgJ/WHiPtES4grRQ==} + '@aws-sdk/credential-provider-ini@3.972.19': + resolution: {integrity: sha512-pVJVjWqVrPqjpFq7o0mCmeZu1Y0c94OCHSYgivdCD2wfmYVtBbwQErakruhgOD8pcMcx9SCqRw1pzHKR7OGBcA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.15': - resolution: {integrity: sha512-x92FJy34/95wgu+qOGD8SHcgh1hZ9Qx2uFtQEGn4m9Ljou8ICIv3Ybq5yxdB7A60S8ZGCQB0mIopmjJwiLbh5g==} + '@aws-sdk/credential-provider-login@3.972.19': + resolution: {integrity: sha512-jOXdZ1o+CywQKr6gyxgxuUmnGwTTnY2Kxs1PM7fI6AYtDWDnmW/yKXayNqkF8KjP1unflqMWKVbVt5VgmE3L0g==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.16': - resolution: {integrity: sha512-7mlt14Ee4rPFAFUVgpWE7+0CBhetJJyzVFqfIsMp7sgyOSm9Y/+qHZOWAuK5I4JNc+Y5PltvJ9kssTzRo92iXQ==} + '@aws-sdk/credential-provider-node@3.972.20': + resolution: {integrity: sha512-0xHca2BnPY0kzjDYPH7vk8YbfdBPpWVS67rtqQMalYDQUCBYS37cZ55K6TuFxCoIyNZgSCFrVKr9PXC5BVvQQw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.15': - resolution: {integrity: sha512-PrH3iTeD18y/8uJvQD2s/T87BTGhsdS/1KZU7ReWHXsplBwvCqi7AbnnNbML1pFlQwRWCE2RdSZFWDVId3CvkA==} + '@aws-sdk/credential-provider-process@3.972.17': + resolution: {integrity: sha512-c8G8wT1axpJDgaP3xzcy+q8Y1fTi9A2eIQJvyhQ9xuXrUZhlCfXbC0vM9bM1CUXiZppFQ1p7g0tuUMvil/gCPg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.15': - resolution: {integrity: sha512-M/+LBHTPKZxxXckM6m4dnJeR+jlm9NynH9b2YDswN4Zj2St05SK/crdL3Wy3WfJTZootnnhm3oTh87Usl7PS7w==} + '@aws-sdk/credential-provider-sso@3.972.19': + resolution: {integrity: sha512-kVjQsEU3b///q7EZGrUzol9wzwJFKbEzqJKSq82A9ShrUTEO7FNylTtby3sPV19ndADZh1H3FB3+5ZrvKtEEeg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.15': - resolution: {integrity: sha512-QTH6k93v+UOfFam/ado8zc71tH+enTVyuvLy9uEWXX1x894dN5ovtf/MdBDgFwq3g6c9mbtgVJ4B+yBqDtXvdA==} + '@aws-sdk/credential-provider-web-identity@3.972.19': + resolution: {integrity: sha512-BV1BlTFdG4w4tAihxN7iXDBoNcNewXD4q8uZlNQiUrnqxwGWUhKHODIQVSPlQGxXClEj+63m+cqZskw+ESmeZg==} engines: {node: '>=20.0.0'} - '@aws-sdk/lib-storage@3.1002.0': - resolution: {integrity: sha512-vOzNq63BLXUTawI4/D2kU/92wZcHsng6yuIAZuW1FofCvsWORdwdRxJYame506lKZTPCNw49LIAoBT2YJ2+pGw==} + '@aws-sdk/lib-storage@3.1008.0': + resolution: {integrity: sha512-SWIzixPt9VG6jz8+tR8VklOi3FXY1J6WMfd1JEtY9gVtJaLGF5tWexIkjUnGHP8B6M6Uk+qC5zdr2Caome5Gfg==} engines: {node: '>=20.0.0'} peerDependencies: - '@aws-sdk/client-s3': ^3.1002.0 + '@aws-sdk/client-s3': ^3.1008.0 - '@aws-sdk/middleware-bucket-endpoint@3.972.6': - resolution: {integrity: sha512-3H2bhvb7Cb/S6WFsBy/Dy9q2aegC9JmGH1inO8Lb2sWirSqpLJlZmvQHPE29h2tIxzv6el/14X/tLCQ8BQU6ZQ==} + '@aws-sdk/middleware-bucket-endpoint@3.972.7': + resolution: {integrity: sha512-goX+axlJ6PQlRnzE2bQisZ8wVrlm6dXJfBzMJhd8LhAIBan/w1Kl73fJnalM/S+18VnpzIHumyV6DtgmvqG5IA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-expect-continue@3.972.6': - resolution: {integrity: sha512-QMdffpU+GkSGC+bz6WdqlclqIeCsOfgX8JFZ5xvwDtX+UTj4mIXm3uXu7Ko6dBseRcJz1FA6T9OmlAAY6JgJUg==} + '@aws-sdk/middleware-expect-continue@3.972.7': + resolution: {integrity: sha512-mvWqvm61bmZUKmmrtl2uWbokqpenY3Mc3Jf4nXB/Hse6gWxLPaCQThmhPBDzsPSV8/Odn8V6ovWt3pZ7vy4BFQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-flexible-checksums@3.973.3': - resolution: {integrity: sha512-C9Mu9pXMpQh7jBydx0MrfQxNIKwJvKbVbJJ0GZthM+cQ+KTChXA01MwttRsMq0ZRb4pBJZQtIKDUxXusDr5OKg==} + '@aws-sdk/middleware-flexible-checksums@3.973.5': + resolution: {integrity: sha512-Dp3hqE5W6hG8HQ3Uh+AINx9wjjqYmFHbxede54sGj3akx/haIQrkp85lNdTdC+ouNUcSYNiuGkzmyDREfHX1Gg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-host-header@3.972.6': - resolution: {integrity: sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w==} + '@aws-sdk/middleware-host-header@3.972.7': + resolution: {integrity: sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-location-constraint@3.972.6': - resolution: {integrity: sha512-XdZ2TLwyj3Am6kvUc67vquQvs6+D8npXvXgyEUJAdkUDx5oMFJKOqpK+UpJhVDsEL068WAJl2NEGzbSik7dGJQ==} + '@aws-sdk/middleware-location-constraint@3.972.7': + resolution: {integrity: sha512-vdK1LJfffBp87Lj0Bw3WdK1rJk9OLDYdQpqoKgmpIZPe+4+HawZ6THTbvjhJt4C4MNnRrHTKHQjkwBiIpDBoig==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-logger@3.972.6': - resolution: {integrity: sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw==} + '@aws-sdk/middleware-logger@3.972.7': + resolution: {integrity: sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-recursion-detection@3.972.6': - resolution: {integrity: sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw==} + '@aws-sdk/middleware-recursion-detection@3.972.7': + resolution: {integrity: sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.17': - resolution: {integrity: sha512-uSyOGoVFMP44pTt29MIMfsOjegqE/7lT0K3HG0GWPiH2lD4rqZC/TRi/kH4zrGiOQdsaLc+dkfd7Sb2q8vh+gA==} + '@aws-sdk/middleware-sdk-s3@3.972.19': + resolution: {integrity: sha512-/CtOHHVFg4ZuN6CnLnYkrqWgVEnbOBC4kNiKa+4fldJ9cioDt3dD/f5vpq0cWLOXwmGL2zgVrVxNhjxWpxNMkg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-ssec@3.972.6': - resolution: {integrity: sha512-acvMUX9jF4I2Ew+Z/EA6gfaFaz9ehci5wxBmXCZeulLuv8m+iGf6pY9uKz8TPjg39bdAz3hxoE0eLP8Qz+IYlA==} + '@aws-sdk/middleware-ssec@3.972.7': + resolution: {integrity: sha512-G9clGVuAml7d8DYzY6DnRi7TIIDRvZ3YpqJPz/8wnWS5fYx/FNWNmkO6iJVlVkQg9BfeMzd+bVPtPJOvC4B+nQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-user-agent@3.972.17': - resolution: {integrity: sha512-HHArkgWzomuwufXwheQqkddu763PWCpoNTq1dGjqXzJT/lojX3VlOqjNSR2Xvb6/T9ISfwYcMOcbFgUp4EWxXA==} + '@aws-sdk/middleware-user-agent@3.972.20': + resolution: {integrity: sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.996.5': - resolution: {integrity: sha512-zn0WApcULn7Rtl6T+KP2CQTZo/7wOa2YV1yHQnbijTQoi4YXQHM8s21JcJzt33/mqPh8AdvWX1f+83KvKuxlZw==} + '@aws-sdk/nested-clients@3.996.9': + resolution: {integrity: sha512-+RpVtpmQbbtzFOKhMlsRcXM/3f1Z49qTOHaA8gEpHOYruERmog6f2AUtf/oTRLCWjR9H2b3roqryV/hI7QMW8w==} engines: {node: '>=20.0.0'} - '@aws-sdk/region-config-resolver@3.972.6': - resolution: {integrity: sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw==} + '@aws-sdk/region-config-resolver@3.972.7': + resolution: {integrity: sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA==} engines: {node: '>=20.0.0'} - '@aws-sdk/s3-request-presigner@3.1002.0': - resolution: {integrity: sha512-vzbygdP2KMRoD7jheRNBlYVvrmGrwyeec+6KwHiM9AtFQ+tx4EvF8x0Wo+7FjVn1PL3t5Do7i54f4ozKCYJleQ==} + '@aws-sdk/s3-request-presigner@3.1008.0': + resolution: {integrity: sha512-YZMG/5X2TVegzLjw6H5MIIeAUlp+JtkomKOITIZ9P9XS21hRZthRmFO4eJZe0xVLGfuMYZPUYSsiD2eEQuWdQw==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.5': - resolution: {integrity: sha512-AVIhf74wRMzU1WBPVzcGPjlADF5VxZ8m8Ctm1v7eO4/reWMhZnEBn4tlR4vM4pOYFkdrYp3MTzYVZIikCO+53Q==} + '@aws-sdk/signature-v4-multi-region@3.996.7': + resolution: {integrity: sha512-mYhh7FY+7OOqjkYkd6+6GgJOsXK1xBWmuR+c5mxJPj2kr5TBNeZq+nUvE9kANWAux5UxDVrNOSiEM/wlHzC3Lg==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1002.0': - resolution: {integrity: sha512-x972uKOydFn4Rb0PZJzLdNW59rH0KWC78Q2JbQzZpGlGt0DxjYdDRwBG6F42B1MyaEwHGqO/tkGc4r3/PRFfMw==} + '@aws-sdk/token-providers@3.1008.0': + resolution: {integrity: sha512-TulwlHQBWcJs668kNUDMZHN51DeLrDsYT59Ux4a/nbvr025gM6HjKJJ3LvnZccam7OS/ZKUVkWomCneRQKJbBg==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.973.4': - resolution: {integrity: sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q==} + '@aws-sdk/types@3.973.5': + resolution: {integrity: sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-arn-parser@3.972.2': - resolution: {integrity: sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==} + '@aws-sdk/util-arn-parser@3.972.3': + resolution: {integrity: sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-endpoints@3.996.3': - resolution: {integrity: sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==} + '@aws-sdk/util-endpoints@3.996.4': + resolution: {integrity: sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-format-url@3.972.6': - resolution: {integrity: sha512-0YNVNgFyziCejXJx0rzxPiD2rkxTWco4c9wiMF6n37Tb9aQvIF8+t7GyEyIFCwQHZ0VMQaAl+nCZHOYz5I5EKw==} + '@aws-sdk/util-format-url@3.972.7': + resolution: {integrity: sha512-V+PbnWfUl93GuFwsOHsAq7hY/fnm9kElRqR8IexIJr5Rvif9e614X5sGSyz3mVSf1YAZ+VTy63W1/pGdA55zyA==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-locate-window@3.965.4': - resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==} + '@aws-sdk/util-locate-window@3.965.5': + resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-user-agent-browser@3.972.6': - resolution: {integrity: sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA==} + '@aws-sdk/util-user-agent-browser@3.972.7': + resolution: {integrity: sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw==} - '@aws-sdk/util-user-agent-node@3.973.2': - resolution: {integrity: sha512-lpaIuekdkpw7VRiik0IZmd6TyvEUcuLgKZ5fKRGpCA3I4PjrD/XH15sSwW+OptxQjNU4DEzSxag70spC9SluvA==} + '@aws-sdk/util-user-agent-node@3.973.6': + resolution: {integrity: sha512-iF7G0prk7AvmOK64FcLvc/fW+Ty1H+vttajL7PvJFReU8urMxfYmynTTuFKDTA76Wgpq3FzTPKwabMQIXQHiXQ==} engines: {node: '>=20.0.0'} peerDependencies: aws-crt: '>=1.0.0' @@ -1305,12 +1311,12 @@ packages: aws-crt: optional: true - '@aws-sdk/xml-builder@3.972.9': - resolution: {integrity: sha512-ItnlMgSqkPrUfJs7EsvU/01zw5UeIb2tNPhD09LBLHbg+g+HDiKibSLwpkuz/ZIlz4F2IMn+5XgE4AK/pfPuog==} + '@aws-sdk/xml-builder@3.972.10': + resolution: {integrity: sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==} engines: {node: '>=20.0.0'} - '@aws/lambda-invoke-store@0.2.3': - resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} '@babel/code-frame@7.29.0': @@ -1416,8 +1422,8 @@ packages: resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} engines: {node: '>=6.9.0'} - '@babel/parser@7.28.5': - resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} engines: {node: '>=6.0.0'} hasBin: true @@ -1879,8 +1885,8 @@ packages: resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.5': - resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} '@balena/dockerignore@1.0.2': @@ -3371,6 +3377,9 @@ packages: '@ioredis/commands@1.5.0': resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -3633,8 +3642,8 @@ packages: '@swc/core': optional: true - '@nestjs/common@11.1.14': - resolution: {integrity: sha512-IN/tlqd7Nl9gl6f0jsWEuOrQDaCI9vHzxv0fisHysfBQzfQIkqlv5A7w4Qge02BUQyczXT9HHPgHtWHCxhjRng==} + '@nestjs/common@11.1.16': + resolution: {integrity: sha512-JSIeW+USuMJkkcNbiOdcPkVCeI3TSnXstIVEPpp3HiaKnPRuSbUUKm9TY9o/XpIcPHWUOQItAtC5BiAwFdVITQ==} peerDependencies: class-transformer: '>=0.4.1' class-validator: '>=0.13.2' @@ -3646,8 +3655,8 @@ packages: class-validator: optional: true - '@nestjs/core@11.1.14': - resolution: {integrity: sha512-7OXPPMoDr6z+5NkoQKu4hOhfjz/YYqM3bNilPqv1WVFWrzSmuNXxvhbX69YMmNmRYascPXiwESqf5jJdjKXEww==} + '@nestjs/core@11.1.16': + resolution: {integrity: sha512-tXWXyCiqWthelJjrE0KLFjf0O98VEt+WPVx5CrqCf+059kIxJ8y1Vw7Cy7N4fwQafWNrmFL2AfN87DDMbVAY0w==} engines: {node: '>= 20'} peerDependencies: '@nestjs/common': ^11.0.0 @@ -3677,14 +3686,14 @@ packages: class-validator: optional: true - '@nestjs/platform-express@11.1.14': - resolution: {integrity: sha512-Fs+/j+mBSBSXErOQJ/YdUn/HqJGSJ4pGfiJyYOyz04l42uNVnqEakvu1kXLbxMabR6vd6/h9d6Bi4tso9p7o4Q==} + '@nestjs/platform-express@11.1.16': + resolution: {integrity: sha512-IOegr5+ZfUiMKgk+garsSU4MOkPRhm46e6w8Bp1GcO4vCdl9Piz6FlWAzKVfa/U3Hn/DdzSVJOW3TWcQQFdBDw==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 - '@nestjs/platform-socket.io@11.1.14': - resolution: {integrity: sha512-LLSIWkYz4FcvUhfepillYQboo9qbjq1YtQj8XC3zyex+EaqNXvxhZntx/1uJhAjc655pJts9HfZwWXei8jrRGw==} + '@nestjs/platform-socket.io@11.1.16': + resolution: {integrity: sha512-3fYQTi8F2hb7HDkes/ArGhY8lkjB/Df29F5CN4cjbk4cmfpRVy89p6N1BC7PjVOHMAzdwqeX8FabqspdSAnywA==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/websockets': ^11.0.0 @@ -3718,8 +3727,8 @@ packages: class-validator: optional: true - '@nestjs/testing@11.1.14': - resolution: {integrity: sha512-cQxX0ronsTbpfHz8/LYOVWXxoTxv6VoxrnuZoQaVX7QV2PSMqxWE7/9jSQR0GcqAFUEmFP34c6EJqfkjfX/k4Q==} + '@nestjs/testing@11.1.16': + resolution: {integrity: sha512-E7/aUCxzeMSJV80L5GWGIuiMyR/1ncS7uOIetAImfbS4ATE1/h2GBafk0qpk+vjFtPIbtoh9BWDGICzUEU5jDA==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -3731,8 +3740,8 @@ packages: '@nestjs/platform-express': optional: true - '@nestjs/websockets@11.1.14': - resolution: {integrity: sha512-fVP6RmmrmtLIitTXN9er7BUOIjjxcdIewN/zUtBlwgfng+qKBTxpNFOs3AXXbCu8bQr2xjzhjrBTfqri0Ske7w==} + '@nestjs/websockets@11.1.16': + resolution: {integrity: sha512-kfLhCFsq6139JVFCQpbFB6LOEjZzdpE7JzXsZtRbVjqmsgTKVSIh8gKRgzpcq27rbLNqHhhZavboOltOfSxZow==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -3775,94 +3784,94 @@ packages: '@oazapfts/runtime@1.2.0': resolution: {integrity: sha512-fi7dp7dNayyh/vzqhf0ZdoPfC7tJvYfjaE8MBL1yR+iIsH7cFoqHt+DV70VU49OMCqLc7wQa+yVJcSmIRnV4wA==} - '@opentelemetry/api-logs@0.212.0': - resolution: {integrity: sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==} + '@opentelemetry/api-logs@0.213.0': + resolution: {integrity: sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==} engines: {node: '>=8.0.0'} '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} - '@opentelemetry/configuration@0.212.0': - resolution: {integrity: sha512-D8sAY6RbqMa1W8lCeiaSL2eMCW2MF87QI3y+I6DQE1j+5GrDMwiKPLdzpa/2/+Zl9v1//74LmooCTCJBvWR8Iw==} + '@opentelemetry/configuration@0.213.0': + resolution: {integrity: sha512-MfVgZiUuwL1d3bPPvXcEkVHGTGNUGoqGK97lfwBuRoKttcVGGqDyxTCCVa5MGbirtBQkUTysXMBUVWPaq7zbWw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.9.0 - '@opentelemetry/context-async-hooks@2.5.1': - resolution: {integrity: sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==} + '@opentelemetry/context-async-hooks@2.6.0': + resolution: {integrity: sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/core@2.5.1': - resolution: {integrity: sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==} + '@opentelemetry/core@2.6.0': + resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/exporter-logs-otlp-grpc@0.212.0': - resolution: {integrity: sha512-/0bk6fQG+eSFZ4L6NlckGTgUous/ib5+OVdg0x4OdwYeHzV3lTEo3it1HgnPY6UKpmX7ki+hJvxjsOql8rCeZA==} + '@opentelemetry/exporter-logs-otlp-grpc@0.213.0': + resolution: {integrity: sha512-QiRZzvayEOFnenSXi85Eorgy5WTqyNQ+E7gjl6P6r+W3IUIwAIH8A9/BgMWfP056LwmdrBL6+qvnwaIEmug6Yg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-logs-otlp-http@0.212.0': - resolution: {integrity: sha512-JidJasLwG/7M9RTxV/64xotDKmFAUSBc9SNlxI32QYuUMK5rVKhHNWMPDzC7E0pCAL3cu+FyiKvsTwLi2KqPYw==} + '@opentelemetry/exporter-logs-otlp-http@0.213.0': + resolution: {integrity: sha512-vqDVSpLp09ZzcFIdb7QZrEFPxUlO3GzdhBKLstq3jhYB5ow3+ZtV5V0ngSdi/0BZs+J5WPiN1+UDV4X5zD/GzA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-logs-otlp-proto@0.212.0': - resolution: {integrity: sha512-RpKB5UVfxc7c6Ta1UaCrxXDTQ0OD7BCGT66a97Q5zR1x3+9fw4dSaiqMXT/6FAWj2HyFbem6Rcu1UzPZikGTWQ==} + '@opentelemetry/exporter-logs-otlp-proto@0.213.0': + resolution: {integrity: sha512-gQk41nqfK3KhDk8jbSo3LR/fQBlV7f6Q5xRcfDmL1hZlbgXQPdVFV9/rIfYUrCoq1OM+2NnKnFfGjBt6QpLSsA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0': - resolution: {integrity: sha512-/6Gqf9wpBq22XsomR1i0iPGnbQtCq2Vwnrq5oiDPjYSqveBdK1jtQbhGfmpK2mLLxk4cPDtD1ZEYdIou5K8EaA==} + '@opentelemetry/exporter-metrics-otlp-grpc@0.213.0': + resolution: {integrity: sha512-Z8gYKUAU48qwm+a1tjnGv9xbE7a5lukVIwgF6Z5i3VPXPVMe4Sjra0nN3zU7m277h+V+ZpsPGZJ2Xf0OTkL7/w==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-http@0.212.0': - resolution: {integrity: sha512-8hgBw3aTTRpSTkU4b9MLf/2YVLnfWp+hfnLq/1Fa2cky+vx6HqTodo+Zv1GTIrAKMOOwgysOjufy0gTxngqeBg==} + '@opentelemetry/exporter-metrics-otlp-http@0.213.0': + resolution: {integrity: sha512-yw3fTIw4KQIRXC/ZyYQq5gtA3Ogfdfz/g5HVgleobQAcjUUE8Nj3spGMx8iQPp+S+u6/js7BixufRkXhzLmpJA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-proto@0.212.0': - resolution: {integrity: sha512-C7I4WN+ghn3g7SnxXm2RK3/sRD0k/BYcXaK6lGU3yPjiM7a1M25MLuM6zY3PeVPPzzTZPfuS7+wgn/tHk768Xw==} + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0': + resolution: {integrity: sha512-geHF+zZaDb0/WRkJTxR8o8dG4fCWT/Wq7HBdNZCxwH5mxhwRi/5f37IDYH7nvU+dwU6IeY4Pg8TPI435JCiNkg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-prometheus@0.212.0': - resolution: {integrity: sha512-hJFLhCJba5MW5QHexZMHZdMhBfNqNItxOsN0AZojwD1W2kU9xM+BEICowFGJFo/vNV+I2BJvTtmuKafeDSAo7Q==} + '@opentelemetry/exporter-prometheus@0.213.0': + resolution: {integrity: sha512-FyV3/JfKGAgx+zJUwCHdjQHbs+YeGd2fOWvBHYrW6dmfv/w89lb8WhJTSZEoWgP525jwv/gFeBttlGu1flebdA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-grpc@0.212.0': - resolution: {integrity: sha512-9xTuYWp8ClBhljDGAoa0NSsJcsxJsC9zCFKMSZJp1Osb9pjXCMRdA6fwXtlubyqe7w8FH16EWtQNKx/FWi+Ghw==} + '@opentelemetry/exporter-trace-otlp-grpc@0.213.0': + resolution: {integrity: sha512-L8y6piP4jBIIx1Nv7/9hkx25ql6/Cro/kQrs+f9e8bPF0Ar5Dm991v7PnbtubKz6Q4fT872H56QXUWVnz/Cs4Q==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-http@0.212.0': - resolution: {integrity: sha512-v/0wMozNoiEPRolzC4YoPo4rAT0q8r7aqdnRw3Nu7IDN0CGFzNQazkfAlBJ6N5y0FYJkban7Aw5WnN73//6YlA==} + '@opentelemetry/exporter-trace-otlp-http@0.213.0': + resolution: {integrity: sha512-tnRmJD39aWrE/Sp7F6AbRNAjKHToDkAqBi6i0lESpGWz3G+f4bhVAV6mgSXH2o18lrDVJXo6jf9bAywQw43wRA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-proto@0.212.0': - resolution: {integrity: sha512-d1ivqPT0V+i0IVOOdzGaLqonjtlk5jYrW7ItutWzXL/Mk+PiYb59dymy/i2reot9dDnBFWfrsvxyqdutGF5Vig==} + '@opentelemetry/exporter-trace-otlp-proto@0.213.0': + resolution: {integrity: sha512-six3vPq3sL+ge1iZOfKEg+RHuFQhGb8ZTdlvD234w/0gi8ty/qKD46qoGpKvM3amy5yYunWBKiFBW47WaVS26w==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-zipkin@2.5.1': - resolution: {integrity: sha512-Me6JVO7WqXGXsgr4+7o+B7qwKJQbt0c8WamFnxpkR43avgG9k/niTntwCaXiXUTjonWy0+61ZuX6CGzj9nn8CQ==} + '@opentelemetry/exporter-zipkin@2.6.0': + resolution: {integrity: sha512-AFP77OQMLfw/Jzh6WT2PtrywstNjdoyT9t9lYrYdk1s4igsvnMZ8DkZKCwxsItC01D+4Lydgrb+Wy0bAvpp8xg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.0.0 @@ -3873,62 +3882,62 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-http@0.212.0': - resolution: {integrity: sha512-t2nt16Uyv9irgR+tqnX96YeToOStc3X5js7Ljn3EKlI2b4Fe76VhMkTXtsTQ0aId6AsYgefrCRnXSCo/Fn/vww==} + '@opentelemetry/instrumentation-http@0.213.0': + resolution: {integrity: sha512-B978Xsm5XEPGhm1P07grDoaOFLHapJPkOG9h016cJsyWWxmiLnPu2M/4Nrm7UCkHSiLnkXgC+zVGUAIahy8EEA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-ioredis@0.60.0': - resolution: {integrity: sha512-R+nnbPD9l2ruzu248qM3YDWzpdmWVaFFFv08lQqsc0EP4pT/B1GGUg06/tHOSo3L5njB2eejwyzpkvJkjaQEMA==} + '@opentelemetry/instrumentation-ioredis@0.61.0': + resolution: {integrity: sha512-hsHDadUtAFbws1YSDc1XW0svGFKiUbqv2td1Cby+UAiwvojm1NyBo/taifH0t8CuFZ0x/2SDm0iuTwrM5pnVOg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-nestjs-core@0.58.0': - resolution: {integrity: sha512-0lE9oW8j6nmvBHJoOxIQgKzMQQYNfX1nhiWZdXD0sNAMFsWBtvECWS7NAPSroKrEP53I04TcHCyyhcK4I9voXg==} + '@opentelemetry/instrumentation-nestjs-core@0.59.0': + resolution: {integrity: sha512-tt2cFTENV8XB3D3xjhOz0q4hLc1eqkMZS5UyT9nnHF5FfYH94S2vAGdssvsMv+pFtA6/PmhPUZd4onUN1O7STg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-pg@0.64.0': - resolution: {integrity: sha512-NbfB/rlfsRI3zpTjnbvJv3qwuoGLsN8FxR/XoI+ZTn1Rs62x1IenO+TSSvk4NO+7FlXpd2MiOe8LT/oNbydHGA==} + '@opentelemetry/instrumentation-pg@0.65.0': + resolution: {integrity: sha512-W0zpHEIEuyZ8zvb3njaX9AAbHgPYOsSWVOoWmv1sjVRSF6ZpBqtlxBWbU+6hhq1TFWBeWJOXZ8nZS/PUFpLJYQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation@0.212.0': - resolution: {integrity: sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==} + '@opentelemetry/instrumentation@0.213.0': + resolution: {integrity: sha512-3i9NdkET/KvQomeh7UaR/F4r9P25Rx6ooALlWXPIjypcEOUxksCmVu0zA70NBJWlrMW1rPr/LRidFAflLI+s/w==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-exporter-base@0.212.0': - resolution: {integrity: sha512-HoMv5pQlzbuxiMS0hN7oiUtg8RsJR5T7EhZccumIWxYfNo/f4wFc7LPDfFK6oHdG2JF/+qTocfqIHoom+7kLpw==} + '@opentelemetry/otlp-exporter-base@0.213.0': + resolution: {integrity: sha512-MegxAP1/n09Ob2dQvY5NBDVjAFkZRuKtWKxYev1R2M8hrsgXzQGkaMgoEKeUOyQ0FUyYcO29UOnYdQWmWa0PXg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-grpc-exporter-base@0.212.0': - resolution: {integrity: sha512-YidOSlzpsun9uw0iyIWrQp6HxpMtBlECE3tiHGAsnpEqJWbAUWcMnIffvIuvTtTQ1OyRtwwaE79dWSQ8+eiB7g==} + '@opentelemetry/otlp-grpc-exporter-base@0.213.0': + resolution: {integrity: sha512-XgRGuLE9usFNlnw2lgMIM4HTwpcIyjdU/xPoJ8v3LbBLBfjaDkIugjc9HoWa7ZSJ/9Bhzgvm/aD0bGdYUFgnTw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-transformer@0.212.0': - resolution: {integrity: sha512-bj7zYFOg6Db7NUwsRZQ/WoVXpAf41WY2gsd3kShSfdpZQDRKHWJiRZIg7A8HvWsf97wb05rMFzPbmSHyjEl9tw==} + '@opentelemetry/otlp-transformer@0.213.0': + resolution: {integrity: sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/propagator-b3@2.5.1': - resolution: {integrity: sha512-AU6sZgunZrZv/LTeHP+9IQsSSH5p3PtOfDPe8VTdwYH69nZCfvvvXehhzu+9fMW2mgJMh5RVpiH8M9xuYOu5Dg==} + '@opentelemetry/propagator-b3@2.6.0': + resolution: {integrity: sha512-SguK4jMmRvQ0c0dxAMl6K+Eu1+01X0OP7RLiIuHFjOS8hlB23ZYNnhnbAdSQEh5xVXQmH0OAS0TnmVI+6vB2Kg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/propagator-jaeger@2.5.1': - resolution: {integrity: sha512-8+SB94/aSIOVGDUPRFSBRHVUm2A8ye1vC6/qcf/D+TF4qat7PC6rbJhRxiUGDXZtMtKEPM/glgv5cBGSJQymSg==} + '@opentelemetry/propagator-jaeger@2.6.0': + resolution: {integrity: sha512-KGWJuvp9X8X36bhHgIhWEnHAzXDInFr+Fvo9IQhhuu6pXLT8mF7HzFyx/X+auZUITvPaZhM39Phj3vK12MbhwA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -3937,38 +3946,38 @@ packages: resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} engines: {node: ^18.19.0 || >=20.6.0} - '@opentelemetry/resources@2.5.1': - resolution: {integrity: sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==} + '@opentelemetry/resources@2.6.0': + resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-logs@0.212.0': - resolution: {integrity: sha512-qglb5cqTf0mOC1sDdZ7nfrPjgmAqs2OxkzOPIf2+Rqx8yKBK0pS7wRtB1xH30rqahBIut9QJDbDePyvtyqvH/Q==} + '@opentelemetry/sdk-logs@0.213.0': + resolution: {integrity: sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.4.0 <1.10.0' - '@opentelemetry/sdk-metrics@2.5.1': - resolution: {integrity: sha512-RKMn3QKi8nE71ULUo0g/MBvq1N4icEBo7cQSKnL3URZT16/YH3nSVgWegOjwx7FRBTrjOIkMJkCUn/ZFIEfn4A==} + '@opentelemetry/sdk-metrics@2.6.0': + resolution: {integrity: sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.9.0 <1.10.0' - '@opentelemetry/sdk-node@0.212.0': - resolution: {integrity: sha512-tJzVDk4Lo44MdgJLlP+gdYdMnjxSNsjC/IiTxj5CFSnsjzpHXwifgl3BpUX67Ty3KcdubNVfedeBc/TlqHXwwg==} + '@opentelemetry/sdk-node@0.213.0': + resolution: {integrity: sha512-8s7SQtY8DIAjraXFrUf0+I90SBAUQbsMWMtUGKmusswRHWXtKJx42aJQMoxEtC82Csqj+IlBH6FoP8XmmUDSrQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace-base@2.5.1': - resolution: {integrity: sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==} + '@opentelemetry/sdk-trace-base@2.6.0': + resolution: {integrity: sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace-node@2.5.1': - resolution: {integrity: sha512-9lopQ6ZoElETOEN0csgmtEV5/9C7BMfA7VtF4Jape3i954b6sTY2k3Xw3CxUTKreDck/vpAuJM+EDo4zheUw+A==} + '@opentelemetry/sdk-trace-node@2.6.0': + resolution: {integrity: sha512-YhswtasmsbIGEFvLGvR9p/y3PVRTfFf+mgY8van4Ygpnv4sA3vooAjvh+qAn9PNWxs4/IwGGqiQS0PPsaRJ0vQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -4104,8 +4113,8 @@ packages: peerDependencies: '@photo-sphere-viewer/core': 5.14.1 - '@photostructure/tz-lookup@11.4.0': - resolution: {integrity: sha512-yrFaDbQQZVJIzpCTnoghWO8Rttu22Hg7/JkfP3CM8UKniXYzD80cuv4UAsFkzP5Z6XWceWNsQTqUJHKyGNXzLg==} + '@photostructure/tz-lookup@11.5.0': + resolution: {integrity: sha512-0DVFriinZ7TeOnm9ytXeSL3NMFU87ZqMjgbPNkd8LgHFLcPg1BDyM1eewFYs+pPM+62S4fSP9Mtgijmn+6y95w==} '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} @@ -4479,220 +4488,220 @@ packages: '@slorber/remark-comment@1.0.0': resolution: {integrity: sha512-RCE24n7jsOj1M0UPvIQCHTe7fI0sFL4S2nwKVWwHyVr/wI/H8GosgsJGyhnsZoGFnD/P2hLf1mSbrrgSLN93NA==} - '@smithy/abort-controller@4.2.10': - resolution: {integrity: sha512-qocxM/X4XGATqQtUkbE9SPUB6wekBi+FyJOMbPj0AhvyvFGYEmOlz6VB22iMePCQsFmMIvFSeViDvA7mZJG47g==} + '@smithy/abort-controller@4.2.12': + resolution: {integrity: sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==} engines: {node: '>=18.0.0'} - '@smithy/chunked-blob-reader-native@4.2.2': - resolution: {integrity: sha512-QzzYIlf4yg0w5TQaC9VId3B3ugSk1MI/wb7tgcHtd7CBV9gNRKZrhc2EPSxSZuDy10zUZ0lomNMgkc6/VVe8xg==} + '@smithy/chunked-blob-reader-native@4.2.3': + resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==} engines: {node: '>=18.0.0'} - '@smithy/chunked-blob-reader@5.2.1': - resolution: {integrity: sha512-y5d4xRiD6TzeP5BWlb+Ig/VFqF+t9oANNhGeMqyzU7obw7FYgTgVi50i5JqBTeKp+TABeDIeeXFZdz65RipNtA==} + '@smithy/chunked-blob-reader@5.2.2': + resolution: {integrity: sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==} engines: {node: '>=18.0.0'} - '@smithy/config-resolver@4.4.9': - resolution: {integrity: sha512-ejQvXqlcU30h7liR9fXtj7PIAau1t/sFbJpgWPfiYDs7zd16jpH0IsSXKcba2jF6ChTXvIjACs27kNMc5xxE2Q==} + '@smithy/config-resolver@4.4.11': + resolution: {integrity: sha512-YxFiiG4YDAtX7WMN7RuhHZLeTmRRAOyCbr+zB8e3AQzHPnUhS8zXjB1+cniPVQI3xbWsQPM0X2aaIkO/ME0ymw==} engines: {node: '>=18.0.0'} - '@smithy/core@3.23.7': - resolution: {integrity: sha512-/+ldRdtiO5Cb26afAZOG1FZM0x7D4AYdjpyOv2OScJw+4C7X+OLdRnNKF5UyUE0VpPgSKr3rnF/kvprRA4h2kg==} + '@smithy/core@3.23.11': + resolution: {integrity: sha512-952rGf7hBRnhUIaeLp6q4MptKW8sPFe5VvkoZ5qIzFAtx6c/QZ/54FS3yootsyUSf9gJX/NBqEBNdNR7jMIlpQ==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.2.10': - resolution: {integrity: sha512-3bsMLJJLTZGZqVGGeBVFfLzuRulVsGTj12BzRKODTHqUABpIr0jMN1vN3+u6r2OfyhAQ2pXaMZWX/swBK5I6PQ==} + '@smithy/credential-provider-imds@4.2.12': + resolution: {integrity: sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-codec@4.2.10': - resolution: {integrity: sha512-A4ynrsFFfSXUHicfTcRehytppFBcY3HQxEGYiyGktPIOye3Ot7fxpiy4VR42WmtGI4Wfo6OXt/c1Ky1nUFxYYQ==} + '@smithy/eventstream-codec@4.2.12': + resolution: {integrity: sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-browser@4.2.10': - resolution: {integrity: sha512-0xupsu9yj9oDVuQ50YCTS9nuSYhGlrwqdaKQel9y2Fz7LU9fNErVlw9N0o4pm4qqvWEGbSTI4HKc6XJfB30MVw==} + '@smithy/eventstream-serde-browser@4.2.12': + resolution: {integrity: sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-config-resolver@4.3.10': - resolution: {integrity: sha512-8kn6sinrduk0yaYHMJDsNuiFpXwQwibR7n/4CDUqn4UgaG+SeBHu5jHGFdU9BLFAM7Q4/gvr9RYxBHz9/jKrhA==} + '@smithy/eventstream-serde-config-resolver@4.3.12': + resolution: {integrity: sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-node@4.2.10': - resolution: {integrity: sha512-uUrxPGgIffnYfvIOUmBM5i+USdEBRTdh7mLPttjphgtooxQ8CtdO1p6K5+Q4BBAZvKlvtJ9jWyrWpBJYzBKsyQ==} + '@smithy/eventstream-serde-node@4.2.12': + resolution: {integrity: sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-universal@4.2.10': - resolution: {integrity: sha512-aArqzOEvcs2dK+xQVCgLbpJQGfZihw8SD4ymhkwNTtwKbnrzdhJsFDKuMQnam2kF69WzgJYOU5eJlCx+CA32bw==} + '@smithy/eventstream-serde-universal@4.2.12': + resolution: {integrity: sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.3.12': - resolution: {integrity: sha512-muS5tFw+A/uo+U+yig06vk1776UFM+aAp9hFM8efI4ZcHhTcgv6NTeK4x7ltHeMPBwnhEjcf0MULTyxNkSNxDw==} + '@smithy/fetch-http-handler@5.3.15': + resolution: {integrity: sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==} engines: {node: '>=18.0.0'} - '@smithy/hash-blob-browser@4.2.11': - resolution: {integrity: sha512-DrcAx3PM6AEbWZxsKl6CWAGnVwiz28Wp1ZhNu+Hi4uI/6C1PIZBIaPM2VoqBDAsOWbM6ZVzOEQMxFLLdmb4eBQ==} + '@smithy/hash-blob-browser@4.2.13': + resolution: {integrity: sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g==} engines: {node: '>=18.0.0'} - '@smithy/hash-node@4.2.10': - resolution: {integrity: sha512-1VzIOI5CcsvMDvP3iv1vG/RfLJVVVc67dCRyLSB2Hn9SWCZrDO3zvcIzj3BfEtqRW5kcMg5KAeVf1K3dR6nD3w==} + '@smithy/hash-node@4.2.12': + resolution: {integrity: sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==} engines: {node: '>=18.0.0'} - '@smithy/hash-stream-node@4.2.10': - resolution: {integrity: sha512-w78xsYrOlwXKwN5tv1GnKIRbHb1HygSpeZMP6xDxCPGf1U/xDHjCpJu64c5T35UKyEPwa0bPeIcvU69VY3khUA==} + '@smithy/hash-stream-node@4.2.12': + resolution: {integrity: sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw==} engines: {node: '>=18.0.0'} - '@smithy/invalid-dependency@4.2.10': - resolution: {integrity: sha512-vy9KPNSFUU0ajFYk0sDZIYiUlAWGEAhRfehIr5ZkdFrRFTAuXEPUd41USuqHU6vvLX4r6Q9X7MKBco5+Il0Org==} + '@smithy/invalid-dependency@4.2.12': + resolution: {integrity: sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==} engines: {node: '>=18.0.0'} '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} - '@smithy/is-array-buffer@4.2.1': - resolution: {integrity: sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==} + '@smithy/is-array-buffer@4.2.2': + resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} engines: {node: '>=18.0.0'} - '@smithy/md5-js@4.2.10': - resolution: {integrity: sha512-Op+Dh6dPLWTjWITChFayDllIaCXRofOed8ecpggTC5fkh8yXes0vAEX7gRUfjGK+TlyxoCAA05gHbZW/zB9JwQ==} + '@smithy/md5-js@4.2.12': + resolution: {integrity: sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ==} engines: {node: '>=18.0.0'} - '@smithy/middleware-content-length@4.2.10': - resolution: {integrity: sha512-TQZ9kX5c6XbjhaEBpvhSvMEZ0klBs1CFtOdPFwATZSbC9UeQfKHPLPN9Y+I6wZGMOavlYTOlHEPDrt42PMSH9w==} + '@smithy/middleware-content-length@4.2.12': + resolution: {integrity: sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==} engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.4.21': - resolution: {integrity: sha512-CoVGZaqIC0tEjz0ga3ciwCMA5fd/4lIOwO2wx0fH+cTi1zxSFZnMJbIiIF9G1d4vRSDyTupDrpS3FKBBJGkRZg==} + '@smithy/middleware-endpoint@4.4.25': + resolution: {integrity: sha512-dqjLwZs2eBxIUG6Qtw8/YZ4DvzHGIf0DA18wrgtfP6a50UIO7e2nY0FPdcbv5tVJKqWCCU5BmGMOUwT7Puan+A==} engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.4.38': - resolution: {integrity: sha512-WdHvdhjE6Fj78vxFwDKFDwlqGOGRUWrwGeuENUbTVE46Su9mnQM+dXHtbnCaQvwuSYrRsjpe8zUsFpwUp/azlA==} + '@smithy/middleware-retry@4.4.42': + resolution: {integrity: sha512-vbwyqHRIpIZutNXZpLAozakzamcINaRCpEy1MYmK6xBeW3xN+TyPRA123GjXnuxZIjc9848MRRCugVMTXxC4Eg==} engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@4.2.11': - resolution: {integrity: sha512-STQdONGPwbbC7cusL60s7vOa6He6A9w2jWhoapL0mgVjmR19pr26slV+yoSP76SIssMTX/95e5nOZ6UQv6jolg==} + '@smithy/middleware-serde@4.2.14': + resolution: {integrity: sha512-+CcaLoLa5apzSRtloOyG7lQvkUw2ZDml3hRh4QiG9WyEPfW5Ke/3tPOPiPjUneuT59Tpn8+c3RVaUvvkkwqZwg==} engines: {node: '>=18.0.0'} - '@smithy/middleware-stack@4.2.10': - resolution: {integrity: sha512-pmts/WovNcE/tlyHa8z/groPeOtqtEpp61q3W0nW1nDJuMq/x+hWa/OVQBtgU0tBqupeXq0VBOLA4UZwE8I0YA==} + '@smithy/middleware-stack@4.2.12': + resolution: {integrity: sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==} engines: {node: '>=18.0.0'} - '@smithy/node-config-provider@4.3.10': - resolution: {integrity: sha512-UALRbJtVX34AdP2VECKVlnNgidLHA2A7YgcJzwSBg1hzmnO/bZBHl/LDQQyYifzUwp1UOODnl9JJ3KNawpUJ9w==} + '@smithy/node-config-provider@4.3.12': + resolution: {integrity: sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.4.13': - resolution: {integrity: sha512-o8CP8w6tlUA0lk+Qfwm6Ed0jCWk3bEY6iBOJjdBaowbXKCSClk8zIHQvUL6RUZMvuNafF27cbRCMYqw6O1v4aA==} + '@smithy/node-http-handler@4.4.16': + resolution: {integrity: sha512-ULC8UCS/HivdCB3jhi+kLFYe4B5gxH2gi9vHBfEIiRrT2jfKiZNiETJSlzRtE6B26XbBHjPtc8iZKSNqMol9bw==} engines: {node: '>=18.0.0'} - '@smithy/property-provider@4.2.10': - resolution: {integrity: sha512-5jm60P0CU7tom0eNrZ7YrkgBaoLFXzmqB0wVS+4uK8PPGmosSrLNf6rRd50UBvukztawZ7zyA8TxlrKpF5z9jw==} + '@smithy/property-provider@4.2.12': + resolution: {integrity: sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==} engines: {node: '>=18.0.0'} - '@smithy/protocol-http@5.3.10': - resolution: {integrity: sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A==} + '@smithy/protocol-http@5.3.12': + resolution: {integrity: sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==} engines: {node: '>=18.0.0'} - '@smithy/querystring-builder@4.2.10': - resolution: {integrity: sha512-HeN7kEvuzO2DmAzLukE9UryiUvejD3tMp9a1D1NJETerIfKobBUCLfviP6QEk500166eD2IATaXM59qgUI+YDA==} + '@smithy/querystring-builder@4.2.12': + resolution: {integrity: sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==} engines: {node: '>=18.0.0'} - '@smithy/querystring-parser@4.2.10': - resolution: {integrity: sha512-4Mh18J26+ao1oX5wXJfWlTT+Q1OpDR8ssiC9PDOuEgVBGloqg18Fw7h5Ct8DyT9NBYwJgtJ2nLjKKFU6RP1G1Q==} + '@smithy/querystring-parser@4.2.12': + resolution: {integrity: sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==} engines: {node: '>=18.0.0'} - '@smithy/service-error-classification@4.2.10': - resolution: {integrity: sha512-0R/+/Il5y8nB/By90o8hy/bWVYptbIfvoTYad0igYQO5RefhNCDmNzqxaMx7K1t/QWo0d6UynqpqN5cCQt1MCg==} + '@smithy/service-error-classification@4.2.12': + resolution: {integrity: sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==} engines: {node: '>=18.0.0'} - '@smithy/shared-ini-file-loader@4.4.5': - resolution: {integrity: sha512-pHgASxl50rrtOztgQCPmOXFjRW+mCd7ALr/3uXNzRrRoGV5G2+78GOsQ3HlQuBVHCh9o6xqMNvlIKZjWn4Euug==} + '@smithy/shared-ini-file-loader@4.4.7': + resolution: {integrity: sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.3.10': - resolution: {integrity: sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA==} + '@smithy/signature-v4@5.3.12': + resolution: {integrity: sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==} engines: {node: '>=18.0.0'} - '@smithy/smithy-client@4.12.1': - resolution: {integrity: sha512-Xf9UFHlAihewfkmLNZ6I/Ek6kcYBKoU3cbRS9Z4q++9GWoW0YFbAHs7wMbuXm+nGuKHZ5OKheZMuDdaWPv8DJw==} + '@smithy/smithy-client@4.12.5': + resolution: {integrity: sha512-UqwYawyqSr/aog8mnLnfbPurS0gi4G7IYDcD28cUIBhsvWs1+rQcL2IwkUQ+QZ7dibaoRzhNF99fAQ9AUcO00w==} engines: {node: '>=18.0.0'} - '@smithy/types@4.13.0': - resolution: {integrity: sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==} + '@smithy/types@4.13.1': + resolution: {integrity: sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==} engines: {node: '>=18.0.0'} - '@smithy/url-parser@4.2.10': - resolution: {integrity: sha512-uypjF7fCDsRk26u3qHmFI/ePL7bxxB9vKkE+2WKEciHhz+4QtbzWiHRVNRJwU3cKhrYDYQE3b0MRFtqfLYdA4A==} + '@smithy/url-parser@4.2.12': + resolution: {integrity: sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==} engines: {node: '>=18.0.0'} - '@smithy/util-base64@4.3.1': - resolution: {integrity: sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==} + '@smithy/util-base64@4.3.2': + resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} engines: {node: '>=18.0.0'} - '@smithy/util-body-length-browser@4.2.1': - resolution: {integrity: sha512-SiJeLiozrAoCrgDBUgsVbmqHmMgg/2bA15AzcbcW+zan7SuyAVHN4xTSbq0GlebAIwlcaX32xacnrG488/J/6g==} + '@smithy/util-body-length-browser@4.2.2': + resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} engines: {node: '>=18.0.0'} - '@smithy/util-body-length-node@4.2.2': - resolution: {integrity: sha512-4rHqBvxtJEBvsZcFQSPQqXP2b/yy/YlB66KlcEgcH2WNoOKCKB03DSLzXmOsXjbl8dJ4OEYTn31knhdznwk7zw==} + '@smithy/util-body-length-node@4.2.3': + resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} engines: {node: '>=18.0.0'} '@smithy/util-buffer-from@2.2.0': resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} - '@smithy/util-buffer-from@4.2.1': - resolution: {integrity: sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==} + '@smithy/util-buffer-from@4.2.2': + resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} engines: {node: '>=18.0.0'} - '@smithy/util-config-provider@4.2.1': - resolution: {integrity: sha512-462id/00U8JWFw6qBuTSWfN5TxOHvDu4WliI97qOIOnuC/g+NDAknTU8eoGXEPlLkRVgWEr03jJBLV4o2FL8+A==} + '@smithy/util-config-provider@4.2.2': + resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.37': - resolution: {integrity: sha512-JlPZhV1kQCGNJgofRTU6E8kHrjCKsb6cps8gco8QDVaFl7biFYzHg0p1x89ytIWyVyCkY3nOpO8tJPM47Vqlww==} + '@smithy/util-defaults-mode-browser@4.3.41': + resolution: {integrity: sha512-M1w1Ux0rSVvBOxIIiqbxvZvhnjQ+VUjJrugtORE90BbadSTH+jsQL279KRL3Hv0w69rE7EuYkV/4Lepz/NBW9g==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.40': - resolution: {integrity: sha512-BM5cPEsyxHdYYO4Da77E94lenhaVPNUzBTyCGDkcw/n/mE8Q1cfHwr+n/w2bNPuUsPC30WaW5/hGKWOTKqw8kw==} + '@smithy/util-defaults-mode-node@4.2.44': + resolution: {integrity: sha512-YPze3/lD1KmWuZsl9JlfhcgGLX7AXhSoaCDtiPntUjNW5/YY0lOHjkcgxyE9x/h5vvS1fzDifMGjzqnNlNiqOQ==} engines: {node: '>=18.0.0'} - '@smithy/util-endpoints@3.3.1': - resolution: {integrity: sha512-xyctc4klmjmieQiF9I1wssBWleRV0RhJ2DpO8+8yzi2LO1Z+4IWOZNGZGNj4+hq9kdo+nyfrRLmQTzc16Op2Vg==} + '@smithy/util-endpoints@3.3.3': + resolution: {integrity: sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==} engines: {node: '>=18.0.0'} - '@smithy/util-hex-encoding@4.2.1': - resolution: {integrity: sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==} + '@smithy/util-hex-encoding@4.2.2': + resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} engines: {node: '>=18.0.0'} - '@smithy/util-middleware@4.2.10': - resolution: {integrity: sha512-LxaQIWLp4y0r72eA8mwPNQ9va4h5KeLM0I3M/HV9klmFaY2kN766wf5vsTzmaOpNNb7GgXAd9a25P3h8T49PSA==} + '@smithy/util-middleware@4.2.12': + resolution: {integrity: sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==} engines: {node: '>=18.0.0'} - '@smithy/util-retry@4.2.10': - resolution: {integrity: sha512-HrBzistfpyE5uqTwiyLsFHscgnwB0kgv8vySp7q5kZ0Eltn/tjosaSGGDj/jJ9ys7pWzIP/icE2d+7vMKXLv7A==} + '@smithy/util-retry@4.2.12': + resolution: {integrity: sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==} engines: {node: '>=18.0.0'} - '@smithy/util-stream@4.5.16': - resolution: {integrity: sha512-c7awZV6cxY0czgDDSr+Bz0XfRtg8AwW2BWhrHhLJISrpmwv8QzA2qzTllWyMVNdy1+UJr9vCm29hzuh3l8TTFw==} + '@smithy/util-stream@4.5.19': + resolution: {integrity: sha512-v4sa+3xTweL1CLO2UP0p7tvIMH/Rq1X4KKOxd568mpe6LSLMQCnDHs4uv7m3ukpl3HvcN2JH6jiCS0SNRXKP/w==} engines: {node: '>=18.0.0'} - '@smithy/util-uri-escape@4.2.1': - resolution: {integrity: sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q==} + '@smithy/util-uri-escape@4.2.2': + resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} engines: {node: '>=18.0.0'} '@smithy/util-utf8@2.3.0': resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} - '@smithy/util-utf8@4.2.1': - resolution: {integrity: sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==} + '@smithy/util-utf8@4.2.2': + resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} engines: {node: '>=18.0.0'} - '@smithy/util-waiter@4.2.10': - resolution: {integrity: sha512-4eTWph/Lkg1wZEDAyObwme0kmhEb7J/JjibY2znJdrYRgKbKqB7YoEhhJVJ4R1g/SYih4zuwX7LpJaM8RsnTVg==} + '@smithy/util-waiter@4.2.13': + resolution: {integrity: sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ==} engines: {node: '>=18.0.0'} - '@smithy/uuid@1.1.1': - resolution: {integrity: sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw==} + '@smithy/uuid@1.1.2': + resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} '@socket.io/component-emitter@3.1.2': @@ -4727,8 +4736,8 @@ packages: svelte: ^5.0.0 vite: ^6.3.0 || >=7.0.0 - '@sveltejs/kit@2.53.3': - resolution: {integrity: sha512-tshOeBUid2v5LAblUpatIdFm5Cyykbw2EiKWOunAAX0A/oJaR7DOdC9wLR5Qqh9zUf3QUISA2m9A3suBdQSYQg==} + '@sveltejs/kit@2.53.4': + resolution: {integrity: sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==} engines: {node: '>=18.13'} hasBin: true peerDependencies: @@ -4836,72 +4845,72 @@ packages: resolution: {integrity: sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==} engines: {node: '>=14'} - '@swc/core-darwin-arm64@1.15.13': - resolution: {integrity: sha512-ztXusRuC5NV2w+a6pDhX13CGioMLq8CjX5P4XgVJ21ocqz9t19288Do0y8LklplDtwcEhYGTNdMbkmUT7+lDTg==} + '@swc/core-darwin-arm64@1.15.18': + resolution: {integrity: sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.15.13': - resolution: {integrity: sha512-cVifxQUKhaE7qcO/y9Mq6PEhoyvN9tSLzCnnFZ4EIabFHBuLtDDO6a+vLveOy98hAs5Qu1+bb5Nv0oa1Pihe3Q==} + '@swc/core-darwin-x64@1.15.18': + resolution: {integrity: sha512-wZle0eaQhnzxWX5V/2kEOI6Z9vl/lTFEC6V4EWcn+5pDjhemCpQv9e/TDJ0GIoiClX8EDWRvuZwh+Z3dhL1NAg==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.15.13': - resolution: {integrity: sha512-t+xxEzZ48enl/wGGy7SRYd7kImWQ/+wvVFD7g5JZo234g6/QnIgZ+YdfIyjHB+ZJI3F7a2IQHS7RNjxF29UkWw==} + '@swc/core-linux-arm-gnueabihf@1.15.18': + resolution: {integrity: sha512-ao61HGXVqrJFHAcPtF4/DegmwEkVCo4HApnotLU8ognfmU8x589z7+tcf3hU+qBiU1WOXV5fQX6W9Nzs6hjxDw==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.15.13': - resolution: {integrity: sha512-VndeGvKmTXFn6AGwjy0Kg8i7HccOCE7Jt/vmZwRxGtOfNZM1RLYRQ7MfDLo6T0h1Bq6eYzps3L5Ma4zBmjOnOg==} + '@swc/core-linux-arm64-gnu@1.15.18': + resolution: {integrity: sha512-3xnctOBLIq3kj8PxOCgPrGjBLP/kNOddr6f5gukYt/1IZxsITQaU9TDyjeX6jG+FiCIHjCuWuffsyQDL5Ew1bg==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [glibc] - '@swc/core-linux-arm64-musl@1.15.13': - resolution: {integrity: sha512-SmZ9m+XqCB35NddHCctvHFLqPZDAs5j8IgD36GoutufDJmeq2VNfgk5rQoqNqKmAK3Y7iFdEmI76QoHIWiCLyw==} + '@swc/core-linux-arm64-musl@1.15.18': + resolution: {integrity: sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [musl] - '@swc/core-linux-x64-gnu@1.15.13': - resolution: {integrity: sha512-5rij+vB9a29aNkHq72EXI2ihDZPszJb4zlApJY4aCC/q6utgqFA6CkrfTfIb+O8hxtG3zP5KERETz8mfFK6A0A==} + '@swc/core-linux-x64-gnu@1.15.18': + resolution: {integrity: sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [glibc] - '@swc/core-linux-x64-musl@1.15.13': - resolution: {integrity: sha512-OlSlaOK9JplQ5qn07WiBLibkOw7iml2++ojEXhhR3rbWrNEKCD7sd8+6wSavsInyFdw4PhLA+Hy6YyDBIE23Yw==} + '@swc/core-linux-x64-musl@1.15.18': + resolution: {integrity: sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [musl] - '@swc/core-win32-arm64-msvc@1.15.13': - resolution: {integrity: sha512-zwQii5YVdsfG8Ti9gIKgBKZg8qMkRZxl+OlYWUT5D93Jl4NuNBRausP20tfEkQdAPSRrMCSUZBM6FhW7izAZRg==} + '@swc/core-win32-arm64-msvc@1.15.18': + resolution: {integrity: sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.15.13': - resolution: {integrity: sha512-hYXvyVVntqRlYoAIDwNzkS3tL2ijP3rxyWQMNKaxcCxxkCDto/w3meOK/OB6rbQSkNw0qTUcBfU9k+T0ptYdfQ==} + '@swc/core-win32-ia32-msvc@1.15.18': + resolution: {integrity: sha512-yVuTrZ0RccD5+PEkpcLOBAuPbYBXS6rslENvIXfvJGXSdX5QGi1ehC4BjAMl5FkKLiam4kJECUI0l7Hq7T1vwg==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.15.13': - resolution: {integrity: sha512-XTzKs7c/vYCcjmcwawnQvlHHNS1naJEAzcBckMI5OJlnrcgW8UtcX9NHFYvNjGtXuKv0/9KvqL4fuahdvlNGKw==} + '@swc/core-win32-x64-msvc@1.15.18': + resolution: {integrity: sha512-7NRmE4hmUQNCbYU3Hn9Tz57mK9Qq4c97ZS+YlamlK6qG9Fb5g/BB3gPDe0iLlJkns/sYv2VWSkm8c3NmbEGjbg==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.15.13': - resolution: {integrity: sha512-0l1gl/72PErwUZuavcRpRAQN9uSst+Nk++niC5IX6lmMWpXoScYx3oq/narT64/sKv/eRiPTaAjBFGDEQiWJIw==} + '@swc/core@1.15.18': + resolution: {integrity: sha512-z87aF9GphWp//fnkRsqvtY+inMVPgYW3zSlXH1kJFvRT5H/wiAn+G32qW5l3oEk63KSF1x3Ov0BfHCObAmT8RA==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -5380,8 +5389,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/multer@2.0.0': - resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==} + '@types/multer@2.1.0': + resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==} '@types/node-forge@1.3.14': resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==} @@ -5392,11 +5401,11 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@24.11.0': - resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==} + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} - '@types/node@25.3.3': - resolution: {integrity: sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==} + '@types/node@25.4.0': + resolution: {integrity: sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==} '@types/nodemailer@7.0.11': resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==} @@ -5413,8 +5422,8 @@ packages: '@types/pg@8.15.6': resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} - '@types/pg@8.16.0': - resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + '@types/pg@8.18.0': + resolution: {integrity: sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==} '@types/picomatch@4.0.2': resolution: {integrity: sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==} @@ -5452,8 +5461,8 @@ packages: '@types/retry@0.12.2': resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} - '@types/sanitize-html@2.16.0': - resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==} + '@types/sanitize-html@2.16.1': + resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==} '@types/sax@1.2.7': resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} @@ -5602,11 +5611,11 @@ packages: '@vitest/browser': optional: true - '@vitest/coverage-v8@4.0.14': - resolution: {integrity: sha512-EYHLqN/BY6b47qHH7gtMxAg++saoGmsjWmAq9MlXxAz4M0NcHh9iOyKhBZyU4yxZqOd8Xnqp80/5saeitz4Cng==} + '@vitest/coverage-v8@4.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} peerDependencies: - '@vitest/browser': 4.0.14 - vitest: 4.0.14 + '@vitest/browser': 4.0.18 + vitest: 4.0.18 peerDependenciesMeta: '@vitest/browser': optional: true @@ -5614,8 +5623,8 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/expect@4.0.14': - resolution: {integrity: sha512-RHk63V3zvRiYOWAV0rGEBRO820ce17hz7cI2kDmEdfQsBjT2luEKB5tCOc91u1oSQoUOZkSv3ZyzkdkSLD7lKw==} + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} '@vitest/mocker@3.2.4': resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} @@ -5628,8 +5637,8 @@ packages: vite: optional: true - '@vitest/mocker@4.0.14': - resolution: {integrity: sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==} + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0-0 @@ -5642,32 +5651,32 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/pretty-format@4.0.14': - resolution: {integrity: sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==} + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} '@vitest/runner@3.2.4': resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} - '@vitest/runner@4.0.14': - resolution: {integrity: sha512-BsAIk3FAqxICqREbX8SetIteT8PiaUL/tgJjmhxJhCsigmzzH8xeadtp7LRnTpCVzvf0ib9BgAfKJHuhNllKLw==} + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} '@vitest/snapshot@3.2.4': resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} - '@vitest/snapshot@4.0.14': - resolution: {integrity: sha512-aQVBfT1PMzDSA16Y3Fp45a0q8nKexx6N5Amw3MX55BeTeZpoC08fGqEZqVmPcqN0ueZsuUQ9rriPMhZ3Mu19Ag==} + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@vitest/spy@4.0.14': - resolution: {integrity: sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==} + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vitest/utils@4.0.14': - resolution: {integrity: sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==} + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -5954,8 +5963,8 @@ packages: ast-metadata-inferer@0.8.1: resolution: {integrity: sha512-ht3Dm6Zr7SXv6t1Ra6gFo0+kLDglHGrEbYihTkcycrbHw7WCcuhBzPlJYHEsIpycaUwzsJHje+vUcxXUX4ztTA==} - ast-v8-to-istanbul@0.3.8: - resolution: {integrity: sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} @@ -6188,8 +6197,8 @@ packages: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} - bullmq@5.70.1: - resolution: {integrity: sha512-HjfGHfICkAClrFL0Y07qNbWcmiOCv1l+nusupXUjrvTPuDEyPEJ23MP0lUwUs/QEy1a3pWt/P/sCsSZ1RjRK+w==} + bullmq@5.70.4: + resolution: {integrity: sha512-S58YT/tGdhc4pEPcIahtZRBR1TcTLpss1UKiXimF+Vy4yZwF38pW2IvhHqs4j4dEbZqDt8oi0jGGN/WYQHbPDg==} bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} @@ -6288,8 +6297,8 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} - chai@6.2.1: - resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} chalk@4.1.2: @@ -6381,8 +6390,8 @@ packages: class-transformer@0.5.1: resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} - class-validator@0.14.4: - resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==} + class-validator@0.15.1: + resolution: {integrity: sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==} clean-css@5.3.3: resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} @@ -7621,21 +7630,21 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} - exiftool-vendored.exe@13.51.0: - resolution: {integrity: sha512-Q49J2c4e+XSGYDJf9PYMVI/IUfUkHLRsPUeDJ2ZekEBVLuw2g7ye9x0vQGWZKwEeZTlnXol7SeBJB0wtAmzM9w==} + exiftool-vendored.exe@13.52.0: + resolution: {integrity: sha512-8KSHKluRebjm2FL4S8rtwMLMELn/64CTI5BV3zmIdLnpS5N+aJEh6t9Y7aB7YBn5CwUao0T9/rxv4BMQqusukg==} os: [win32] - exiftool-vendored.pl@13.51.0: - resolution: {integrity: sha512-RhDM10w4kv5YNCvECj0aLXZXi0UWyzVo2OS4P/hpmyCHL+NGCkZ6N9z/Yc3ek0cEfCj4AiLhe8C96pnz/Fw9Yg==} + exiftool-vendored.pl@13.52.0: + resolution: {integrity: sha512-DXsMRRNdjordn1Ckcp1h9OQJRQy9VDDOcs60H+3IP+W9zRnpSU3HqQMhAVKyHR4FzioiGDbREN9BI/M1oDNoEw==} os: ['!win32'] hasBin: true - exiftool-vendored@35.10.1: - resolution: {integrity: sha512-orD61HdNcdlegfD80wI+3JE/n+iobYPztpFqv2drLHb1rb2QEKR1QY62r+O0wZHHNIf3Bje+xjweS1hxWignQA==} + exiftool-vendored@35.13.1: + resolution: {integrity: sha512-RiXz8RrJSBQ5jiZA1yMicmE/FgEFK/4QkU2KsqmlvTvouOOgANsNWv0f0uZbf098Ee933BE4bec5YAOBT0DuIQ==} engines: {node: '>=20.0.0'} - expect-type@1.2.2: - resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} exponential-backoff@3.1.3: @@ -7698,8 +7707,8 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fast-xml-builder@1.0.0: - resolution: {integrity: sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==} + fast-xml-builder@1.1.2: + resolution: {integrity: sha512-NJAmiuVaJEjVa7TjLZKlYd7RqmzOC91EtPFXHvlTcqBVo50Qh7XV5IwvXi1c7NRz2Q/majGX9YLcwJtWgHjtkA==} fast-xml-parser@5.4.1: resolution: {integrity: sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==} @@ -8007,8 +8016,8 @@ packages: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} - globals@17.3.0: - resolution: {integrity: sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==} + globals@17.4.0: + resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==} engines: {node: '>=18'} globalyzer@0.1.0: @@ -8058,8 +8067,8 @@ packages: engines: {node: '>=0.4.7'} hasBin: true - happy-dom@20.7.0: - resolution: {integrity: sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==} + happy-dom@20.8.3: + resolution: {integrity: sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ==} engines: {node: '>=20.0.0'} has-flag@4.0.0: @@ -8200,6 +8209,9 @@ packages: webpack: optional: true + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + htmlparser2@6.1.0: resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==} @@ -8323,8 +8335,9 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} - import-in-the-middle@2.0.6: - resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==} + import-in-the-middle@3.0.0: + resolution: {integrity: sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg==} + engines: {node: '>=18'} import-lazy@4.0.0: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} @@ -8386,6 +8399,10 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + ioredis@5.10.0: + resolution: {integrity: sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==} + engines: {node: '>=12.22.0'} + ioredis@5.9.3: resolution: {integrity: sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==} engines: {node: '>=12.22.0'} @@ -8566,8 +8583,8 @@ packages: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} - is-wsl@3.1.0: - resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} is-yarn-global@0.4.1: @@ -8655,6 +8672,9 @@ packages: jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -8800,10 +8820,6 @@ packages: resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} engines: {node: '>=20.0.0'} - kysely@0.28.2: - resolution: {integrity: sha512-4YAVLoF0Sf0UTqlhgQMFU9iQECdah7n+13ANkiuVfRvlK+uI0Etbgd7bVP36dKlG+NXWbhGua8vnGt+sdhvT7A==} - engines: {node: '>=18.0.0'} - langium@3.3.1: resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==} engines: {node: '>=16.0.0'} @@ -9071,8 +9087,8 @@ packages: magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} - magicast@0.5.1: - resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} @@ -9484,10 +9500,6 @@ packages: resolution: {integrity: sha512-OHsdUcVAQ6pOtg5JYWpCBo9W/GySVuwvP9hueRMW7UqshC0tbfzLv8wjySTPm3tfUZ/21CE9E1pJagOA91Pxew==} deprecated: Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.) - mkdirp@0.5.6: - resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} - hasBin: true - mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -9535,10 +9547,6 @@ packages: msgpackr@1.11.5: resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} - multer@2.0.2: - resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} - engines: {node: '>= 10.16.0'} - multer@2.1.1: resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} engines: {node: '>= 10.16.0'} @@ -9941,6 +9949,10 @@ packages: resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-expression-matcher@1.1.3: + resolution: {integrity: sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -10003,27 +10015,27 @@ packages: pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} - pg-connection-string@2.11.0: - resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} - pg-pool@3.12.0: - resolution: {integrity: sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg==} + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} peerDependencies: pg: '>=8.0' - pg-protocol@1.12.0: - resolution: {integrity: sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg==} + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg@8.19.0: - resolution: {integrity: sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==} + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} engines: {node: '>= 16.0.0'} peerDependencies: pg-native: '>=3.0.1' @@ -10602,8 +10614,8 @@ packages: peerDependencies: prettier: ^3.0.0 - prettier-plugin-svelte@3.5.0: - resolution: {integrity: sha512-2lLO/7EupnjO/95t+XZesXs8Bf3nYLIDfCo270h5QWbj/vjLqmrQ1LiRk9LPggxSDsnVYfehamZNf+rgQYApZg==} + prettier-plugin-svelte@3.5.1: + resolution: {integrity: sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==} peerDependencies: prettier: ^3.0.0 svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 @@ -10675,10 +10687,6 @@ packages: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} - protobufjs@8.0.0: - resolution: {integrity: sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw==} - engines: {node: '>=12.0.0'} - protocol-buffers-schema@3.6.0: resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==} @@ -11034,8 +11042,8 @@ packages: robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} - rollup-plugin-visualizer@6.0.5: - resolution: {integrity: sha512-9+HlNgKCVbJDs8tVtjQ43US12eqaiHyyiLMdBwQ7vSZPiHMysGNo2E88TAp1si5wx8NAoYriI2A5kuKfIakmJg==} + rollup-plugin-visualizer@6.0.11: + resolution: {integrity: sha512-TBwVHVY7buHjIKVLqr9scTVFwqZqMXINcCphPwIWKPDCOBIa+jCQfafvbjRJDZgXdq/A996Dy6yGJ/+/NtAXDQ==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -11475,8 +11483,8 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} strip-bom-string@1.0.0: @@ -11568,17 +11576,17 @@ packages: peerDependencies: svelte: '>= 3.43.1 < 6' - svelte-check@4.4.3: - resolution: {integrity: sha512-4HtdEv2hOoLCEsSXI+RDELk9okP/4sImWa7X02OjMFFOWeSdFF3NFy3vqpw0z+eH9C88J9vxZfUXz/Uv2A1ANw==} + svelte-check@4.4.4: + resolution: {integrity: sha512-F1pGqXc710Oi/wTI4d/x7d6lgPwwfx1U6w3Q35n4xsC2e8C/yN2sM1+mWxjlMcpAfWucjlq4vPi+P4FZ8a14sQ==} engines: {node: '>= 18.0.0'} hasBin: true peerDependencies: svelte: ^4.0.0 || ^5.0.0-next.0 typescript: '>=5.0.0' - svelte-eslint-parser@1.5.1: - resolution: {integrity: sha512-UbY7DYoDg+x4AKLUcX5xWuEWylgmm8ZD2Z89YT/AK6Wm/ckeMTnOMwr6AVC99znXbRC26xzWEPhSgmB62E07Gg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.30.2} + svelte-eslint-parser@1.6.0: + resolution: {integrity: sha512-qoB1ehychT6OxEtQAqc/guSqLS20SlA53Uijl7x375s8nlUT0lb9ol/gzraEEatQwsyPTJo87s2CmKL9Xab+Uw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.30.3} peerDependencies: svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 peerDependenciesMeta: @@ -11641,8 +11649,8 @@ packages: peerDependencies: svelte: ^5.30.2 - svelte@5.53.5: - resolution: {integrity: sha512-YkqERnF05g8KLdDZwZrF8/i1eSbj6Eoat8Jjr2IfruZz9StLuBqo8sfCSzjosNKd+ZrQ8DkKZDjpO5y3ht1Pow==} + svelte@5.53.7: + resolution: {integrity: sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ==} engines: {node: '>=18'} svg-parser@2.0.4: @@ -11681,8 +11689,8 @@ packages: tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} - tailwind-merge@3.4.0: - resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} tailwind-variants@3.2.2: resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==} @@ -12342,18 +12350,18 @@ packages: jsdom: optional: true - vitest@4.0.14: - resolution: {integrity: sha512-d9B2J9Cm9dN9+6nxMnnNJKJCtcyKfnHj15N6YNJfaFHRLua/d3sRKU9RuKmO9mB0XdFtUizlxfz/VPbd3OxGhw==} + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.14 - '@vitest/browser-preview': 4.0.14 - '@vitest/browser-webdriverio': 4.0.14 - '@vitest/ui': 4.0.14 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -12879,11 +12887,11 @@ snapshots: optionalDependencies: chokidar: 4.0.3 - '@angular-devkit/schematics-cli@19.2.19(@types/node@24.11.0)(chokidar@4.0.3)': + '@angular-devkit/schematics-cli@19.2.19(@types/node@24.12.0)(chokidar@4.0.3)': dependencies: '@angular-devkit/core': 19.2.19(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) - '@inquirer/prompts': 7.3.2(@types/node@24.11.0) + '@inquirer/prompts': 7.3.2(@types/node@24.12.0) ansi-colors: 4.1.3 symbol-observable: 4.0.0 yargs-parser: 21.1.1 @@ -12928,21 +12936,21 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.4 + '@aws-sdk/types': 3.973.5 tslib: 2.8.1 '@aws-crypto/crc32c@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.4 + '@aws-sdk/types': 3.973.5 tslib: 2.8.1 '@aws-crypto/sha1-browser@5.2.0': dependencies: '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-locate-window': 3.965.4 + '@aws-sdk/types': 3.973.5 + '@aws-sdk/util-locate-window': 3.965.5 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -12951,15 +12959,15 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-locate-window': 3.965.4 + '@aws-sdk/types': 3.973.5 + '@aws-sdk/util-locate-window': 3.965.5 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.4 + '@aws-sdk/types': 3.973.5 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -12968,433 +12976,435 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.4 + '@aws-sdk/types': 3.973.5 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1002.0': + '@aws-sdk/client-s3@3.1008.0': dependencies: '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.17 - '@aws-sdk/credential-provider-node': 3.972.16 - '@aws-sdk/middleware-bucket-endpoint': 3.972.6 - '@aws-sdk/middleware-expect-continue': 3.972.6 - '@aws-sdk/middleware-flexible-checksums': 3.973.3 - '@aws-sdk/middleware-host-header': 3.972.6 - '@aws-sdk/middleware-location-constraint': 3.972.6 - '@aws-sdk/middleware-logger': 3.972.6 - '@aws-sdk/middleware-recursion-detection': 3.972.6 - '@aws-sdk/middleware-sdk-s3': 3.972.17 - '@aws-sdk/middleware-ssec': 3.972.6 - '@aws-sdk/middleware-user-agent': 3.972.17 - '@aws-sdk/region-config-resolver': 3.972.6 - '@aws-sdk/signature-v4-multi-region': 3.996.5 - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-endpoints': 3.996.3 - '@aws-sdk/util-user-agent-browser': 3.972.6 - '@aws-sdk/util-user-agent-node': 3.973.2 - '@smithy/config-resolver': 4.4.9 - '@smithy/core': 3.23.7 - '@smithy/eventstream-serde-browser': 4.2.10 - '@smithy/eventstream-serde-config-resolver': 4.3.10 - '@smithy/eventstream-serde-node': 4.2.10 - '@smithy/fetch-http-handler': 5.3.12 - '@smithy/hash-blob-browser': 4.2.11 - '@smithy/hash-node': 4.2.10 - '@smithy/hash-stream-node': 4.2.10 - '@smithy/invalid-dependency': 4.2.10 - '@smithy/md5-js': 4.2.10 - '@smithy/middleware-content-length': 4.2.10 - '@smithy/middleware-endpoint': 4.4.21 - '@smithy/middleware-retry': 4.4.38 - '@smithy/middleware-serde': 4.2.11 - '@smithy/middleware-stack': 4.2.10 - '@smithy/node-config-provider': 4.3.10 - '@smithy/node-http-handler': 4.4.13 - '@smithy/protocol-http': 5.3.10 - '@smithy/smithy-client': 4.12.1 - '@smithy/types': 4.13.0 - '@smithy/url-parser': 4.2.10 - '@smithy/util-base64': 4.3.1 - '@smithy/util-body-length-browser': 4.2.1 - '@smithy/util-body-length-node': 4.2.2 - '@smithy/util-defaults-mode-browser': 4.3.37 - '@smithy/util-defaults-mode-node': 4.2.40 - '@smithy/util-endpoints': 3.3.1 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-retry': 4.2.10 - '@smithy/util-stream': 4.5.16 - '@smithy/util-utf8': 4.2.1 - '@smithy/util-waiter': 4.2.10 + '@aws-sdk/core': 3.973.19 + '@aws-sdk/credential-provider-node': 3.972.20 + '@aws-sdk/middleware-bucket-endpoint': 3.972.7 + '@aws-sdk/middleware-expect-continue': 3.972.7 + '@aws-sdk/middleware-flexible-checksums': 3.973.5 + '@aws-sdk/middleware-host-header': 3.972.7 + '@aws-sdk/middleware-location-constraint': 3.972.7 + '@aws-sdk/middleware-logger': 3.972.7 + '@aws-sdk/middleware-recursion-detection': 3.972.7 + '@aws-sdk/middleware-sdk-s3': 3.972.19 + '@aws-sdk/middleware-ssec': 3.972.7 + '@aws-sdk/middleware-user-agent': 3.972.20 + '@aws-sdk/region-config-resolver': 3.972.7 + '@aws-sdk/signature-v4-multi-region': 3.996.7 + '@aws-sdk/types': 3.973.5 + '@aws-sdk/util-endpoints': 3.996.4 + '@aws-sdk/util-user-agent-browser': 3.972.7 + '@aws-sdk/util-user-agent-node': 3.973.6 + '@smithy/config-resolver': 4.4.11 + '@smithy/core': 3.23.11 + '@smithy/eventstream-serde-browser': 4.2.12 + '@smithy/eventstream-serde-config-resolver': 4.3.12 + '@smithy/eventstream-serde-node': 4.2.12 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-blob-browser': 4.2.13 + '@smithy/hash-node': 4.2.12 + '@smithy/hash-stream-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/md5-js': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.25 + '@smithy/middleware-retry': 4.4.42 + '@smithy/middleware-serde': 4.2.14 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.4.16 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.5 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.41 + '@smithy/util-defaults-mode-node': 4.2.44 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-stream': 4.5.19 + '@smithy/util-utf8': 4.2.2 + '@smithy/util-waiter': 4.2.13 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/core@3.973.17': - dependencies: - '@aws-sdk/types': 3.973.4 - '@aws-sdk/xml-builder': 3.972.9 - '@smithy/core': 3.23.7 - '@smithy/node-config-provider': 4.3.10 - '@smithy/property-provider': 4.2.10 - '@smithy/protocol-http': 5.3.10 - '@smithy/signature-v4': 5.3.10 - '@smithy/smithy-client': 4.12.1 - '@smithy/types': 4.13.0 - '@smithy/util-base64': 4.3.1 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-utf8': 4.2.1 + '@aws-sdk/core@3.973.19': + dependencies: + '@aws-sdk/types': 3.973.5 + '@aws-sdk/xml-builder': 3.972.10 + '@smithy/core': 3.23.11 + '@smithy/node-config-provider': 4.3.12 + '@smithy/property-provider': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/signature-v4': 5.3.12 + '@smithy/smithy-client': 4.12.5 + '@smithy/types': 4.13.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@aws-sdk/crc64-nvme@3.972.3': + '@aws-sdk/crc64-nvme@3.972.4': dependencies: - '@smithy/types': 4.13.0 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.15': + '@aws-sdk/credential-provider-env@3.972.17': dependencies: - '@aws-sdk/core': 3.973.17 - '@aws-sdk/types': 3.973.4 - '@smithy/property-provider': 4.2.10 - '@smithy/types': 4.13.0 + '@aws-sdk/core': 3.973.19 + '@aws-sdk/types': 3.973.5 + '@smithy/property-provider': 4.2.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.17': - dependencies: - '@aws-sdk/core': 3.973.17 - '@aws-sdk/types': 3.973.4 - '@smithy/fetch-http-handler': 5.3.12 - '@smithy/node-http-handler': 4.4.13 - '@smithy/property-provider': 4.2.10 - '@smithy/protocol-http': 5.3.10 - '@smithy/smithy-client': 4.12.1 - '@smithy/types': 4.13.0 - '@smithy/util-stream': 4.5.16 + '@aws-sdk/credential-provider-http@3.972.19': + dependencies: + '@aws-sdk/core': 3.973.19 + '@aws-sdk/types': 3.973.5 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/node-http-handler': 4.4.16 + '@smithy/property-provider': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.5 + '@smithy/types': 4.13.1 + '@smithy/util-stream': 4.5.19 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.15': - dependencies: - '@aws-sdk/core': 3.973.17 - '@aws-sdk/credential-provider-env': 3.972.15 - '@aws-sdk/credential-provider-http': 3.972.17 - '@aws-sdk/credential-provider-login': 3.972.15 - '@aws-sdk/credential-provider-process': 3.972.15 - '@aws-sdk/credential-provider-sso': 3.972.15 - '@aws-sdk/credential-provider-web-identity': 3.972.15 - '@aws-sdk/nested-clients': 3.996.5 - '@aws-sdk/types': 3.973.4 - '@smithy/credential-provider-imds': 4.2.10 - '@smithy/property-provider': 4.2.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 + '@aws-sdk/credential-provider-ini@3.972.19': + dependencies: + '@aws-sdk/core': 3.973.19 + '@aws-sdk/credential-provider-env': 3.972.17 + '@aws-sdk/credential-provider-http': 3.972.19 + '@aws-sdk/credential-provider-login': 3.972.19 + '@aws-sdk/credential-provider-process': 3.972.17 + '@aws-sdk/credential-provider-sso': 3.972.19 + '@aws-sdk/credential-provider-web-identity': 3.972.19 + '@aws-sdk/nested-clients': 3.996.9 + '@aws-sdk/types': 3.973.5 + '@smithy/credential-provider-imds': 4.2.12 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-login@3.972.15': + '@aws-sdk/credential-provider-login@3.972.19': dependencies: - '@aws-sdk/core': 3.973.17 - '@aws-sdk/nested-clients': 3.996.5 - '@aws-sdk/types': 3.973.4 - '@smithy/property-provider': 4.2.10 - '@smithy/protocol-http': 5.3.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 + '@aws-sdk/core': 3.973.19 + '@aws-sdk/nested-clients': 3.996.9 + '@aws-sdk/types': 3.973.5 + '@smithy/property-provider': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-node@3.972.16': - dependencies: - '@aws-sdk/credential-provider-env': 3.972.15 - '@aws-sdk/credential-provider-http': 3.972.17 - '@aws-sdk/credential-provider-ini': 3.972.15 - '@aws-sdk/credential-provider-process': 3.972.15 - '@aws-sdk/credential-provider-sso': 3.972.15 - '@aws-sdk/credential-provider-web-identity': 3.972.15 - '@aws-sdk/types': 3.973.4 - '@smithy/credential-provider-imds': 4.2.10 - '@smithy/property-provider': 4.2.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 + '@aws-sdk/credential-provider-node@3.972.20': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.17 + '@aws-sdk/credential-provider-http': 3.972.19 + '@aws-sdk/credential-provider-ini': 3.972.19 + '@aws-sdk/credential-provider-process': 3.972.17 + '@aws-sdk/credential-provider-sso': 3.972.19 + '@aws-sdk/credential-provider-web-identity': 3.972.19 + '@aws-sdk/types': 3.973.5 + '@smithy/credential-provider-imds': 4.2.12 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-process@3.972.15': + '@aws-sdk/credential-provider-process@3.972.17': dependencies: - '@aws-sdk/core': 3.973.17 - '@aws-sdk/types': 3.973.4 - '@smithy/property-provider': 4.2.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 + '@aws-sdk/core': 3.973.19 + '@aws-sdk/types': 3.973.5 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.15': + '@aws-sdk/credential-provider-sso@3.972.19': dependencies: - '@aws-sdk/core': 3.973.17 - '@aws-sdk/nested-clients': 3.996.5 - '@aws-sdk/token-providers': 3.1002.0 - '@aws-sdk/types': 3.973.4 - '@smithy/property-provider': 4.2.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 + '@aws-sdk/core': 3.973.19 + '@aws-sdk/nested-clients': 3.996.9 + '@aws-sdk/token-providers': 3.1008.0 + '@aws-sdk/types': 3.973.5 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-web-identity@3.972.15': + '@aws-sdk/credential-provider-web-identity@3.972.19': dependencies: - '@aws-sdk/core': 3.973.17 - '@aws-sdk/nested-clients': 3.996.5 - '@aws-sdk/types': 3.973.4 - '@smithy/property-provider': 4.2.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 + '@aws-sdk/core': 3.973.19 + '@aws-sdk/nested-clients': 3.996.9 + '@aws-sdk/types': 3.973.5 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/lib-storage@3.1002.0(@aws-sdk/client-s3@3.1002.0)': + '@aws-sdk/lib-storage@3.1008.0(@aws-sdk/client-s3@3.1008.0)': dependencies: - '@aws-sdk/client-s3': 3.1002.0 - '@smithy/abort-controller': 4.2.10 - '@smithy/middleware-endpoint': 4.4.21 - '@smithy/smithy-client': 4.12.1 + '@aws-sdk/client-s3': 3.1008.0 + '@smithy/abort-controller': 4.2.12 + '@smithy/middleware-endpoint': 4.4.25 + '@smithy/smithy-client': 4.12.5 buffer: 5.6.0 events: 3.3.0 stream-browserify: 3.0.0 tslib: 2.8.1 - '@aws-sdk/middleware-bucket-endpoint@3.972.6': + '@aws-sdk/middleware-bucket-endpoint@3.972.7': dependencies: - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-arn-parser': 3.972.2 - '@smithy/node-config-provider': 4.3.10 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 - '@smithy/util-config-provider': 4.2.1 + '@aws-sdk/types': 3.973.5 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/node-config-provider': 4.3.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-config-provider': 4.2.2 tslib: 2.8.1 - '@aws-sdk/middleware-expect-continue@3.972.6': + '@aws-sdk/middleware-expect-continue@3.972.7': dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 + '@aws-sdk/types': 3.973.5 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/middleware-flexible-checksums@3.973.3': + '@aws-sdk/middleware-flexible-checksums@3.973.5': dependencies: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/crc32c': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.973.17 - '@aws-sdk/crc64-nvme': 3.972.3 - '@aws-sdk/types': 3.973.4 - '@smithy/is-array-buffer': 4.2.1 - '@smithy/node-config-provider': 4.3.10 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-stream': 4.5.16 - '@smithy/util-utf8': 4.2.1 + '@aws-sdk/core': 3.973.19 + '@aws-sdk/crc64-nvme': 3.972.4 + '@aws-sdk/types': 3.973.5 + '@smithy/is-array-buffer': 4.2.2 + '@smithy/node-config-provider': 4.3.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-stream': 4.5.19 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@aws-sdk/middleware-host-header@3.972.6': + '@aws-sdk/middleware-host-header@3.972.7': dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 + '@aws-sdk/types': 3.973.5 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/middleware-location-constraint@3.972.6': + '@aws-sdk/middleware-location-constraint@3.972.7': dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/types': 4.13.0 + '@aws-sdk/types': 3.973.5 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/middleware-logger@3.972.6': + '@aws-sdk/middleware-logger@3.972.7': dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/types': 4.13.0 + '@aws-sdk/types': 3.973.5 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/middleware-recursion-detection@3.972.6': + '@aws-sdk/middleware-recursion-detection@3.972.7': dependencies: - '@aws-sdk/types': 3.973.4 - '@aws/lambda-invoke-store': 0.2.3 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 + '@aws-sdk/types': 3.973.5 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.17': - dependencies: - '@aws-sdk/core': 3.973.17 - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-arn-parser': 3.972.2 - '@smithy/core': 3.23.7 - '@smithy/node-config-provider': 4.3.10 - '@smithy/protocol-http': 5.3.10 - '@smithy/signature-v4': 5.3.10 - '@smithy/smithy-client': 4.12.1 - '@smithy/types': 4.13.0 - '@smithy/util-config-provider': 4.2.1 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-stream': 4.5.16 - '@smithy/util-utf8': 4.2.1 + '@aws-sdk/middleware-sdk-s3@3.972.19': + dependencies: + '@aws-sdk/core': 3.973.19 + '@aws-sdk/types': 3.973.5 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/core': 3.23.11 + '@smithy/node-config-provider': 4.3.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/signature-v4': 5.3.12 + '@smithy/smithy-client': 4.12.5 + '@smithy/types': 4.13.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-stream': 4.5.19 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@aws-sdk/middleware-ssec@3.972.6': + '@aws-sdk/middleware-ssec@3.972.7': dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/types': 4.13.0 + '@aws-sdk/types': 3.973.5 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/middleware-user-agent@3.972.17': + '@aws-sdk/middleware-user-agent@3.972.20': dependencies: - '@aws-sdk/core': 3.973.17 - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-endpoints': 3.996.3 - '@smithy/core': 3.23.7 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 + '@aws-sdk/core': 3.973.19 + '@aws-sdk/types': 3.973.5 + '@aws-sdk/util-endpoints': 3.996.4 + '@smithy/core': 3.23.11 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-retry': 4.2.12 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.996.5': + '@aws-sdk/nested-clients@3.996.9': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.17 - '@aws-sdk/middleware-host-header': 3.972.6 - '@aws-sdk/middleware-logger': 3.972.6 - '@aws-sdk/middleware-recursion-detection': 3.972.6 - '@aws-sdk/middleware-user-agent': 3.972.17 - '@aws-sdk/region-config-resolver': 3.972.6 - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-endpoints': 3.996.3 - '@aws-sdk/util-user-agent-browser': 3.972.6 - '@aws-sdk/util-user-agent-node': 3.973.2 - '@smithy/config-resolver': 4.4.9 - '@smithy/core': 3.23.7 - '@smithy/fetch-http-handler': 5.3.12 - '@smithy/hash-node': 4.2.10 - '@smithy/invalid-dependency': 4.2.10 - '@smithy/middleware-content-length': 4.2.10 - '@smithy/middleware-endpoint': 4.4.21 - '@smithy/middleware-retry': 4.4.38 - '@smithy/middleware-serde': 4.2.11 - '@smithy/middleware-stack': 4.2.10 - '@smithy/node-config-provider': 4.3.10 - '@smithy/node-http-handler': 4.4.13 - '@smithy/protocol-http': 5.3.10 - '@smithy/smithy-client': 4.12.1 - '@smithy/types': 4.13.0 - '@smithy/url-parser': 4.2.10 - '@smithy/util-base64': 4.3.1 - '@smithy/util-body-length-browser': 4.2.1 - '@smithy/util-body-length-node': 4.2.2 - '@smithy/util-defaults-mode-browser': 4.3.37 - '@smithy/util-defaults-mode-node': 4.2.40 - '@smithy/util-endpoints': 3.3.1 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-retry': 4.2.10 - '@smithy/util-utf8': 4.2.1 + '@aws-sdk/core': 3.973.19 + '@aws-sdk/middleware-host-header': 3.972.7 + '@aws-sdk/middleware-logger': 3.972.7 + '@aws-sdk/middleware-recursion-detection': 3.972.7 + '@aws-sdk/middleware-user-agent': 3.972.20 + '@aws-sdk/region-config-resolver': 3.972.7 + '@aws-sdk/types': 3.973.5 + '@aws-sdk/util-endpoints': 3.996.4 + '@aws-sdk/util-user-agent-browser': 3.972.7 + '@aws-sdk/util-user-agent-node': 3.973.6 + '@smithy/config-resolver': 4.4.11 + '@smithy/core': 3.23.11 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/hash-node': 4.2.12 + '@smithy/invalid-dependency': 4.2.12 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.25 + '@smithy/middleware-retry': 4.4.42 + '@smithy/middleware-serde': 4.2.14 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.4.16 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.5 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.41 + '@smithy/util-defaults-mode-node': 4.2.44 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/region-config-resolver@3.972.6': + '@aws-sdk/region-config-resolver@3.972.7': dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/config-resolver': 4.4.9 - '@smithy/node-config-provider': 4.3.10 - '@smithy/types': 4.13.0 + '@aws-sdk/types': 3.973.5 + '@smithy/config-resolver': 4.4.11 + '@smithy/node-config-provider': 4.3.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/s3-request-presigner@3.1002.0': + '@aws-sdk/s3-request-presigner@3.1008.0': dependencies: - '@aws-sdk/signature-v4-multi-region': 3.996.5 - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-format-url': 3.972.6 - '@smithy/middleware-endpoint': 4.4.21 - '@smithy/protocol-http': 5.3.10 - '@smithy/smithy-client': 4.12.1 - '@smithy/types': 4.13.0 + '@aws-sdk/signature-v4-multi-region': 3.996.7 + '@aws-sdk/types': 3.973.5 + '@aws-sdk/util-format-url': 3.972.7 + '@smithy/middleware-endpoint': 4.4.25 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.5 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.5': + '@aws-sdk/signature-v4-multi-region@3.996.7': dependencies: - '@aws-sdk/middleware-sdk-s3': 3.972.17 - '@aws-sdk/types': 3.973.4 - '@smithy/protocol-http': 5.3.10 - '@smithy/signature-v4': 5.3.10 - '@smithy/types': 4.13.0 + '@aws-sdk/middleware-sdk-s3': 3.972.19 + '@aws-sdk/types': 3.973.5 + '@smithy/protocol-http': 5.3.12 + '@smithy/signature-v4': 5.3.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1002.0': + '@aws-sdk/token-providers@3.1008.0': dependencies: - '@aws-sdk/core': 3.973.17 - '@aws-sdk/nested-clients': 3.996.5 - '@aws-sdk/types': 3.973.4 - '@smithy/property-provider': 4.2.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 + '@aws-sdk/core': 3.973.19 + '@aws-sdk/nested-clients': 3.996.9 + '@aws-sdk/types': 3.973.5 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/types@3.973.4': + '@aws-sdk/types@3.973.5': dependencies: - '@smithy/types': 4.13.0 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/util-arn-parser@3.972.2': + '@aws-sdk/util-arn-parser@3.972.3': dependencies: tslib: 2.8.1 - '@aws-sdk/util-endpoints@3.996.3': + '@aws-sdk/util-endpoints@3.996.4': dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/types': 4.13.0 - '@smithy/url-parser': 4.2.10 - '@smithy/util-endpoints': 3.3.1 + '@aws-sdk/types': 3.973.5 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-endpoints': 3.3.3 tslib: 2.8.1 - '@aws-sdk/util-format-url@3.972.6': + '@aws-sdk/util-format-url@3.972.7': dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/querystring-builder': 4.2.10 - '@smithy/types': 4.13.0 + '@aws-sdk/types': 3.973.5 + '@smithy/querystring-builder': 4.2.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/util-locate-window@3.965.4': + '@aws-sdk/util-locate-window@3.965.5': dependencies: tslib: 2.8.1 - '@aws-sdk/util-user-agent-browser@3.972.6': + '@aws-sdk/util-user-agent-browser@3.972.7': dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/types': 4.13.0 + '@aws-sdk/types': 3.973.5 + '@smithy/types': 4.13.1 bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/util-user-agent-node@3.973.2': + '@aws-sdk/util-user-agent-node@3.973.6': dependencies: - '@aws-sdk/middleware-user-agent': 3.972.17 - '@aws-sdk/types': 3.973.4 - '@smithy/node-config-provider': 4.3.10 - '@smithy/types': 4.13.0 + '@aws-sdk/middleware-user-agent': 3.972.20 + '@aws-sdk/types': 3.973.5 + '@smithy/node-config-provider': 4.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-config-provider': 4.2.2 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.9': + '@aws-sdk/xml-builder@3.972.10': dependencies: - '@smithy/types': 4.13.0 + '@smithy/types': 4.13.1 fast-xml-parser: 5.4.1 tslib: 2.8.1 - '@aws/lambda-invoke-store@0.2.3': {} + '@aws/lambda-invoke-store@0.2.4': {} '@babel/code-frame@7.29.0': dependencies: @@ -13411,10 +13421,10 @@ snapshots: '@babel/helper-compilation-targets': 7.27.2 '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) '@babel/helpers': 7.28.4 - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.0 '@babel/template': 7.27.2 '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -13426,15 +13436,15 @@ snapshots: '@babel/generator@7.28.5': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@babel/helper-compilation-targets@7.27.2': dependencies: @@ -13480,14 +13490,14 @@ snapshots: '@babel/helper-member-expression-to-functions@7.28.5': dependencies: '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.27.1': dependencies: '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -13502,7 +13512,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@babel/helper-plugin-utils@7.27.1': {} @@ -13527,7 +13537,7 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -13541,18 +13551,18 @@ snapshots: dependencies: '@babel/template': 7.27.2 '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color '@babel/helpers@7.28.4': dependencies: '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 - '@babel/parser@7.28.5': + '@babel/parser@7.29.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.28.5)': dependencies: @@ -13914,7 +13924,7 @@ snapshots: '@babel/helper-module-imports': 7.27.1 '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -14094,7 +14104,7 @@ snapshots: dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 esutils: 2.0.3 '@babel/preset-react@7.28.5(@babel/core@7.28.5)': @@ -14129,22 +14139,22 @@ snapshots: '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.29.0 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 '@babel/traverse@7.28.5': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.28.5 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.0 '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.28.5': + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 @@ -15686,10 +15696,10 @@ snapshots: dependencies: '@fortawesome/fontawesome-common-types': 7.1.0 - '@golevelup/nestjs-discovery@5.0.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': + '@golevelup/nestjs-discovery@5.0.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) lodash: 4.17.23 '@grpc/grpc-js@1.14.3': @@ -15839,171 +15849,171 @@ snapshots: commander: 14.0.3 kysely: 0.28.11 kysely-postgres-js: 3.0.0(kysely@0.28.11)(postgres@3.4.8) - pg-connection-string: 2.11.0 + pg-connection-string: 2.12.0 postgres: 3.4.8 - '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.53.5)': + '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.53.7)': dependencies: front-matter: 4.0.2 marked: 17.0.3 node-emoji: 2.2.0 - svelte: 5.53.5 + svelte: 5.53.7 - '@immich/ui@0.64.0(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(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.5)': + '@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)': dependencies: - '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.5) + '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.7) '@internationalized/date': 3.10.0 '@mdi/js': 7.4.47 - bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(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.5) + 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) luxon: 3.7.2 simple-icons: 16.9.0 - svelte: 5.53.5 + svelte: 5.53.7 svelte-highlight: 7.9.0 - tailwind-merge: 3.4.0 - tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1) + tailwind-merge: 3.5.0 + tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1) tailwindcss: 4.2.1 transitivePeerDependencies: - '@sveltejs/kit' '@inquirer/ansi@1.0.2': {} - '@inquirer/checkbox@4.3.2(@types/node@24.11.0)': + '@inquirer/checkbox@4.3.2(@types/node@24.12.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/confirm@5.1.21(@types/node@24.11.0)': + '@inquirer/confirm@5.1.21(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.11.0) - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/core@10.3.2(@types/node@24.11.0)': + '@inquirer/core@10.3.2(@types/node@24.12.0)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/editor@4.2.23(@types/node@24.11.0)': + '@inquirer/editor@4.2.23(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.11.0) - '@inquirer/external-editor': 1.0.3(@types/node@24.11.0) - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/external-editor': 1.0.3(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/expand@4.0.23(@types/node@24.11.0)': + '@inquirer/expand@4.0.23(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.11.0) - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/external-editor@1.0.3(@types/node@24.11.0)': + '@inquirer/external-editor@1.0.3(@types/node@24.12.0)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@inquirer/figures@1.0.15': {} - '@inquirer/input@4.3.1(@types/node@24.11.0)': + '@inquirer/input@4.3.1(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.11.0) - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/number@3.0.23(@types/node@24.11.0)': + '@inquirer/number@3.0.23(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.11.0) - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/password@4.0.23(@types/node@24.11.0)': + '@inquirer/password@4.0.23(@types/node@24.12.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.11.0) - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) optionalDependencies: - '@types/node': 24.11.0 - - '@inquirer/prompts@7.10.1(@types/node@24.11.0)': - dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@24.11.0) - '@inquirer/confirm': 5.1.21(@types/node@24.11.0) - '@inquirer/editor': 4.2.23(@types/node@24.11.0) - '@inquirer/expand': 4.0.23(@types/node@24.11.0) - '@inquirer/input': 4.3.1(@types/node@24.11.0) - '@inquirer/number': 3.0.23(@types/node@24.11.0) - '@inquirer/password': 4.0.23(@types/node@24.11.0) - '@inquirer/rawlist': 4.1.11(@types/node@24.11.0) - '@inquirer/search': 3.2.2(@types/node@24.11.0) - '@inquirer/select': 4.4.2(@types/node@24.11.0) + '@types/node': 24.12.0 + + '@inquirer/prompts@7.10.1(@types/node@24.12.0)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@24.12.0) + '@inquirer/confirm': 5.1.21(@types/node@24.12.0) + '@inquirer/editor': 4.2.23(@types/node@24.12.0) + '@inquirer/expand': 4.0.23(@types/node@24.12.0) + '@inquirer/input': 4.3.1(@types/node@24.12.0) + '@inquirer/number': 3.0.23(@types/node@24.12.0) + '@inquirer/password': 4.0.23(@types/node@24.12.0) + '@inquirer/rawlist': 4.1.11(@types/node@24.12.0) + '@inquirer/search': 3.2.2(@types/node@24.12.0) + '@inquirer/select': 4.4.2(@types/node@24.12.0) optionalDependencies: - '@types/node': 24.11.0 - - '@inquirer/prompts@7.3.2(@types/node@24.11.0)': - dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@24.11.0) - '@inquirer/confirm': 5.1.21(@types/node@24.11.0) - '@inquirer/editor': 4.2.23(@types/node@24.11.0) - '@inquirer/expand': 4.0.23(@types/node@24.11.0) - '@inquirer/input': 4.3.1(@types/node@24.11.0) - '@inquirer/number': 3.0.23(@types/node@24.11.0) - '@inquirer/password': 4.0.23(@types/node@24.11.0) - '@inquirer/rawlist': 4.1.11(@types/node@24.11.0) - '@inquirer/search': 3.2.2(@types/node@24.11.0) - '@inquirer/select': 4.4.2(@types/node@24.11.0) + '@types/node': 24.12.0 + + '@inquirer/prompts@7.3.2(@types/node@24.12.0)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@24.12.0) + '@inquirer/confirm': 5.1.21(@types/node@24.12.0) + '@inquirer/editor': 4.2.23(@types/node@24.12.0) + '@inquirer/expand': 4.0.23(@types/node@24.12.0) + '@inquirer/input': 4.3.1(@types/node@24.12.0) + '@inquirer/number': 3.0.23(@types/node@24.12.0) + '@inquirer/password': 4.0.23(@types/node@24.12.0) + '@inquirer/rawlist': 4.1.11(@types/node@24.12.0) + '@inquirer/search': 3.2.2(@types/node@24.12.0) + '@inquirer/select': 4.4.2(@types/node@24.12.0) optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/rawlist@4.1.11(@types/node@24.11.0)': + '@inquirer/rawlist@4.1.11(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.11.0) - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/search@3.2.2(@types/node@24.11.0)': + '@inquirer/search@3.2.2(@types/node@24.12.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/select@4.4.2(@types/node@24.11.0)': + '@inquirer/select@4.4.2(@types/node@24.12.0)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.11.0) + '@inquirer/core': 10.3.2(@types/node@24.12.0) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.11.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 - '@inquirer/type@3.0.10(@types/node@24.11.0)': + '@inquirer/type@3.0.10(@types/node@24.12.0)': optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@internationalized/date@3.10.0': dependencies: @@ -16011,11 +16021,13 @@ snapshots: '@ioredis/commands@1.5.0': {} + '@ioredis/commands@1.5.1': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 strip-ansi-cjs: strip-ansi@6.0.1 wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 @@ -16035,7 +16047,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -16171,22 +16183,6 @@ snapshots: '@mapbox/mapbox-gl-rtl-text@0.3.0': {} - '@mapbox/node-pre-gyp@1.0.11': - dependencies: - detect-libc: 2.1.2 - https-proxy-agent: 5.0.1 - make-dir: 3.1.0 - node-fetch: 2.7.0 - nopt: 5.0.0 - npmlog: 5.0.1 - rimraf: 3.0.2 - semver: 7.7.4 - tar: 6.2.1 - transitivePeerDependencies: - - encoding - - supports-color - optional: true - '@mapbox/node-pre-gyp@1.0.11(encoding@0.1.13)': dependencies: detect-libc: 2.1.2 @@ -16316,49 +16312,49 @@ snapshots: '@namnode/store@0.1.0': {} - '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(bullmq@5.70.1)': + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(bullmq@5.70.4)': dependencies: - '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) - bullmq: 5.70.1 + '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + bullmq: 5.70.4 tslib: 2.8.1 - '@nestjs/cli@11.0.16(@swc/core@1.15.13(@swc/helpers@0.5.17))(@types/node@24.11.0)': + '@nestjs/cli@11.0.16(@swc/core@1.15.18(@swc/helpers@0.5.17))(@types/node@24.12.0)': dependencies: '@angular-devkit/core': 19.2.19(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) - '@angular-devkit/schematics-cli': 19.2.19(@types/node@24.11.0)(chokidar@4.0.3) - '@inquirer/prompts': 7.10.1(@types/node@24.11.0) + '@angular-devkit/schematics-cli': 19.2.19(@types/node@24.12.0)(chokidar@4.0.3) + '@inquirer/prompts': 7.10.1(@types/node@24.12.0) '@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.9.3) ansis: 4.2.0 chokidar: 4.0.3 cli-table3: 0.6.5 commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17))) + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.18(@swc/helpers@0.5.17))) glob: 13.0.0 node-emoji: 1.11.0 ora: 5.4.1 tsconfig-paths: 4.2.0 tsconfig-paths-webpack-plugin: 4.2.0 typescript: 5.9.3 - webpack: 5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17)) + webpack: 5.104.1(@swc/core@1.15.18(@swc/helpers@0.5.17)) webpack-node-externals: 3.0.0 optionalDependencies: - '@swc/core': 1.15.13(@swc/helpers@0.5.17) + '@swc/core': 1.15.18(@swc/helpers@0.5.17) transitivePeerDependencies: - '@types/node' - esbuild - uglify-js - webpack-cli - '@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.3.0 iterare: 1.2.1 @@ -16369,13 +16365,13 @@ snapshots: uid: 2.0.2 optionalDependencies: class-transformer: 0.5.1 - class-validator: 0.14.4 + class-validator: 0.15.1 transitivePeerDependencies: - supports-color - '@nestjs/core@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -16385,33 +16381,33 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) - '@nestjs/websockets': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/websockets': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-socket.io@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': + '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 optionalDependencies: class-transformer: 0.5.1 - class-validator: 0.14.4 + class-validator: 0.15.1 - '@nestjs/platform-express@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': + '@nestjs/platform-express@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.6 express: 5.2.1 - multer: 2.0.2 + multer: 2.1.1 path-to-regexp: 8.3.0 tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@nestjs/platform-socket.io@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(rxjs@7.8.2)': + '@nestjs/platform-socket.io@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.16)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/websockets': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/websockets': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-socket.io@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) rxjs: 7.8.2 socket.io: 4.8.3 tslib: 2.8.1 @@ -16420,10 +16416,10 @@ snapshots: - supports-color - utf-8-validate - '@nestjs/schedule@6.1.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': + '@nestjs/schedule@6.1.1(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) cron: 4.4.0 '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)': @@ -16437,12 +16433,12 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/swagger@11.2.6(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': + '@nestjs/swagger@11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.16.0 - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) js-yaml: 4.1.1 lodash: 4.17.23 path-to-regexp: 8.3.0 @@ -16450,27 +16446,27 @@ snapshots: swagger-ui-dist: 5.31.0 optionalDependencies: class-transformer: 0.5.1 - class-validator: 0.14.4 + class-validator: 0.15.1 - '@nestjs/testing@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-express@11.1.14)': + '@nestjs/testing@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) - '@nestjs/websockets@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/websockets@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-socket.io@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) iterare: 1.2.1 object-hash: 3.0.0 reflect-metadata: 0.2.2 rxjs: 7.8.2 tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-socket.io': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(rxjs@7.8.2) + '@nestjs/platform-socket.io': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.16)(rxjs@7.8.2) '@noble/hashes@1.8.0': {} @@ -16506,131 +16502,131 @@ snapshots: '@oazapfts/runtime@1.2.0': {} - '@opentelemetry/api-logs@0.212.0': + '@opentelemetry/api-logs@0.213.0': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/api@1.9.0': {} - '@opentelemetry/configuration@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/configuration@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) yaml: 2.8.2 - '@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/exporter-logs-otlp-grpc@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-grpc@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-http@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-http@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.212.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-proto@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-proto@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.212.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-grpc@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-http@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-proto@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-prometheus@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-prometheus@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/exporter-trace-otlp-grpc@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-grpc@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-proto@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-zipkin@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-zipkin@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/host-metrics@0.36.2(@opentelemetry/api@1.9.0)': @@ -16638,38 +16634,38 @@ snapshots: '@opentelemetry/api': 1.9.0 systeminformation: 5.23.8 - '@opentelemetry/instrumentation-http@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-http@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 forwarded-parse: 2.1.2 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-ioredis@0.60.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-ioredis@0.61.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/redis-common': 0.38.2 '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-nestjs-core@0.58.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-nestjs-core@0.59.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-pg@0.64.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-pg@0.65.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) '@types/pg': 8.15.6 @@ -16677,121 +16673,122 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.212.0 - import-in-the-middle: 2.0.6 + '@opentelemetry/api-logs': 0.213.0 + import-in-the-middle: 3.0.0 require-in-the-middle: 8.0.1 transitivePeerDependencies: - supports-color - '@opentelemetry/otlp-exporter-base@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-exporter-base@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-grpc-exporter-base@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-transformer@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.212.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) - protobufjs: 8.0.0 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + protobufjs: 7.5.4 - '@opentelemetry/propagator-b3@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/propagator-b3@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-jaeger@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/propagator-jaeger@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/redis-common@0.38.2': {} - '@opentelemetry/resources@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/sdk-logs@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-logs@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.212.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/sdk-metrics@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-metrics@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-node@0.212.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-node@0.213.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.212.0 - '@opentelemetry/configuration': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-proto': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-proto': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-prometheus': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-proto': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-zipkin': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-b3': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-jaeger': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-node': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/configuration': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-proto': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-proto': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-prometheus': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-proto': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-zipkin': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-b3': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-jaeger': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/sdk-trace-node@2.5.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-trace-node@2.6.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions@1.40.0': {} '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@paralleldrive/cuid2@2.3.1': dependencies: @@ -16886,7 +16883,7 @@ snapshots: '@photo-sphere-viewer/core': 5.14.1 three: 0.182.0 - '@photostructure/tz-lookup@11.4.0': {} + '@photostructure/tz-lookup@11.5.0': {} '@pkgjs/parseargs@0.11.0': optional: true @@ -17172,254 +17169,255 @@ snapshots: micromark-util-character: 1.2.0 micromark-util-symbol: 1.1.0 - '@smithy/abort-controller@4.2.10': + '@smithy/abort-controller@4.2.12': dependencies: - '@smithy/types': 4.13.0 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/chunked-blob-reader-native@4.2.2': + '@smithy/chunked-blob-reader-native@4.2.3': dependencies: - '@smithy/util-base64': 4.3.1 + '@smithy/util-base64': 4.3.2 tslib: 2.8.1 - '@smithy/chunked-blob-reader@5.2.1': + '@smithy/chunked-blob-reader@5.2.2': dependencies: tslib: 2.8.1 - '@smithy/config-resolver@4.4.9': + '@smithy/config-resolver@4.4.11': dependencies: - '@smithy/node-config-provider': 4.3.10 - '@smithy/types': 4.13.0 - '@smithy/util-config-provider': 4.2.1 - '@smithy/util-endpoints': 3.3.1 - '@smithy/util-middleware': 4.2.10 + '@smithy/node-config-provider': 4.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 tslib: 2.8.1 - '@smithy/core@3.23.7': - dependencies: - '@smithy/middleware-serde': 4.2.11 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 - '@smithy/util-base64': 4.3.1 - '@smithy/util-body-length-browser': 4.2.1 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-stream': 4.5.16 - '@smithy/util-utf8': 4.2.1 - '@smithy/uuid': 1.1.1 + '@smithy/core@3.23.11': + dependencies: + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-stream': 4.5.19 + '@smithy/util-utf8': 4.2.2 + '@smithy/uuid': 1.1.2 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.2.10': + '@smithy/credential-provider-imds@4.2.12': dependencies: - '@smithy/node-config-provider': 4.3.10 - '@smithy/property-provider': 4.2.10 - '@smithy/types': 4.13.0 - '@smithy/url-parser': 4.2.10 + '@smithy/node-config-provider': 4.3.12 + '@smithy/property-provider': 4.2.12 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 tslib: 2.8.1 - '@smithy/eventstream-codec@4.2.10': + '@smithy/eventstream-codec@4.2.12': dependencies: '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.13.0 - '@smithy/util-hex-encoding': 4.2.1 + '@smithy/types': 4.13.1 + '@smithy/util-hex-encoding': 4.2.2 tslib: 2.8.1 - '@smithy/eventstream-serde-browser@4.2.10': + '@smithy/eventstream-serde-browser@4.2.12': dependencies: - '@smithy/eventstream-serde-universal': 4.2.10 - '@smithy/types': 4.13.0 + '@smithy/eventstream-serde-universal': 4.2.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/eventstream-serde-config-resolver@4.3.10': + '@smithy/eventstream-serde-config-resolver@4.3.12': dependencies: - '@smithy/types': 4.13.0 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/eventstream-serde-node@4.2.10': + '@smithy/eventstream-serde-node@4.2.12': dependencies: - '@smithy/eventstream-serde-universal': 4.2.10 - '@smithy/types': 4.13.0 + '@smithy/eventstream-serde-universal': 4.2.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/eventstream-serde-universal@4.2.10': + '@smithy/eventstream-serde-universal@4.2.12': dependencies: - '@smithy/eventstream-codec': 4.2.10 - '@smithy/types': 4.13.0 + '@smithy/eventstream-codec': 4.2.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/fetch-http-handler@5.3.12': + '@smithy/fetch-http-handler@5.3.15': dependencies: - '@smithy/protocol-http': 5.3.10 - '@smithy/querystring-builder': 4.2.10 - '@smithy/types': 4.13.0 - '@smithy/util-base64': 4.3.1 + '@smithy/protocol-http': 5.3.12 + '@smithy/querystring-builder': 4.2.12 + '@smithy/types': 4.13.1 + '@smithy/util-base64': 4.3.2 tslib: 2.8.1 - '@smithy/hash-blob-browser@4.2.11': + '@smithy/hash-blob-browser@4.2.13': dependencies: - '@smithy/chunked-blob-reader': 5.2.1 - '@smithy/chunked-blob-reader-native': 4.2.2 - '@smithy/types': 4.13.0 + '@smithy/chunked-blob-reader': 5.2.2 + '@smithy/chunked-blob-reader-native': 4.2.3 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/hash-node@4.2.10': + '@smithy/hash-node@4.2.12': dependencies: - '@smithy/types': 4.13.0 - '@smithy/util-buffer-from': 4.2.1 - '@smithy/util-utf8': 4.2.1 + '@smithy/types': 4.13.1 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/hash-stream-node@4.2.10': + '@smithy/hash-stream-node@4.2.12': dependencies: - '@smithy/types': 4.13.0 - '@smithy/util-utf8': 4.2.1 + '@smithy/types': 4.13.1 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/invalid-dependency@4.2.10': + '@smithy/invalid-dependency@4.2.12': dependencies: - '@smithy/types': 4.13.0 + '@smithy/types': 4.13.1 tslib: 2.8.1 '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 - '@smithy/is-array-buffer@4.2.1': + '@smithy/is-array-buffer@4.2.2': dependencies: tslib: 2.8.1 - '@smithy/md5-js@4.2.10': + '@smithy/md5-js@4.2.12': dependencies: - '@smithy/types': 4.13.0 - '@smithy/util-utf8': 4.2.1 + '@smithy/types': 4.13.1 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/middleware-content-length@4.2.10': + '@smithy/middleware-content-length@4.2.12': dependencies: - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/middleware-endpoint@4.4.21': + '@smithy/middleware-endpoint@4.4.25': dependencies: - '@smithy/core': 3.23.7 - '@smithy/middleware-serde': 4.2.11 - '@smithy/node-config-provider': 4.3.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 - '@smithy/url-parser': 4.2.10 - '@smithy/util-middleware': 4.2.10 + '@smithy/core': 3.23.11 + '@smithy/middleware-serde': 4.2.14 + '@smithy/node-config-provider': 4.3.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-middleware': 4.2.12 tslib: 2.8.1 - '@smithy/middleware-retry@4.4.38': + '@smithy/middleware-retry@4.4.42': dependencies: - '@smithy/node-config-provider': 4.3.10 - '@smithy/protocol-http': 5.3.10 - '@smithy/service-error-classification': 4.2.10 - '@smithy/smithy-client': 4.12.1 - '@smithy/types': 4.13.0 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-retry': 4.2.10 - '@smithy/uuid': 1.1.1 + '@smithy/node-config-provider': 4.3.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/service-error-classification': 4.2.12 + '@smithy/smithy-client': 4.12.5 + '@smithy/types': 4.13.1 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/uuid': 1.1.2 tslib: 2.8.1 - '@smithy/middleware-serde@4.2.11': + '@smithy/middleware-serde@4.2.14': dependencies: - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 + '@smithy/core': 3.23.11 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/middleware-stack@4.2.10': + '@smithy/middleware-stack@4.2.12': dependencies: - '@smithy/types': 4.13.0 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/node-config-provider@4.3.10': + '@smithy/node-config-provider@4.3.12': dependencies: - '@smithy/property-provider': 4.2.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 + '@smithy/property-provider': 4.2.12 + '@smithy/shared-ini-file-loader': 4.4.7 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/node-http-handler@4.4.13': + '@smithy/node-http-handler@4.4.16': dependencies: - '@smithy/abort-controller': 4.2.10 - '@smithy/protocol-http': 5.3.10 - '@smithy/querystring-builder': 4.2.10 - '@smithy/types': 4.13.0 + '@smithy/abort-controller': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/querystring-builder': 4.2.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/property-provider@4.2.10': + '@smithy/property-provider@4.2.12': dependencies: - '@smithy/types': 4.13.0 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/protocol-http@5.3.10': + '@smithy/protocol-http@5.3.12': dependencies: - '@smithy/types': 4.13.0 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/querystring-builder@4.2.10': + '@smithy/querystring-builder@4.2.12': dependencies: - '@smithy/types': 4.13.0 - '@smithy/util-uri-escape': 4.2.1 + '@smithy/types': 4.13.1 + '@smithy/util-uri-escape': 4.2.2 tslib: 2.8.1 - '@smithy/querystring-parser@4.2.10': + '@smithy/querystring-parser@4.2.12': dependencies: - '@smithy/types': 4.13.0 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/service-error-classification@4.2.10': + '@smithy/service-error-classification@4.2.12': dependencies: - '@smithy/types': 4.13.0 + '@smithy/types': 4.13.1 - '@smithy/shared-ini-file-loader@4.4.5': + '@smithy/shared-ini-file-loader@4.4.7': dependencies: - '@smithy/types': 4.13.0 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/signature-v4@5.3.10': + '@smithy/signature-v4@5.3.12': dependencies: - '@smithy/is-array-buffer': 4.2.1 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 - '@smithy/util-hex-encoding': 4.2.1 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-uri-escape': 4.2.1 - '@smithy/util-utf8': 4.2.1 + '@smithy/is-array-buffer': 4.2.2 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-uri-escape': 4.2.2 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/smithy-client@4.12.1': + '@smithy/smithy-client@4.12.5': dependencies: - '@smithy/core': 3.23.7 - '@smithy/middleware-endpoint': 4.4.21 - '@smithy/middleware-stack': 4.2.10 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 - '@smithy/util-stream': 4.5.16 + '@smithy/core': 3.23.11 + '@smithy/middleware-endpoint': 4.4.25 + '@smithy/middleware-stack': 4.2.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-stream': 4.5.19 tslib: 2.8.1 - '@smithy/types@4.13.0': + '@smithy/types@4.13.1': dependencies: tslib: 2.8.1 - '@smithy/url-parser@4.2.10': + '@smithy/url-parser@4.2.12': dependencies: - '@smithy/querystring-parser': 4.2.10 - '@smithy/types': 4.13.0 + '@smithy/querystring-parser': 4.2.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-base64@4.3.1': + '@smithy/util-base64@4.3.2': dependencies: - '@smithy/util-buffer-from': 4.2.1 - '@smithy/util-utf8': 4.2.1 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/util-body-length-browser@4.2.1': + '@smithy/util-body-length-browser@4.2.2': dependencies: tslib: 2.8.1 - '@smithy/util-body-length-node@4.2.2': + '@smithy/util-body-length-node@4.2.3': dependencies: tslib: 2.8.1 @@ -17428,65 +17426,65 @@ snapshots: '@smithy/is-array-buffer': 2.2.0 tslib: 2.8.1 - '@smithy/util-buffer-from@4.2.1': + '@smithy/util-buffer-from@4.2.2': dependencies: - '@smithy/is-array-buffer': 4.2.1 + '@smithy/is-array-buffer': 4.2.2 tslib: 2.8.1 - '@smithy/util-config-provider@4.2.1': + '@smithy/util-config-provider@4.2.2': dependencies: tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.37': + '@smithy/util-defaults-mode-browser@4.3.41': dependencies: - '@smithy/property-provider': 4.2.10 - '@smithy/smithy-client': 4.12.1 - '@smithy/types': 4.13.0 + '@smithy/property-provider': 4.2.12 + '@smithy/smithy-client': 4.12.5 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.40': + '@smithy/util-defaults-mode-node@4.2.44': dependencies: - '@smithy/config-resolver': 4.4.9 - '@smithy/credential-provider-imds': 4.2.10 - '@smithy/node-config-provider': 4.3.10 - '@smithy/property-provider': 4.2.10 - '@smithy/smithy-client': 4.12.1 - '@smithy/types': 4.13.0 + '@smithy/config-resolver': 4.4.11 + '@smithy/credential-provider-imds': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/property-provider': 4.2.12 + '@smithy/smithy-client': 4.12.5 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-endpoints@3.3.1': + '@smithy/util-endpoints@3.3.3': dependencies: - '@smithy/node-config-provider': 4.3.10 - '@smithy/types': 4.13.0 + '@smithy/node-config-provider': 4.3.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-hex-encoding@4.2.1': + '@smithy/util-hex-encoding@4.2.2': dependencies: tslib: 2.8.1 - '@smithy/util-middleware@4.2.10': + '@smithy/util-middleware@4.2.12': dependencies: - '@smithy/types': 4.13.0 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-retry@4.2.10': + '@smithy/util-retry@4.2.12': dependencies: - '@smithy/service-error-classification': 4.2.10 - '@smithy/types': 4.13.0 + '@smithy/service-error-classification': 4.2.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-stream@4.5.16': + '@smithy/util-stream@4.5.19': dependencies: - '@smithy/fetch-http-handler': 5.3.12 - '@smithy/node-http-handler': 4.4.13 - '@smithy/types': 4.13.0 - '@smithy/util-base64': 4.3.1 - '@smithy/util-buffer-from': 4.2.1 - '@smithy/util-hex-encoding': 4.2.1 - '@smithy/util-utf8': 4.2.1 + '@smithy/fetch-http-handler': 5.3.15 + '@smithy/node-http-handler': 4.4.16 + '@smithy/types': 4.13.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/util-uri-escape@4.2.1': + '@smithy/util-uri-escape@4.2.2': dependencies: tslib: 2.8.1 @@ -17495,18 +17493,18 @@ snapshots: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 - '@smithy/util-utf8@4.2.1': + '@smithy/util-utf8@4.2.2': dependencies: - '@smithy/util-buffer-from': 4.2.1 + '@smithy/util-buffer-from': 4.2.2 tslib: 2.8.1 - '@smithy/util-waiter@4.2.10': + '@smithy/util-waiter@4.2.13': dependencies: - '@smithy/abort-controller': 4.2.10 - '@smithy/types': 4.13.0 + '@smithy/abort-controller': 4.2.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/uuid@1.1.1': + '@smithy/uuid@1.1.2': dependencies: tslib: 2.8.1 @@ -17529,29 +17527,29 @@ snapshots: dependencies: acorn: 8.16.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(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.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)))': dependencies: - '@sveltejs/kit': 2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(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.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/enhanced-img@0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.5)(vite@7.3.1(@types/node@25.3.3)(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))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.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)) magic-string: 0.30.21 sharp: 0.34.5 - svelte: 5.53.5 - svelte-parse-markup: 0.1.5(svelte@5.53.5) - vite: 7.3.1(@types/node@25.3.3)(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-parse-markup: 0.1.5(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) vite-imagetools: 9.0.3(rollup@4.55.1) zimmerframe: 1.1.4 transitivePeerDependencies: - rollup - supports-color - '@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(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.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))': 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.5)(vite@7.3.1(@types/node@25.3.3)(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.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)) '@types/cookie': 0.6.0 acorn: 8.16.0 cookie: 0.6.0 @@ -17562,30 +17560,30 @@ snapshots: mrmime: 2.0.1 set-cookie-parser: 3.0.1 sirv: 3.0.2 - svelte: 5.53.5 - vite: 7.3.1(@types/node@25.3.3)(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) 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.5)(vite@7.3.1(@types/node@25.3.3)(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.5)(vite@7.3.1(@types/node@25.3.3)(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.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))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.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)) debug: 4.4.3 - svelte: 5.53.5 - vite: 7.3.1(@types/node@25.3.3)(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) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.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))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.5)(vite@7.3.1(@types/node@25.3.3)(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.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)) deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 - svelte: 5.53.5 - vite: 7.3.1(@types/node@25.3.3)(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.3.3)(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) + 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: - supports-color @@ -17646,7 +17644,7 @@ snapshots: '@svgr/hast-util-to-babel-ast@8.0.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 entities: 4.5.0 '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))': @@ -17682,51 +17680,51 @@ snapshots: - supports-color - typescript - '@swc/core-darwin-arm64@1.15.13': + '@swc/core-darwin-arm64@1.15.18': optional: true - '@swc/core-darwin-x64@1.15.13': + '@swc/core-darwin-x64@1.15.18': optional: true - '@swc/core-linux-arm-gnueabihf@1.15.13': + '@swc/core-linux-arm-gnueabihf@1.15.18': optional: true - '@swc/core-linux-arm64-gnu@1.15.13': + '@swc/core-linux-arm64-gnu@1.15.18': optional: true - '@swc/core-linux-arm64-musl@1.15.13': + '@swc/core-linux-arm64-musl@1.15.18': optional: true - '@swc/core-linux-x64-gnu@1.15.13': + '@swc/core-linux-x64-gnu@1.15.18': optional: true - '@swc/core-linux-x64-musl@1.15.13': + '@swc/core-linux-x64-musl@1.15.18': optional: true - '@swc/core-win32-arm64-msvc@1.15.13': + '@swc/core-win32-arm64-msvc@1.15.18': optional: true - '@swc/core-win32-ia32-msvc@1.15.13': + '@swc/core-win32-ia32-msvc@1.15.18': optional: true - '@swc/core-win32-x64-msvc@1.15.13': + '@swc/core-win32-x64-msvc@1.15.18': optional: true - '@swc/core@1.15.13(@swc/helpers@0.5.17)': + '@swc/core@1.15.18(@swc/helpers@0.5.17)': dependencies: '@swc/counter': 0.1.3 '@swc/types': 0.1.25 optionalDependencies: - '@swc/core-darwin-arm64': 1.15.13 - '@swc/core-darwin-x64': 1.15.13 - '@swc/core-linux-arm-gnueabihf': 1.15.13 - '@swc/core-linux-arm64-gnu': 1.15.13 - '@swc/core-linux-arm64-musl': 1.15.13 - '@swc/core-linux-x64-gnu': 1.15.13 - '@swc/core-linux-x64-musl': 1.15.13 - '@swc/core-win32-arm64-msvc': 1.15.13 - '@swc/core-win32-ia32-msvc': 1.15.13 - '@swc/core-win32-x64-msvc': 1.15.13 + '@swc/core-darwin-arm64': 1.15.18 + '@swc/core-darwin-x64': 1.15.18 + '@swc/core-linux-arm-gnueabihf': 1.15.18 + '@swc/core-linux-arm64-gnu': 1.15.18 + '@swc/core-linux-arm64-musl': 1.15.18 + '@swc/core-linux-x64-gnu': 1.15.18 + '@swc/core-linux-x64-musl': 1.15.18 + '@swc/core-win32-arm64-msvc': 1.15.18 + '@swc/core-win32-ia32-msvc': 1.15.18 + '@swc/core-win32-x64-msvc': 1.15.18 '@swc/helpers': 0.5.17 '@swc/counter@0.1.3': {} @@ -17804,12 +17802,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 - '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@25.3.3)(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@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))': dependencies: '@tailwindcss/node': 4.2.1 '@tailwindcss/oxide': 4.2.1 tailwindcss: 4.2.1 - vite: 7.3.1(@types/node@25.3.3)(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: 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) '@testing-library/dom@10.4.1': dependencies: @@ -17831,18 +17829,18 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/svelte-core@1.0.0(svelte@5.53.5)': + '@testing-library/svelte-core@1.0.0(svelte@5.53.7)': dependencies: - svelte: 5.53.5 + svelte: 5.53.7 - '@testing-library/svelte@5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(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.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(encoding@0.1.13)))(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.5) - svelte: 5.53.5 + '@testing-library/svelte-core': 1.0.0(svelte@5.53.7) + svelte: 5.53.7 optionalDependencies: - vite: 7.3.1(@types/node@25.3.3)(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.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(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) + 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(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -17880,7 +17878,7 @@ snapshots: '@types/accepts@1.3.7': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/archiver@7.0.0': dependencies: @@ -17892,16 +17890,16 @@ snapshots: '@types/bcrypt@6.0.0': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/bonjour@3.5.13': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/braces@3.0.5': {} @@ -17923,21 +17921,21 @@ snapshots: '@types/cli-progress@3.11.6': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/compression@1.8.1': dependencies: '@types/express': 5.0.6 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/connect-history-api-fallback@1.5.4': dependencies: '@types/express-serve-static-core': 5.1.0 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/connect@3.4.38': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/content-disposition@0.5.9': {} @@ -17954,11 +17952,11 @@ snapshots: '@types/connect': 3.4.38 '@types/express': 5.0.6 '@types/keygrip': 1.0.6 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/cors@2.8.19': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/d3-array@3.2.2': {} @@ -18085,13 +18083,13 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/ssh2': 1.15.5 '@types/dockerode@4.0.1': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/ssh2': 1.15.5 '@types/dom-to-image@2.6.7': {} @@ -18116,14 +18114,14 @@ snapshots: '@types/express-serve-static-core@4.19.7': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 '@types/express-serve-static-core@5.1.0': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -18149,7 +18147,7 @@ snapshots: '@types/fluent-ffmpeg@2.1.28': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/geojson@7946.0.16': {} @@ -18177,7 +18175,7 @@ snapshots: '@types/http-proxy@1.17.17': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/inquirer@8.2.12': dependencies: @@ -18201,7 +18199,7 @@ snapshots: '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/justified-layout@4.1.4': {} @@ -18220,7 +18218,7 @@ snapshots: '@types/http-errors': 2.0.5 '@types/keygrip': 1.0.6 '@types/koa-compose': 3.2.9 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/leaflet@1.9.21': dependencies: @@ -18250,17 +18248,17 @@ snapshots: '@types/mock-fs@4.13.4': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/ms@2.1.0': {} - '@types/multer@2.0.0': + '@types/multer@2.1.0': dependencies: '@types/express': 5.0.6 '@types/node-forge@1.3.14': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/node@17.0.45': {} @@ -18268,54 +18266,54 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/node@24.11.0': + '@types/node@24.12.0': dependencies: undici-types: 7.16.0 - '@types/node@25.3.3': + '@types/node@25.4.0': dependencies: undici-types: 7.18.2 optional: true '@types/nodemailer@7.0.11': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/oidc-provider@9.5.0': dependencies: '@types/keygrip': 1.0.6 '@types/koa': 3.0.1 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/parse5@5.0.3': {} '@types/pg-pool@2.0.7': dependencies: - '@types/pg': 8.16.0 + '@types/pg': 8.18.0 '@types/pg@8.15.6': dependencies: - '@types/node': 24.11.0 - pg-protocol: 1.12.0 + '@types/node': 24.12.0 + pg-protocol: 1.13.0 pg-types: 2.2.0 - '@types/pg@8.16.0': + '@types/pg@8.18.0': dependencies: - '@types/node': 24.11.0 - pg-protocol: 1.12.0 + '@types/node': 24.12.0 + pg-protocol: 1.13.0 pg-types: 2.2.0 '@types/picomatch@4.0.2': {} '@types/pngjs@6.0.5': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/prismjs@1.26.5': {} '@types/qrcode@1.5.6': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/qs@6.14.0': {} @@ -18344,28 +18342,28 @@ snapshots: '@types/readdir-glob@1.1.5': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/retry@0.12.2': {} - '@types/sanitize-html@2.16.0': + '@types/sanitize-html@2.16.1': dependencies: - htmlparser2: 8.0.2 + htmlparser2: 10.1.0 '@types/sax@1.2.7': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/semver@7.7.1': {} '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/send@1.2.1': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/serve-index@1.9.4': dependencies: @@ -18374,25 +18372,25 @@ snapshots: '@types/serve-static@1.15.10': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/send': 0.17.6 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/sockjs@0.3.36': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/ssh2-streams@0.1.13': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/ssh2@0.5.52': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/ssh2-streams': 0.1.13 '@types/ssh2@1.15.5': @@ -18403,7 +18401,7 @@ snapshots: dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 24.11.0 + '@types/node': 24.12.0 form-data: 4.0.5 '@types/supercluster@7.1.3': @@ -18417,7 +18415,7 @@ snapshots: '@types/through@0.0.33': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/trusted-types@2.0.7': {} @@ -18433,7 +18431,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/yargs-parser@21.0.3': {} @@ -18536,11 +18534,11 @@ snapshots: '@vercel/oidc@3.0.5': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(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))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 - ast-v8-to-istanbul: 0.3.8 + ast-v8-to-istanbul: 0.3.12 debug: 4.4.3 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -18551,43 +18549,37 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(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) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(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))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.14 - ast-v8-to-istanbul: 0.3.8 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.12 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.2.0 - magicast: 0.5.1 + magicast: 0.5.2 obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(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) - transitivePeerDependencies: - - supports-color + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/coverage-v8@4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(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))': + '@vitest/coverage-v8@4.0.18(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(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.14 - ast-v8-to-istanbul: 0.3.8 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.12 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.2.0 - magicast: 0.5.1 + magicast: 0.5.2 obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(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) - transitivePeerDependencies: - - supports-color + 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(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@vitest/expect@3.2.4': dependencies: @@ -18597,44 +18589,44 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/expect@4.0.14': + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.14 - '@vitest/utils': 4.0.14 - chai: 6.2.1 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.11.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/mocker@3.2.4(vite@7.3.1(@types/node@24.12.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: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@24.11.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: 7.3.1(@types/node@24.12.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/mocker@4.0.14(vite@7.3.1(@types/node@24.11.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/mocker@4.0.18(vite@7.3.1(@types/node@24.12.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: - '@vitest/spy': 4.0.14 + '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@24.11.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: 7.3.1(@types/node@24.12.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/mocker@4.0.14(vite@7.3.1(@types/node@25.3.3)(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/mocker@4.0.18(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: - '@vitest/spy': 4.0.14 + '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.3)(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: 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/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 - '@vitest/pretty-format@4.0.14': + '@vitest/pretty-format@4.0.18': dependencies: tinyrainbow: 3.0.3 @@ -18644,9 +18636,9 @@ snapshots: pathe: 2.0.3 strip-literal: 3.1.0 - '@vitest/runner@4.0.14': + '@vitest/runner@4.0.18': dependencies: - '@vitest/utils': 4.0.14 + '@vitest/utils': 4.0.18 pathe: 2.0.3 '@vitest/snapshot@3.2.4': @@ -18655,9 +18647,9 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/snapshot@4.0.14': + '@vitest/snapshot@4.0.18': dependencies: - '@vitest/pretty-format': 4.0.14 + '@vitest/pretty-format': 4.0.18 magic-string: 0.30.21 pathe: 2.0.3 @@ -18665,7 +18657,7 @@ snapshots: dependencies: tinyspy: 4.0.4 - '@vitest/spy@4.0.14': {} + '@vitest/spy@4.0.18': {} '@vitest/utils@3.2.4': dependencies: @@ -18673,9 +18665,9 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vitest/utils@4.0.14': + '@vitest/utils@4.0.18': dependencies: - '@vitest/pretty-format': 4.0.14 + '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 '@webassemblyjs/ast@1.14.1': @@ -18764,10 +18756,10 @@ snapshots: dependencies: '@namnode/store': 0.1.0 - '@zoom-image/svelte@0.3.9(svelte@5.53.5)': + '@zoom-image/svelte@0.3.9(svelte@5.53.7)': dependencies: '@zoom-image/core': 0.42.0 - svelte: 5.53.5 + svelte: 5.53.7 abbrev@1.1.1: {} @@ -18989,11 +18981,11 @@ snapshots: dependencies: '@mdn/browser-compat-data': 5.7.6 - ast-v8-to-istanbul@0.3.8: + ast-v8-to-istanbul@0.3.12: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 - js-tokens: 9.0.1 + js-tokens: 10.0.0 astring@1.9.0: {} @@ -19131,15 +19123,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(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.5): + 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): 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.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(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.5) - svelte: 5.53.5 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(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.5) + 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) tabbable: 6.4.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -19263,7 +19255,7 @@ snapshots: builtin-modules@5.0.0: {} - bullmq@5.70.1: + bullmq@5.70.4: dependencies: cron-parser: 4.9.0 ioredis: 5.9.3 @@ -19360,16 +19352,6 @@ snapshots: caniuse-lite@1.0.30001776: {} - canvas@2.11.2: - dependencies: - '@mapbox/node-pre-gyp': 1.0.11 - nan: 2.25.0 - simple-get: 3.1.1 - transitivePeerDependencies: - - encoding - - supports-color - optional: true - canvas@2.11.2(encoding@0.1.13): dependencies: '@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13) @@ -19390,7 +19372,7 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 - chai@6.2.1: {} + chai@6.2.2: {} chalk@4.1.2: dependencies: @@ -19484,7 +19466,7 @@ snapshots: class-transformer@0.5.1: {} - class-validator@0.14.4: + class-validator@0.15.1: dependencies: '@types/validator': 13.15.10 libphonenumber-js: 1.12.38 @@ -20434,7 +20416,7 @@ snapshots: engine.io@6.6.5: dependencies: '@types/cors': 2.8.19 - '@types/node': 24.11.0 + '@types/node': 24.12.0 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.7.2 @@ -20647,7 +20629,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.5): + eslint-plugin-svelte@3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.7): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) '@jridgewell/sourcemap-codec': 1.5.5 @@ -20659,9 +20641,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.5.1(svelte@5.53.5) + svelte-eslint-parser: 1.6.0(svelte@5.53.7) optionalDependencies: - svelte: 5.53.5 + svelte: 5.53.7 transitivePeerDependencies: - ts-node @@ -20833,7 +20815,7 @@ snapshots: eval@0.1.8: dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 require-like: 0.1.2 event-emitter@0.3.5: @@ -20867,23 +20849,23 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 - exiftool-vendored.exe@13.51.0: + exiftool-vendored.exe@13.52.0: optional: true - exiftool-vendored.pl@13.51.0: {} + exiftool-vendored.pl@13.52.0: {} - exiftool-vendored@35.10.1: + exiftool-vendored@35.13.1: dependencies: - '@photostructure/tz-lookup': 11.4.0 + '@photostructure/tz-lookup': 11.5.0 '@types/luxon': 3.7.1 batch-cluster: 17.3.1 - exiftool-vendored.pl: 13.51.0 + exiftool-vendored.pl: 13.52.0 he: 1.2.0 luxon: 3.7.2 optionalDependencies: - exiftool-vendored.exe: 13.51.0 + exiftool-vendored.exe: 13.52.0 - expect-type@1.2.2: {} + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} @@ -20968,10 +20950,10 @@ snapshots: extend@3.0.2: {} - fabric@7.2.0: + fabric@7.2.0(encoding@0.1.13): optionalDependencies: - canvas: 2.11.2 - jsdom: 26.1.0(canvas@2.11.2) + canvas: 2.11.2(encoding@0.1.13) + jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: - bufferutil - encoding @@ -21007,11 +20989,13 @@ snapshots: fast-uri@3.1.0: {} - fast-xml-builder@1.0.0: {} + fast-xml-builder@1.1.2: + dependencies: + path-expression-matcher: 1.1.3 fast-xml-parser@5.4.1: dependencies: - fast-xml-builder: 1.0.0 + fast-xml-builder: 1.1.2 strnum: 2.2.0 fastq@1.20.1: @@ -21133,7 +21117,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17))): + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.18(@swc/helpers@0.5.17))): dependencies: '@babel/code-frame': 7.29.0 chalk: 4.1.2 @@ -21148,7 +21132,7 @@ snapshots: semver: 7.7.4 tapable: 2.3.0 typescript: 5.9.3 - webpack: 5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17)) + webpack: 5.104.1(@swc/core@1.15.18(@swc/helpers@0.5.17)) form-data-encoder@2.1.4: {} @@ -21344,7 +21328,7 @@ snapshots: globals@16.5.0: {} - globals@17.3.0: {} + globals@17.4.0: {} globalyzer@0.1.0: {} @@ -21411,9 +21395,9 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 - happy-dom@20.7.0: + happy-dom@20.8.3: dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -21665,6 +21649,13 @@ snapshots: optionalDependencies: webpack: 5.104.1 + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + htmlparser2@6.1.0: dependencies: domelementtype: 2.3.0 @@ -21804,7 +21795,7 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - import-in-the-middle@2.0.6: + import-in-the-middle@3.0.0: dependencies: acorn: 8.16.0 acorn-import-attributes: 1.9.5(acorn@8.16.0) @@ -21836,9 +21827,9 @@ snapshots: inline-style-parser@0.2.7: {} - inquirer@8.2.7(@types/node@24.11.0): + inquirer@8.2.7(@types/node@24.12.0): dependencies: - '@inquirer/external-editor': 1.0.3(@types/node@24.11.0) + '@inquirer/external-editor': 1.0.3(@types/node@24.12.0) ansi-escapes: 4.3.2 chalk: 4.1.2 cli-cursor: 3.1.0 @@ -21878,6 +21869,20 @@ snapshots: dependencies: loose-envify: 1.4.0 + ioredis@5.10.0: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ioredis@5.9.3: dependencies: '@ioredis/commands': 1.5.0 @@ -22009,7 +22014,7 @@ snapshots: dependencies: is-docker: 2.2.1 - is-wsl@3.1.0: + is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 @@ -22061,7 +22066,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 24.11.0 + '@types/node': 24.12.0 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -22069,13 +22074,13 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 merge-stream: 2.0.0 supports-color: 8.1.1 jest-worker@29.7.0: dependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -22100,6 +22105,8 @@ snapshots: jose@6.1.3: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -22143,36 +22150,6 @@ snapshots: - utf-8-validate optional: true - jsdom@26.1.0(canvas@2.11.2): - dependencies: - cssstyle: 4.6.0 - data-urls: 5.0.0 - decimal.js: 10.6.0 - html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.23 - parse5: 7.3.0 - rrweb-cssom: 0.8.0 - saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 5.1.2 - w3c-xmlserializer: 5.0.0 - webidl-conversions: 7.0.0 - whatwg-encoding: 3.1.1 - whatwg-mimetype: 4.0.0 - whatwg-url: 14.2.0 - ws: 8.19.0 - xml-name-validator: 5.0.0 - optionalDependencies: - canvas: 2.11.2 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - optional: true - jsep@1.4.0: {} jsesc@3.1.0: {} @@ -22292,16 +22269,8 @@ snapshots: optionalDependencies: postgres: 3.4.8 - kysely-postgres-js@3.0.0(kysely@0.28.2)(postgres@3.4.8): - dependencies: - kysely: 0.28.2 - optionalDependencies: - postgres: 3.4.8 - kysely@0.28.11: {} - kysely@0.28.2: {} - langium@3.3.1: dependencies: chevrotain: 11.0.3 @@ -22512,14 +22481,14 @@ snapshots: magicast@0.3.5: dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 source-map-js: 1.2.1 - magicast@0.5.1: + magicast@0.5.2: dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 source-map-js: 1.2.1 make-dir@3.1.0: @@ -23250,10 +23219,6 @@ snapshots: mkdirp@0.3.0: {} - mkdirp@0.5.6: - dependencies: - minimist: 1.2.8 - mkdirp@1.0.4: {} mkdirp@3.0.1: {} @@ -23299,16 +23264,6 @@ snapshots: optionalDependencies: msgpackr-extract: 3.0.3 - multer@2.0.2: - dependencies: - append-field: 1.0.0 - busboy: 1.6.0 - concat-stream: 2.0.0 - mkdirp: 0.5.6 - object-assign: 4.1.1 - type-is: 1.6.18 - xtend: 4.0.2 - multer@2.1.1: dependencies: append-field: 1.0.0 @@ -23359,39 +23314,39 @@ snapshots: neo-async@2.6.2: {} - nest-commander@3.20.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@types/inquirer@8.2.12)(@types/node@24.11.0)(typescript@5.9.3): + nest-commander@3.20.1(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@types/inquirer@8.2.12)(@types/node@24.12.0)(typescript@5.9.3): dependencies: '@fig/complete-commander': 3.2.0(commander@11.1.0) - '@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@types/inquirer': 8.2.12 commander: 11.1.0 cosmiconfig: 8.3.6(typescript@5.9.3) - inquirer: 8.2.7(@types/node@24.11.0) + inquirer: 8.2.7(@types/node@24.12.0) transitivePeerDependencies: - '@types/node' - typescript - nestjs-cls@5.4.3(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2): + nestjs-cls@5.4.3(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2): dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 rxjs: 7.8.2 - nestjs-kysely@3.1.2(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(kysely@0.28.2)(reflect-metadata@0.2.2): + nestjs-kysely@3.1.2(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(kysely@0.28.11)(reflect-metadata@0.2.2): dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) - kysely: 0.28.2 + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + kysely: 0.28.11 reflect-metadata: 0.2.2 tslib: 2.8.1 - nestjs-otel@7.0.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14): + nestjs-otel@7.0.1(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16): dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': 1.9.0 '@opentelemetry/host-metrics': 0.36.2(@opentelemetry/api@1.9.0) response-time: 2.3.4 @@ -23424,11 +23379,6 @@ snapshots: emojilib: 2.4.0 skin-tone: 2.0.0 - node-fetch@2.7.0: - dependencies: - whatwg-url: 5.0.0 - optional: true - node-fetch@2.7.0(encoding@0.1.13): dependencies: whatwg-url: 5.0.0 @@ -23626,7 +23576,7 @@ snapshots: log-symbols: 6.0.0 stdin-discarder: 0.2.2 string-width: 7.2.0 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 p-cancelable@3.0.0: {} @@ -23749,6 +23699,8 @@ snapshots: path-exists@5.0.0: {} + path-expression-matcher@1.1.3: {} + path-is-absolute@1.0.1: {} path-is-inside@1.0.2: {} @@ -23802,15 +23754,15 @@ snapshots: pg-cloudflare@1.3.0: optional: true - pg-connection-string@2.11.0: {} + pg-connection-string@2.12.0: {} pg-int8@1.0.1: {} - pg-pool@3.12.0(pg@8.19.0): + pg-pool@3.13.0(pg@8.20.0): dependencies: - pg: 8.19.0 + pg: 8.20.0 - pg-protocol@1.12.0: {} + pg-protocol@1.13.0: {} pg-types@2.2.0: dependencies: @@ -23820,11 +23772,11 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg@8.19.0: + pg@8.20.0: dependencies: - pg-connection-string: 2.11.0 - pg-pool: 3.12.0(pg@8.19.0) - pg-protocol: 1.12.0 + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: @@ -24405,10 +24357,10 @@ snapshots: dependencies: prettier: 3.8.1 - prettier-plugin-svelte@3.5.0(prettier@3.8.1)(svelte@5.53.5): + prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.53.7): dependencies: prettier: 3.8.1 - svelte: 5.53.5 + svelte: 5.53.7 prettier@3.8.1: {} @@ -24488,22 +24440,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 24.11.0 - long: 5.3.2 - - protobufjs@8.0.0: - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/base64': 1.1.2 - '@protobufjs/codegen': 2.0.4 - '@protobufjs/eventemitter': 1.1.0 - '@protobufjs/fetch': 1.1.0 - '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.0 - '@protobufjs/path': 1.1.2 - '@protobufjs/pool': 1.1.0 - '@protobufjs/utf8': 1.1.0 - '@types/node': 24.11.0 + '@types/node': 24.12.0 long: 5.3.2 protocol-buffers-schema@3.6.0: {} @@ -24599,7 +24536,7 @@ snapshots: react-email@4.3.2: dependencies: - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.0 '@babel/traverse': 7.28.5 chokidar: 4.0.3 commander: 13.1.0 @@ -24953,7 +24890,7 @@ snapshots: robust-predicates@3.0.2: {} - rollup-plugin-visualizer@6.0.5(rollup@4.55.1): + rollup-plugin-visualizer@6.0.11(rollup@4.55.1): dependencies: open: 8.4.2 picomatch: 4.0.3 @@ -25028,14 +24965,14 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.35.1(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(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.5): + 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): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 - svelte: 5.53.5 + svelte: 5.53.7 optionalDependencies: - '@sveltejs/kit': 2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(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.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)) rw@1.3.3: {} @@ -25541,13 +25478,13 @@ snapshots: dependencies: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 string-width@7.2.0: dependencies: emoji-regex: 10.6.0 get-east-asian-width: 1.4.0 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 string_decoder@1.1.1: dependencies: @@ -25572,7 +25509,7 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.1.2: + strip-ansi@7.2.0: dependencies: ansi-regex: 6.2.2 @@ -25666,23 +25603,23 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-awesome@3.3.5(svelte@5.53.5): + svelte-awesome@3.3.5(svelte@5.53.7): dependencies: - svelte: 5.53.5 + svelte: 5.53.7 - svelte-check@4.4.3(picomatch@4.0.3)(svelte@5.53.5)(typescript@5.9.3): + svelte-check@4.4.4(picomatch@4.0.3)(svelte@5.53.7)(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.5 + svelte: 5.53.7 typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.5.1(svelte@5.53.5): + svelte-eslint-parser@1.6.0(svelte@5.53.7): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -25692,7 +25629,7 @@ snapshots: postcss-selector-parser: 7.1.1 semver: 7.7.4 optionalDependencies: - svelte: 5.53.5 + svelte: 5.53.7 svelte-floating-ui@1.5.8: dependencies: @@ -25705,7 +25642,7 @@ snapshots: dependencies: highlight.js: 11.11.1 - svelte-i18n@4.0.1(svelte@5.53.5): + svelte-i18n@4.0.1(svelte@5.53.7): dependencies: cli-color: 2.0.4 deepmerge: 4.3.1 @@ -25713,10 +25650,10 @@ snapshots: estree-walker: 2.0.2 intl-messageformat: 10.7.18 sade: 1.8.1 - svelte: 5.53.5 + svelte: 5.53.7 tiny-glob: 0.2.9 - svelte-jsoneditor@3.11.0(svelte@5.53.5): + svelte-jsoneditor@3.11.0(svelte@5.53.7): dependencies: '@codemirror/autocomplete': 6.20.0 '@codemirror/commands': 6.10.1 @@ -25743,42 +25680,42 @@ snapshots: memoize-one: 6.0.0 natural-compare-lite: 1.4.0 sass: 1.97.1 - svelte: 5.53.5 - svelte-awesome: 3.3.5(svelte@5.53.5) + svelte: 5.53.7 + svelte-awesome: 3.3.5(svelte@5.53.7) svelte-select: 5.8.3 vanilla-picker: 2.12.3 - svelte-maplibre@1.2.6(svelte@5.53.5): + svelte-maplibre@1.2.6(svelte@5.53.7): 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.5 + svelte: 5.53.7 - svelte-parse-markup@0.1.5(svelte@5.53.5): + svelte-parse-markup@0.1.5(svelte@5.53.7): dependencies: - svelte: 5.53.5 + svelte: 5.53.7 - svelte-persisted-store@0.12.0(svelte@5.53.5): + svelte-persisted-store@0.12.0(svelte@5.53.7): dependencies: - svelte: 5.53.5 + svelte: 5.53.7 svelte-select@5.8.3: dependencies: svelte-floating-ui: 1.5.8 - svelte-toolbelt@0.10.6(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(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.5): + 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): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(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.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(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.5) + 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) style-to-object: 1.0.14 - svelte: 5.53.5 + svelte: 5.53.7 transitivePeerDependencies: - '@sveltejs/kit' - svelte@5.53.5: + svelte@5.53.7: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 @@ -25832,13 +25769,13 @@ snapshots: tabbable@6.4.0: {} - tailwind-merge@3.4.0: {} + tailwind-merge@3.5.0: {} - tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1): + tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1): dependencies: tailwindcss: 4.2.1 optionalDependencies: - tailwind-merge: 3.4.0 + tailwind-merge: 3.5.0 tailwindcss-email-variants@3.0.5(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)): dependencies: @@ -25947,16 +25884,16 @@ snapshots: - react-native-b4a optional: true - terser-webpack-plugin@5.3.16(@swc/core@1.15.13(@swc/helpers@0.5.17))(webpack@5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17))): + terser-webpack-plugin@5.3.16(@swc/core@1.15.18(@swc/helpers@0.5.17))(webpack@5.104.1(@swc/core@1.15.18(@swc/helpers@0.5.17))): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 serialize-javascript: 6.0.2 terser: 5.44.1 - webpack: 5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17)) + webpack: 5.104.1(@swc/core@1.15.18(@swc/helpers@0.5.17)) optionalDependencies: - '@swc/core': 1.15.13(@swc/helpers@0.5.17) + '@swc/core': 1.15.18(@swc/helpers@0.5.17) terser-webpack-plugin@5.3.16(webpack@5.104.1): dependencies: @@ -26334,10 +26271,10 @@ snapshots: unpipe@1.0.0: {} - unplugin-swc@1.5.9(@swc/core@1.15.13(@swc/helpers@0.5.17))(rollup@4.55.1): + unplugin-swc@1.5.9(@swc/core@1.15.18(@swc/helpers@0.5.17))(rollup@4.55.1): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.55.1) - '@swc/core': 1.15.13(@swc/helpers@0.5.17) + '@swc/core': 1.15.18(@swc/helpers@0.5.17) load-tsconfig: 0.2.5 unplugin: 2.3.11 transitivePeerDependencies: @@ -26471,13 +26408,13 @@ snapshots: - rollup - supports-color - vite-node@3.2.4(@types/node@24.11.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-node@3.2.4(@types/node@24.12.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: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@24.11.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: 7.3.1(@types/node@24.12.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: - '@types/node' - jiti @@ -26492,17 +26429,17 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.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-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.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: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) - vite: 7.3.1(@types/node@24.11.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: 7.3.1(@types/node@24.12.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 - typescript - vite@7.3.1(@types/node@24.11.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@7.3.1(@types/node@24.12.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: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -26511,7 +26448,7 @@ snapshots: rollup: 4.55.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.11.0 + '@types/node': 24.12.0 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.31.1 @@ -26520,7 +26457,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vite@7.3.1(@types/node@25.3.3)(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@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: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -26529,7 +26466,7 @@ snapshots: rollup: 4.55.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.3.3 + '@types/node': 25.4.0 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.31.1 @@ -26538,19 +26475,19 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vitefu@1.1.1(vite@7.3.1(@types/node@25.3.3)(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)): optionalDependencies: - vite: 7.3.1(@types/node@25.3.3)(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: 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-fetch-mock@0.4.5(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(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)): + vitest-fetch-mock@0.4.5(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: - vitest: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(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) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(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): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.11.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/mocker': 3.2.4(vite@7.3.1(@types/node@24.12.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/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -26558,7 +26495,7 @@ snapshots: '@vitest/utils': 3.2.4 chai: 5.3.3 debug: 4.4.3 - expect-type: 1.2.2 + expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 picomatch: 4.0.3 @@ -26568,14 +26505,14 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@24.11.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-node: 3.2.4(@types/node@24.11.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: 7.3.1(@types/node@24.12.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-node: 3.2.4(@types/node@24.12.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) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 24.11.0 - happy-dom: 20.7.0 - jsdom: 26.1.0(canvas@2.11.2) + '@types/node': 24.12.0 + happy-dom: 20.8.3 + jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: - jiti - less @@ -26590,32 +26527,32 @@ snapshots: - tsx - yaml - vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(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@24.12.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - '@vitest/expect': 4.0.14 - '@vitest/mocker': 4.0.14(vite@7.3.1(@types/node@24.11.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/pretty-format': 4.0.14 - '@vitest/runner': 4.0.14 - '@vitest/snapshot': 4.0.14 - '@vitest/spy': 4.0.14 - '@vitest/utils': 4.0.14 + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.12.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/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 es-module-lexer: 1.7.0 - expect-type: 1.2.2 + expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.3 std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 0.3.2 + tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@24.11.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: 7.3.1(@types/node@24.12.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) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - '@types/node': 24.11.0 - happy-dom: 20.7.0 + '@types/node': 24.12.0 + happy-dom: 20.8.3 jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: - jiti @@ -26630,73 +26567,33 @@ snapshots: - tsx - yaml - vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(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: - '@vitest/expect': 4.0.14 - '@vitest/mocker': 4.0.14(vite@7.3.1(@types/node@24.11.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/pretty-format': 4.0.14 - '@vitest/runner': 4.0.14 - '@vitest/snapshot': 4.0.14 - '@vitest/spy': 4.0.14 - '@vitest/utils': 4.0.14 - es-module-lexer: 1.7.0 - expect-type: 1.2.2 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@24.11.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) - why-is-node-running: 2.3.0 - optionalDependencies: - '@opentelemetry/api': 1.9.0 - '@types/node': 24.11.0 - happy-dom: 20.7.0 - jsdom: 26.1.0(canvas@2.11.2) - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml - - vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(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): + 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(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - '@vitest/expect': 4.0.14 - '@vitest/mocker': 4.0.14(vite@7.3.1(@types/node@25.3.3)(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/pretty-format': 4.0.14 - '@vitest/runner': 4.0.14 - '@vitest/snapshot': 4.0.14 - '@vitest/spy': 4.0.14 - '@vitest/utils': 4.0.14 + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(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/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 es-module-lexer: 1.7.0 - expect-type: 1.2.2 + expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.3 std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 0.3.2 + tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.3.3)(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: 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) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - '@types/node': 25.3.3 - happy-dom: 20.7.0 - jsdom: 26.1.0(canvas@2.11.2) + '@types/node': 25.4.0 + happy-dom: 20.8.3 + jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: - jiti - less @@ -26873,7 +26770,7 @@ snapshots: - esbuild - uglify-js - webpack@5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17)): + webpack@5.104.1(@swc/core@1.15.18(@swc/helpers@0.5.17)): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -26897,7 +26794,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.0 - terser-webpack-plugin: 5.3.16(@swc/core@1.15.13(@swc/helpers@0.5.17))(webpack@5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17))) + terser-webpack-plugin: 5.3.16(@swc/core@1.15.18(@swc/helpers@0.5.17))(webpack@5.104.1(@swc/core@1.15.18(@swc/helpers@0.5.17))) watchpack: 2.5.1 webpack-sources: 3.3.3 transitivePeerDependencies: @@ -26995,7 +26892,7 @@ snapshots: dependencies: ansi-styles: 6.2.3 string-width: 5.1.2 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 wrappy@1.0.2: {} @@ -27014,7 +26911,7 @@ snapshots: wsl-utils@0.1.0: dependencies: - is-wsl: 3.1.0 + is-wsl: 3.1.1 xdg-basedir@5.1.0: {} diff --git a/server/package.json b/server/package.json index 37f4e2c2a349c..e6006ea8ea0ef 100644 --- a/server/package.json +++ b/server/package.json @@ -49,14 +49,14 @@ "@nestjs/websockets": "^11.0.4", "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.0.0", - "@opentelemetry/exporter-prometheus": "^0.212.0", - "@opentelemetry/instrumentation-http": "^0.212.0", - "@opentelemetry/instrumentation-ioredis": "^0.60.0", - "@opentelemetry/instrumentation-nestjs-core": "^0.58.0", - "@opentelemetry/instrumentation-pg": "^0.64.0", + "@opentelemetry/exporter-prometheus": "^0.213.0", + "@opentelemetry/instrumentation-http": "^0.213.0", + "@opentelemetry/instrumentation-ioredis": "^0.61.0", + "@opentelemetry/instrumentation-nestjs-core": "^0.59.0", + "@opentelemetry/instrumentation-pg": "^0.65.0", "@opentelemetry/resources": "^2.0.1", "@opentelemetry/sdk-metrics": "^2.0.1", - "@opentelemetry/sdk-node": "^0.212.0", + "@opentelemetry/sdk-node": "^0.213.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@react-email/components": "^0.5.0", "@react-email/render": "^1.1.2", @@ -69,7 +69,7 @@ "bullmq": "^5.51.0", "chokidar": "^4.0.3", "class-transformer": "^0.5.1", - "class-validator": "^0.14.0", + "class-validator": "^0.15.0", "compression": "^1.8.0", "cookie": "^1.0.2", "cookie-parser": "^1.4.7", @@ -85,7 +85,7 @@ "jose": "^5.10.0", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", - "kysely": "0.28.2", + "kysely": "0.28.11", "kysely-postgres-js": "^3.0.0", "lodash": "^4.17.21", "luxon": "^3.4.2", diff --git a/server/src/controllers/shared-link.controller.spec.ts b/server/src/controllers/shared-link.controller.spec.ts index 96c84040ca402..d8b89d00298fa 100644 --- a/server/src/controllers/shared-link.controller.spec.ts +++ b/server/src/controllers/shared-link.controller.spec.ts @@ -1,7 +1,8 @@ import { SharedLinkController } from 'src/controllers/shared-link.controller'; -import { SharedLinkType } from 'src/enum'; +import { Permission, SharedLinkType } from 'src/enum'; import { SharedLinkService } from 'src/services/shared-link.service'; import request from 'supertest'; +import { factory } from 'test/small.factory'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; describe(SharedLinkController.name, () => { @@ -31,4 +32,16 @@ describe(SharedLinkController.name, () => { expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null })); }); }); + + describe('DELETE /shared-links/:id/assets', () => { + it('should require shared link update permission', async () => { + await request(ctx.getHttpServer()).delete(`/shared-links/${factory.uuid()}/assets`).send({ assetIds: [] }); + + expect(ctx.authenticate).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ permission: Permission.SharedLinkUpdate, sharedLinkRoute: false }), + }), + ); + }); + }); }); diff --git a/server/src/controllers/shared-link.controller.ts b/server/src/controllers/shared-link.controller.ts index 1f91409e804f2..c7ba589a9fc29 100644 --- a/server/src/controllers/shared-link.controller.ts +++ b/server/src/controllers/shared-link.controller.ts @@ -180,7 +180,7 @@ export class SharedLinkController { } @Delete(':id/assets') - @Authenticated({ sharedLink: true }) + @Authenticated({ permission: Permission.SharedLinkUpdate }) @Endpoint({ summary: 'Remove assets from a shared link', description: diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 7e3acdb5ed0a3..a17841b3e191a 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -154,10 +154,11 @@ export class StorageCore { } async moveAssetVideo(asset: StorageAsset) { + const encodedVideoFile = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false }); return this.moveFile({ entityId: asset.id, pathType: AssetPathType.EncodedVideo, - oldPath: asset.encodedVideoPath, + oldPath: encodedVideoFile?.path || null, newPath: StorageCore.getEncodedVideoPath(asset), }); } @@ -303,21 +304,15 @@ export class StorageCore { case AssetPathType.Original: { return this.assetRepository.update({ id, originalPath: newPath }); } - case AssetFileType.FullSize: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath }); - } - case AssetFileType.Preview: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath }); - } - case AssetFileType.Thumbnail: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Thumbnail, path: newPath }); - } - case AssetPathType.EncodedVideo: { - return this.assetRepository.update({ id, encodedVideoPath: newPath }); - } + + case AssetFileType.FullSize: + case AssetFileType.EncodedVideo: + case AssetFileType.Thumbnail: + case AssetFileType.Preview: case AssetFileType.Sidecar: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath }); + return this.assetRepository.upsertFile({ assetId: id, type: pathType as AssetFileType, path: newPath }); } + case PersonPathType.Face: { return this.personRepository.update({ id, thumbnailPath: newPath }); } diff --git a/server/src/database.ts b/server/src/database.ts index 80bb5c3ed1635..6b6b91eab3249 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -1,4 +1,4 @@ -import { Selectable } from 'kysely'; +import { Selectable, ShallowDehydrateObject } from 'kysely'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { AlbumUserRole, @@ -16,6 +16,7 @@ import { } from 'src/enum'; import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; +import { AssetTable } from 'src/schema/tables/asset.table'; import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table'; import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; import { UserMetadataItem } from 'src/types'; @@ -31,7 +32,7 @@ export type AuthUser = { }; export type AlbumUser = { - user: User; + user: ShallowDehydrateObject; role: AlbumUserRole; }; @@ -67,7 +68,7 @@ export type Activity = { updatedAt: Date; albumId: string; userId: string; - user: User; + user: ShallowDehydrateObject; assetId: string | null; comment: string | null; isLiked: boolean; @@ -105,7 +106,7 @@ export type Memory = { data: object; ownerId: string; isSaved: boolean; - assets: MapAsset[]; + assets: ShallowDehydrateObject[]; }; export type Asset = { @@ -153,15 +154,14 @@ export type StorageAsset = { id: string; ownerId: string; files: AssetFile[]; - encodedVideoPath: string | null; }; export type Stack = { id: string; primaryAssetId: string; - owner?: User; + owner?: ShallowDehydrateObject; ownerId: string; - assets: MapAsset[]; + assets: ShallowDehydrateObject[]; assetCount?: number; }; @@ -177,11 +177,11 @@ export type AuthSharedLink = { export type SharedLink = { id: string; - album?: Album | null; + album?: ShallowDehydrateObject | null; albumId: string | null; allowDownload: boolean; allowUpload: boolean; - assets: MapAsset[]; + assets: ShallowDehydrateObject[]; createdAt: Date; description: string | null; expiresAt: Date | null; @@ -194,8 +194,8 @@ export type SharedLink = { }; export type Album = Selectable & { - owner: User; - assets: MapAsset[]; + owner: ShallowDehydrateObject; + assets: ShallowDehydrateObject>[]; }; export type AuthSession = { @@ -205,9 +205,9 @@ export type AuthSession = { export type Partner = { sharedById: string; - sharedBy: User; + sharedBy: ShallowDehydrateObject; sharedWithId: string; - sharedWith: User; + sharedWith: ShallowDehydrateObject; createdAt: Date; createId: string; updatedAt: Date; @@ -272,7 +272,7 @@ export type AssetFace = { imageWidth: number; personId: string | null; sourceType: SourceType; - person?: Person | null; + person?: ShallowDehydrateObject | null; updatedAt: Date; updateId: string; isVisible: boolean; diff --git a/server/src/dtos/album-response.dto.spec.ts b/server/src/dtos/album-response.dto.spec.ts index d3536a3482e69..e82067580b6dc 100644 --- a/server/src/dtos/album-response.dto.spec.ts +++ b/server/src/dtos/album-response.dto.spec.ts @@ -1,18 +1,22 @@ import { mapAlbum } from 'src/dtos/album.dto'; import { AlbumFactory } from 'test/factories/album.factory'; +import { getForAlbum } from 'test/mappers'; describe('mapAlbum', () => { it('should set start and end dates', () => { const startDate = new Date('2023-02-22T05:06:29.716Z'); const endDate = new Date('2025-01-01T01:02:03.456Z'); - const album = AlbumFactory.from().asset({ localDateTime: endDate }).asset({ localDateTime: startDate }).build(); - const dto = mapAlbum(album, false); - expect(dto.startDate).toEqual(startDate); - expect(dto.endDate).toEqual(endDate); + const album = AlbumFactory.from() + .asset({ localDateTime: endDate }, (builder) => builder.exif()) + .asset({ localDateTime: startDate }, (builder) => builder.exif()) + .build(); + const dto = mapAlbum(getForAlbum(album), false); + expect(dto.startDate).toEqual(startDate.toISOString()); + expect(dto.endDate).toEqual(endDate.toISOString()); }); it('should not set start and end dates for empty assets', () => { - const dto = mapAlbum(AlbumFactory.create(), false); + const dto = mapAlbum(getForAlbum(AlbumFactory.create()), false); expect(dto.startDate).toBeUndefined(); expect(dto.endDate).toBeUndefined(); }); diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 62013fbd92b6f..b270125b36513 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -1,13 +1,16 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { ArrayNotEmpty, IsArray, IsString, ValidateNested } from 'class-validator'; +import { ShallowDehydrateObject } from 'kysely'; import _ from 'lodash'; import { AlbumUser, AuthSharedLink, User } from 'src/database'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; +import { mapUser, UserResponseDto } from 'src/dtos/user.dto'; import { AlbumUserRole, AssetOrder } from 'src/enum'; +import { MaybeDehydrated } from 'src/types'; +import { asDateString } from 'src/utils/date'; import { Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation'; export class AlbumInfoDto { @@ -151,10 +154,10 @@ export class AlbumResponseDto { albumName!: string; @ApiProperty({ description: 'Album description' }) description!: string; - @ApiProperty({ description: 'Creation date' }) - createdAt!: Date; - @ApiProperty({ description: 'Last update date' }) - updatedAt!: Date; + @ApiProperty({ description: 'Creation date', format: 'date-time' }) + createdAt!: string; + @ApiProperty({ description: 'Last update date', format: 'date-time' }) + updatedAt!: string; @ApiProperty({ description: 'Thumbnail asset ID' }) albumThumbnailAssetId!: string | null; @ApiProperty({ description: 'Is shared album' }) @@ -172,12 +175,12 @@ export class AlbumResponseDto { owner!: UserResponseDto; @ApiProperty({ type: 'integer', description: 'Number of assets' }) assetCount!: number; - @ApiPropertyOptional({ description: 'Last modified asset timestamp' }) - lastModifiedAssetTimestamp?: Date; - @ApiPropertyOptional({ description: 'Start date (earliest asset)' }) - startDate?: Date; - @ApiPropertyOptional({ description: 'End date (latest asset)' }) - endDate?: Date; + @ApiPropertyOptional({ description: 'Last modified asset timestamp', format: 'date-time' }) + lastModifiedAssetTimestamp?: string; + @ApiPropertyOptional({ description: 'Start date (earliest asset)', format: 'date-time' }) + startDate?: string; + @ApiPropertyOptional({ description: 'End date (latest asset)', format: 'date-time' }) + endDate?: string; @ApiProperty({ description: 'Activity feed enabled' }) isActivityEnabled!: boolean; @ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', description: 'Asset sort order', optional: true }) @@ -191,8 +194,8 @@ export class AlbumResponseDto { export type MapAlbumDto = { albumUsers?: AlbumUser[]; - assets?: MapAsset[]; - sharedLinks?: AuthSharedLink[]; + assets?: ShallowDehydrateObject[]; + sharedLinks?: ShallowDehydrateObject[]; albumName: string; description: string; albumThumbnailAssetId: string | null; @@ -200,12 +203,16 @@ export type MapAlbumDto = { updatedAt: Date; id: string; ownerId: string; - owner: User; + owner: ShallowDehydrateObject; isActivityEnabled: boolean; order: AssetOrder; }; -export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => { +export const mapAlbum = ( + entity: MaybeDehydrated, + withAssets: boolean, + auth?: AuthDto, +): AlbumResponseDto => { const albumUsers: AlbumUserResponseDto[] = []; if (entity.albumUsers) { @@ -236,16 +243,16 @@ export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDt albumName: entity.albumName, description: entity.description, albumThumbnailAssetId: entity.albumThumbnailAssetId, - createdAt: entity.createdAt, - updatedAt: entity.updatedAt, + createdAt: asDateString(entity.createdAt), + updatedAt: asDateString(entity.updatedAt), id: entity.id, ownerId: entity.ownerId, owner: mapUser(entity.owner), albumUsers: albumUsersSorted, shared: hasSharedUser || hasSharedLink, hasSharedLink, - startDate, - endDate, + startDate: asDateString(startDate), + endDate: asDateString(endDate), assets: (withAssets ? assets : []).map((asset) => mapAsset(asset, { auth })), assetCount: entity.assets?.length || 0, isActivityEnabled: entity.isActivityEnabled, @@ -253,5 +260,5 @@ export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDt }; }; -export const mapAlbumWithAssets = (entity: MapAlbumDto) => mapAlbum(entity, true); -export const mapAlbumWithoutAssets = (entity: MapAlbumDto) => mapAlbum(entity, false); +export const mapAlbumWithAssets = (entity: MaybeDehydrated) => mapAlbum(entity, true); +export const mapAlbumWithoutAssets = (entity: MaybeDehydrated) => mapAlbum(entity, false); diff --git a/server/src/dtos/asset-response.dto.spec.ts b/server/src/dtos/asset-response.dto.spec.ts index ff3b3f6acd089..8e85b983c33d5 100644 --- a/server/src/dtos/asset-response.dto.spec.ts +++ b/server/src/dtos/asset-response.dto.spec.ts @@ -3,6 +3,7 @@ import { AssetEditAction } from 'src/dtos/editing.dto'; import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFactory } from 'test/factories/asset.factory'; import { PersonFactory } from 'test/factories/person.factory'; +import { getForAsset } from 'test/mappers'; describe('mapAsset', () => { describe('peopleWithFaces', () => { @@ -41,7 +42,7 @@ describe('mapAsset', () => { }) .build(); - const result = mapAsset(asset); + const result = mapAsset(getForAsset(asset)); expect(result.people).toBeDefined(); expect(result.people).toHaveLength(1); @@ -80,7 +81,7 @@ describe('mapAsset', () => { .edit({ action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 500, height: 400 } }) .build(); - const result = mapAsset(asset); + const result = mapAsset(getForAsset(asset)); expect(result.unassignedFaces).toBeDefined(); expect(result.unassignedFaces).toHaveLength(1); @@ -130,7 +131,7 @@ describe('mapAsset', () => { .exif({ exifImageWidth: 1000, exifImageHeight: 800 }) .build(); - const result = mapAsset(asset); + const result = mapAsset(getForAsset(asset)); expect(result.people).toBeDefined(); expect(result.people).toHaveLength(2); @@ -179,7 +180,7 @@ describe('mapAsset', () => { .exif({ exifImageWidth: 1000, exifImageHeight: 800 }) .build(); - const result = mapAsset(asset); + const result = mapAsset(getForAsset(asset)); expect(result.people).toBeDefined(); expect(result.people).toHaveLength(1); diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index a76df4abaa26d..8b38b2e1240fd 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Selectable } from 'kysely'; +import { Selectable, ShallowDehydrateObject } from 'kysely'; import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database'; import { HistoryBuilder, Property } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -14,9 +14,10 @@ import { import { TagResponseDto, mapTag } from 'src/dtos/tag.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { AssetStatus, AssetType, AssetVisibility } from 'src/enum'; -import { ImageDimensions } from 'src/types'; +import { ImageDimensions, MaybeDehydrated } from 'src/types'; import { getDimensions } from 'src/utils/asset.util'; import { hexOrBufferToBase64 } from 'src/utils/bytes'; +import { asDateString } from 'src/utils/date'; import { mimeTypes } from 'src/utils/mime-types'; import { ValidateEnum, ValidateUUID } from 'src/validation'; @@ -39,7 +40,7 @@ export class SanitizedAssetResponseDto { 'The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer\'s local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months.', example: '2024-01-15T14:30:00.000Z', }) - localDateTime!: Date; + localDateTime!: string; @ApiProperty({ description: 'Video duration (for videos)' }) duration!: string; @ApiPropertyOptional({ description: 'Live photo video ID' }) @@ -59,7 +60,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { description: 'The UTC timestamp when the asset was originally uploaded to Immich.', example: '2024-01-15T20:30:00.000Z', }) - createdAt!: Date; + createdAt!: string; @ApiProperty({ description: 'Device asset ID' }) deviceAssetId!: string; @ApiProperty({ description: 'Device ID' }) @@ -86,7 +87,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { 'The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken.', example: '2024-01-15T19:30:00.000Z', }) - fileCreatedAt!: Date; + fileCreatedAt!: string; @ApiProperty({ type: 'string', format: 'date-time', @@ -94,7 +95,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { 'The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.', example: '2024-01-16T10:15:00.000Z', }) - fileModifiedAt!: Date; + fileModifiedAt!: string; @ApiProperty({ type: 'string', format: 'date-time', @@ -102,7 +103,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { 'The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.', example: '2024-01-16T12:45:30.000Z', }) - updatedAt!: Date; + updatedAt!: string; @ApiProperty({ description: 'Is favorite' }) isFavorite!: boolean; @ApiProperty({ description: 'Is archived' }) @@ -151,13 +152,12 @@ export type MapAsset = { deviceId: string; duplicateId: string | null; duration: string | null; - edits?: AssetEditActionItem[]; - encodedVideoPath: string | null; - exifInfo?: Selectable | null; - faces?: AssetFace[]; + edits?: ShallowDehydrateObject[]; + exifInfo?: ShallowDehydrateObject> | null; + faces?: ShallowDehydrateObject[]; fileCreatedAt: Date; fileModifiedAt: Date; - files?: AssetFile[]; + files?: ShallowDehydrateObject[]; isExternal: boolean; isFavorite: boolean; isOffline: boolean; @@ -167,11 +167,11 @@ export type MapAsset = { localDateTime: Date; originalFileName: string; originalPath: string; - owner?: User | null; + owner?: ShallowDehydrateObject | null; ownerId: string; - stack?: Stack | null; + stack?: (ShallowDehydrateObject & { assets: Stack['assets'] }) | null; stackId: string | null; - tags?: Tag[]; + tags?: ShallowDehydrateObject[]; thumbhash: Buffer | null; type: AssetType; width: number | null; @@ -197,7 +197,7 @@ export type AssetMapOptions = { }; const peopleWithFaces = ( - faces?: AssetFace[], + faces?: MaybeDehydrated[], edits?: AssetEditActionItem[], assetDimensions?: ImageDimensions, ): PersonWithFacesResponseDto[] => { @@ -213,7 +213,10 @@ const peopleWithFaces = ( } if (!peopleFaces.has(face.person.id)) { - peopleFaces.set(face.person.id, { ...mapPerson(face.person), faces: [] }); + peopleFaces.set(face.person.id, { + ...mapPerson(face.person), + faces: [], + }); } const mappedFace = mapFacesWithoutPerson(face, edits, assetDimensions); peopleFaces.get(face.person.id)!.faces.push(mappedFace); @@ -234,7 +237,7 @@ const mapStack = (entity: { stack?: Stack | null }) => { }; }; -export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): AssetResponseDto { +export function mapAsset(entity: MaybeDehydrated, options: AssetMapOptions = {}): AssetResponseDto { const { stripMetadata = false, withStack = false } = options; if (stripMetadata) { @@ -243,7 +246,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset type: entity.type, originalMimeType: mimeTypes.lookup(entity.originalFileName), thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null, - localDateTime: entity.localDateTime, + localDateTime: asDateString(entity.localDateTime), duration: entity.duration ?? '0:00:00.00000', livePhotoVideoId: entity.livePhotoVideoId, hasMetadata: false, @@ -257,7 +260,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset return { id: entity.id, - createdAt: entity.createdAt, + createdAt: asDateString(entity.createdAt), deviceAssetId: entity.deviceAssetId, ownerId: entity.ownerId, owner: entity.owner ? mapUser(entity.owner) : undefined, @@ -268,10 +271,10 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset originalFileName: entity.originalFileName, originalMimeType: mimeTypes.lookup(entity.originalFileName), thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null, - fileCreatedAt: entity.fileCreatedAt, - fileModifiedAt: entity.fileModifiedAt, - localDateTime: entity.localDateTime, - updatedAt: entity.updatedAt, + fileCreatedAt: asDateString(entity.fileCreatedAt), + fileModifiedAt: asDateString(entity.fileModifiedAt), + localDateTime: asDateString(entity.localDateTime), + updatedAt: asDateString(entity.updatedAt), isFavorite: options.auth?.user.id === entity.ownerId && entity.isFavorite, isArchived: entity.visibility === AssetVisibility.Archive, isTrashed: !!entity.deletedAt, @@ -283,7 +286,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset people: peopleWithFaces(entity.faces, entity.edits, assetDimensions), unassignedFaces: entity.faces ?.filter((face) => !face.person) - .map((a) => mapFacesWithoutPerson(a, entity.edits, assetDimensions)), + .map((face) => mapFacesWithoutPerson(face, entity.edits, assetDimensions)), checksum: hexOrBufferToBase64(entity.checksum)!, stack: withStack ? mapStack(entity) : undefined, isOffline: entity.isOffline, diff --git a/server/src/dtos/exif.dto.ts b/server/src/dtos/exif.dto.ts index 0052b95b6ec9b..165ecde4db548 100644 --- a/server/src/dtos/exif.dto.ts +++ b/server/src/dtos/exif.dto.ts @@ -1,5 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Exif } from 'src/database'; +import { MaybeDehydrated } from 'src/types'; +import { asDateString } from 'src/utils/date'; export class ExifResponseDto { @ApiPropertyOptional({ description: 'Camera make' }) @@ -16,9 +18,9 @@ export class ExifResponseDto { @ApiPropertyOptional({ description: 'Image orientation' }) orientation?: string | null = null; @ApiPropertyOptional({ description: 'Original date/time', format: 'date-time' }) - dateTimeOriginal?: Date | null = null; + dateTimeOriginal?: string | null = null; @ApiPropertyOptional({ description: 'Modification date/time', format: 'date-time' }) - modifyDate?: Date | null = null; + modifyDate?: string | null = null; @ApiPropertyOptional({ description: 'Time zone' }) timeZone?: string | null = null; @ApiPropertyOptional({ description: 'Lens model' }) @@ -49,7 +51,7 @@ export class ExifResponseDto { rating?: number | null = null; } -export function mapExif(entity: Exif): ExifResponseDto { +export function mapExif(entity: MaybeDehydrated): ExifResponseDto { return { make: entity.make, model: entity.model, @@ -57,8 +59,8 @@ export function mapExif(entity: Exif): ExifResponseDto { exifImageHeight: entity.exifImageHeight, fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null, orientation: entity.orientation, - dateTimeOriginal: entity.dateTimeOriginal, - modifyDate: entity.modifyDate, + dateTimeOriginal: asDateString(entity.dateTimeOriginal), + modifyDate: asDateString(entity.modifyDate), timeZone: entity.timeZone, lensModel: entity.lensModel, fNumber: entity.fNumber, @@ -80,7 +82,7 @@ export function mapSanitizedExif(entity: Exif): ExifResponseDto { return { fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null, orientation: entity.orientation, - dateTimeOriginal: entity.dateTimeOriginal, + dateTimeOriginal: asDateString(entity.dateTimeOriginal), timeZone: entity.timeZone, projectionType: entity.projectionType, exifImageWidth: entity.exifImageWidth, diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 97a412ad583b3..086de2e1991f2 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -9,8 +9,8 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { SourceType } from 'src/enum'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; -import { ImageDimensions } from 'src/types'; -import { asDateString } from 'src/utils/date'; +import { ImageDimensions, MaybeDehydrated } from 'src/types'; +import { asBirthDateString, asDateString } from 'src/utils/date'; import { transformFaceBoundingBox } from 'src/utils/transform'; import { IsDateStringFormat, @@ -33,7 +33,7 @@ export class PersonCreateDto { @MaxDateString(() => DateTime.now(), { message: 'Birth date cannot be in the future' }) @IsDateStringFormat('yyyy-MM-dd') @Optional({ nullable: true, emptyToNull: true }) - birthDate?: Date | null; + birthDate?: string | null; @ValidateBoolean({ optional: true, description: 'Person visibility (hidden)' }) isHidden?: boolean; @@ -105,8 +105,12 @@ export class PersonResponseDto { thumbnailPath!: string; @ApiProperty({ description: 'Is hidden' }) isHidden!: boolean; - @Property({ description: 'Last update date', history: new HistoryBuilder().added('v1.107.0').stable('v2') }) - updatedAt?: Date; + @Property({ + description: 'Last update date', + format: 'date-time', + history: new HistoryBuilder().added('v1.107.0').stable('v2'), + }) + updatedAt?: string; @Property({ description: 'Is favorite', history: new HistoryBuilder().added('v1.126.0').stable('v2') }) isFavorite?: boolean; @Property({ description: 'Person color (hex)', history: new HistoryBuilder().added('v1.126.0').stable('v2') }) @@ -226,23 +230,23 @@ export class PeopleResponseDto { hasNextPage?: boolean; } -export function mapPerson(person: Person): PersonResponseDto { +export function mapPerson(person: MaybeDehydrated): PersonResponseDto { return { id: person.id, name: person.name, - birthDate: asDateString(person.birthDate), + birthDate: asBirthDateString(person.birthDate), thumbnailPath: person.thumbnailPath, isHidden: person.isHidden, isFavorite: person.isFavorite, color: person.color ?? undefined, - updatedAt: person.updatedAt, + updatedAt: asDateString(person.updatedAt), type: person.type, species: person.species, }; } export function mapFacesWithoutPerson( - face: Selectable, + face: MaybeDehydrated>, edits?: AssetEditActionItem[], assetDimensions?: ImageDimensions, ): AssetFaceWithoutPersonResponseDto { diff --git a/server/src/dtos/tag.dto.ts b/server/src/dtos/tag.dto.ts index bb33659bfe663..ea85ea71f3366 100644 --- a/server/src/dtos/tag.dto.ts +++ b/server/src/dtos/tag.dto.ts @@ -1,6 +1,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsHexColor, IsNotEmpty, IsString } from 'class-validator'; import { Tag } from 'src/database'; +import { MaybeDehydrated } from 'src/types'; +import { asDateString } from 'src/utils/date'; import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation'; export class TagCreateDto { @@ -54,22 +56,22 @@ export class TagResponseDto { name!: string; @ApiProperty({ description: 'Tag value (full path)' }) value!: string; - @ApiProperty({ description: 'Creation date' }) - createdAt!: Date; - @ApiProperty({ description: 'Last update date' }) - updatedAt!: Date; + @ApiProperty({ description: 'Creation date', format: 'date-time' }) + createdAt!: string; + @ApiProperty({ description: 'Last update date', format: 'date-time' }) + updatedAt!: string; @ApiPropertyOptional({ description: 'Tag color (hex)' }) color?: string; } -export function mapTag(entity: Tag): TagResponseDto { +export function mapTag(entity: MaybeDehydrated): TagResponseDto { return { id: entity.id, parentId: entity.parentId ?? undefined, name: entity.value.split('/').at(-1) as string, value: entity.value, - createdAt: entity.createdAt, - updatedAt: entity.updatedAt, + createdAt: asDateString(entity.createdAt), + updatedAt: asDateString(entity.updatedAt), color: entity.color ?? undefined, }; } diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 2d4fc3934fe74..ebd0018bba9ff 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -3,7 +3,8 @@ import { Transform } from 'class-transformer'; import { IsEmail, IsInt, IsNotEmpty, IsString, Min } from 'class-validator'; import { User, UserAdmin } from 'src/database'; import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; -import { UserMetadataItem } from 'src/types'; +import { MaybeDehydrated, UserMetadataItem } from 'src/types'; +import { asDateString } from 'src/utils/date'; import { Optional, PinCode, ValidateBoolean, ValidateEnum, ValidateUUID, toEmail, toSanitized } from 'src/validation'; export class UserUpdateMeDto { @@ -47,8 +48,8 @@ export class UserResponseDto { profileImagePath!: string; @ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', description: 'Avatar color' }) avatarColor!: UserAvatarColor; - @ApiProperty({ description: 'Profile change date' }) - profileChangedAt!: Date; + @ApiProperty({ description: 'Profile change date', format: 'date-time' }) + profileChangedAt!: string; } export class UserLicense { @@ -68,14 +69,14 @@ const emailToAvatarColor = (email: string): UserAvatarColor => { return values[randomIndex]; }; -export const mapUser = (entity: User | UserAdmin): UserResponseDto => { +export const mapUser = (entity: MaybeDehydrated): UserResponseDto => { return { id: entity.id, email: entity.email, name: entity.name, profileImagePath: entity.profileImagePath, avatarColor: entity.avatarColor ?? emailToAvatarColor(entity.email), - profileChangedAt: entity.profileChangedAt, + profileChangedAt: asDateString(entity.profileChangedAt), }; }; diff --git a/server/src/enum.ts b/server/src/enum.ts index 9ef83a720fa9b..80065446ba094 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -45,6 +45,7 @@ export enum AssetFileType { Preview = 'preview', Thumbnail = 'thumbnail', Sidecar = 'sidecar', + EncodedVideo = 'encoded_video', } export enum AlbumUserRole { diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index a4921fad57db0..b0bc28f0a8a5d 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -175,7 +175,6 @@ where select "asset"."id", "asset"."ownerId", - "asset"."encodedVideoPath", ( select coalesce(json_agg(agg), '[]') @@ -467,7 +466,6 @@ select "asset"."libraryId", "asset"."ownerId", "asset"."livePhotoVideoId", - "asset"."encodedVideoPath", "asset"."originalPath", "asset"."isOffline", to_json("asset_exif") as "exifInfo", @@ -525,12 +523,17 @@ select from "asset" where - "asset"."type" = $1 - and ( - "asset"."encodedVideoPath" is null - or "asset"."encodedVideoPath" = $2 + "asset"."type" = 'VIDEO' + and not exists ( + select + "asset_file"."id" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = 'encoded_video' ) - and "asset"."visibility" != $3 + and "asset"."visibility" != 'hidden' and "asset"."deletedAt" is null -- AssetJobRepository.getForVideoConversion @@ -538,12 +541,27 @@ select "asset"."id", "asset"."ownerId", "asset"."originalPath", - "asset"."encodedVideoPath" + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_file"."id", + "asset_file"."path", + "asset_file"."type", + "asset_file"."isEdited" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + ) as agg + ) as "files" from "asset" where "asset"."id" = $1 - and "asset"."type" = $2 + and "asset"."type" = 'VIDEO' -- AssetJobRepository.streamForMetadataExtraction select diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 632fb823c65c0..a2525c3b17062 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -438,6 +438,7 @@ with and "stack"."primaryAssetId" != "asset"."id" ) order by + (asset."localDateTime" AT TIME ZONE 'UTC')::date desc, "asset"."fileCreatedAt" desc ), "agg" as ( @@ -628,13 +629,21 @@ order by -- AssetRepository.getForVideo select - "asset"."encodedVideoPath", - "asset"."originalPath" + "asset"."originalPath", + ( + select + "asset_file"."path" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $1 + ) as "encodedVideoPath" from "asset" where - "asset"."id" = $1 - and "asset"."type" = $2 + "asset"."id" = $2 + and "asset"."type" = $3 -- AssetRepository.getForOcr select diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index cdd20ef85dd63..2630e384fcc85 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -244,3 +244,37 @@ where or "album"."id" is not null ) and "shared_link"."slug" = $2 + +-- SharedLinkRepository.getSharedLinks +select + "shared_link".*, + coalesce( + json_agg("assets") filter ( + where + "assets"."id" is not null + ), + '[]' + ) as "assets" +from + "shared_link" + left join "shared_link_asset" on "shared_link_asset"."sharedLinkId" = "shared_link"."id" + left join lateral ( + select + "asset".* + from + "asset" + inner join lateral ( + select + * + from + "asset_exif" + where + "asset_exif"."assetId" = "asset"."id" + ) as "exifInfo" on true + where + "asset"."id" = "shared_link_asset"."assetId" + ) as "assets" on true +where + "shared_link"."id" = $1 +group by + "shared_link"."id" diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index cf132a023d557..9a76b379edb06 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -1,12 +1,22 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, NotNull, sql, Updateable } from 'kysely'; +import { + ExpressionBuilder, + Insertable, + Kysely, + NotNull, + Selectable, + ShallowDehydrateObject, + sql, + Updateable, +} from 'kysely'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; -import { columns, Exif } from 'src/database'; +import { columns } from 'src/database'; import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { AlbumUserCreateDto } from 'src/dtos/album.dto'; import { DB } from 'src/schema'; import { AlbumTable } from 'src/schema/tables/album.table'; +import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { withDefaultVisibility } from 'src/utils/database'; export interface AlbumAssetCount { @@ -56,7 +66,9 @@ const withAssets = (eb: ExpressionBuilder) => { .selectFrom('asset') .selectAll('asset') .leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId') - .select((eb) => eb.table('asset_exif').$castTo().as('exifInfo')) + .select((eb) => + eb.table('asset_exif').$castTo>>().as('exifInfo'), + ) .innerJoin('album_asset', 'album_asset.assetId', 'asset.id') .whereRef('album_asset.albumId', '=', 'album.id') .where('asset.deletedAt', 'is', null) diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index b39b78de6b2e0..79297e9114bca 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -9,7 +9,6 @@ import { DB } from 'src/schema'; import { anyUuid, asUuid, - toJson, withDefaultVisibility, withEdits, withExif, @@ -105,7 +104,7 @@ export class AssetJobRepository { getForMigrationJob(id: string) { return this.db .selectFrom('asset') - .select(['asset.id', 'asset.ownerId', 'asset.encodedVideoPath']) + .select(['asset.id', 'asset.ownerId']) .select(withFiles) .where('asset.id', '=', id) .executeTakeFirst(); @@ -271,7 +270,6 @@ export class AssetJobRepository { 'asset.libraryId', 'asset.ownerId', 'asset.livePhotoVideoId', - 'asset.encodedVideoPath', 'asset.originalPath', 'asset.isOffline', ]) @@ -298,7 +296,12 @@ export class AssetJobRepository { .as('stack_result'), (join) => join.onTrue(), ) - .select((eb) => toJson(eb, 'stack_result').as('stack')) + .select((eb) => + eb.fn + .toJson(eb.table('stack_result')) + .$castTo<{ id: string; primaryAssetId: string; assets: { id: string }[] } | null>() + .as('stack'), + ) .where('asset.id', '=', id) .executeTakeFirst(); } @@ -308,11 +311,21 @@ export class AssetJobRepository { return this.db .selectFrom('asset') .select(['asset.id']) - .where('asset.type', '=', AssetType.Video) + .where('asset.type', '=', sql.lit(AssetType.Video)) .$if(!force, (qb) => qb - .where((eb) => eb.or([eb('asset.encodedVideoPath', 'is', null), eb('asset.encodedVideoPath', '=', '')])) - .where('asset.visibility', '!=', AssetVisibility.Hidden), + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom('asset_file') + .select('asset_file.id') + .whereRef('asset_file.assetId', '=', 'asset.id') + .where('asset_file.type', '=', sql.lit(AssetFileType.EncodedVideo)), + ), + ), + ) + .where('asset.visibility', '!=', sql.lit(AssetVisibility.Hidden)), ) .where('asset.deletedAt', 'is', null) .stream(); @@ -322,9 +335,10 @@ export class AssetJobRepository { getForVideoConversion(id: string) { return this.db .selectFrom('asset') - .select(['asset.id', 'asset.ownerId', 'asset.originalPath', 'asset.encodedVideoPath']) + .select(['asset.id', 'asset.ownerId', 'asset.originalPath']) + .select(withFiles) .where('asset.id', '=', id) - .where('asset.type', '=', AssetType.Video) + .where('asset.type', '=', sql.lit(AssetType.Video)) .executeTakeFirst(); } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index dff14c59674a3..3358a6ad0c328 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -6,6 +6,7 @@ import { NotNull, Selectable, SelectQueryBuilder, + ShallowDehydrateObject, sql, Updateable, UpdateResult, @@ -36,6 +37,7 @@ import { withExif, withFaces, withFacesAndPeople, + withFilePath, withFiles, withLibrary, withOwner, @@ -560,7 +562,11 @@ export class AssetRepository { eb .selectFrom('asset as stacked') .selectAll('stack') - .select((eb) => eb.fn('array_agg', [eb.table('stacked')]).as('assets')) + .select((eb) => + eb + .fn>>('array_agg', [eb.table('stacked')]) + .as('assets'), + ) .whereRef('stacked.stackId', '=', 'stack.id') .whereRef('stacked.id', '!=', 'stack.primaryAssetId') .where('stacked.deletedAt', 'is', null) @@ -569,7 +575,7 @@ export class AssetRepository { .as('stacked_assets'), (join) => join.on('stack.id', 'is not', null), ) - .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).$castTo().as('stack')), + .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')), ), ) .$if(!!files, (qb) => qb.select(withFiles)) @@ -771,6 +777,7 @@ export class AssetRepository { params: [DummyValue.TIME_BUCKET, { withStacked: true }, { user: { id: DummyValue.UUID } }], }) getTimeBucket(timeBucket: string, options: TimeBucketOptions, auth: AuthDto) { + const order = options.order ?? 'desc'; const query = this.db .with('cte', (qb) => qb @@ -894,7 +901,8 @@ export class AssetRepository { ) .$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted)) .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)) - .orderBy('asset.fileCreatedAt', options.order ?? 'desc'), + .orderBy(sql`(asset."localDateTime" AT TIME ZONE 'UTC')::date`, order) + .orderBy('asset.fileCreatedAt', order), ) .with('agg', (qb) => qb @@ -1065,8 +1073,21 @@ export class AssetRepository { .execute(); } - async deleteFile({ assetId, type }: { assetId: string; type: AssetFileType }): Promise { - await this.db.deleteFrom('asset_file').where('assetId', '=', asUuid(assetId)).where('type', '=', type).execute(); + async deleteFile({ + assetId, + type, + edited, + }: { + assetId: string; + type: AssetFileType; + edited?: boolean; + }): Promise { + await this.db + .deleteFrom('asset_file') + .where('assetId', '=', asUuid(assetId)) + .where('type', '=', type) + .$if(edited !== undefined, (qb) => qb.where('isEdited', '=', edited!)) + .execute(); } async deleteFiles(files: Pick, 'id'>[]): Promise { @@ -1185,7 +1206,8 @@ export class AssetRepository { async getForVideo(id: string) { return this.db .selectFrom('asset') - .select(['asset.encodedVideoPath', 'asset.originalPath']) + .select(['asset.originalPath']) + .select((eb) => withFilePath(eb, AssetFileType.EncodedVideo).as('encodedVideoPath')) .where('asset.id', '=', id) .where('asset.type', '=', AssetType.Video) .executeTakeFirst(); diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 4ffb37c79c222..7ae1119bbcdec 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -431,7 +431,6 @@ export class DatabaseRepository { .updateTable('asset') .set((eb) => ({ originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]), - encodedVideoPath: eb.fn('REGEXP_REPLACE', ['encodedVideoPath', source, target]), })) .execute(); diff --git a/server/src/repositories/duplicate.repository.ts b/server/src/repositories/duplicate.repository.ts index 95ccbea63d69e..7a5931e029dec 100644 --- a/server/src/repositories/duplicate.repository.ts +++ b/server/src/repositories/duplicate.repository.ts @@ -1,11 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { Kysely, NotNull, sql } from 'kysely'; +import { Kysely, NotNull, Selectable, ShallowDehydrateObject, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { Chunked, DummyValue, GenerateSql } from 'src/decorators'; -import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetType, VectorIndex } from 'src/enum'; import { probes } from 'src/repositories/database.repository'; import { DB } from 'src/schema'; +import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { anyUuid, asUuid, withDefaultVisibility } from 'src/utils/database'; interface DuplicateSearch { @@ -39,15 +39,15 @@ export class DuplicateRepository { qb .selectFrom('asset_exif') .selectAll('asset') - .select((eb) => eb.table('asset_exif').as('exifInfo')) + .select((eb) => + eb.table('asset_exif').$castTo>>().as('exifInfo'), + ) .whereRef('asset_exif.assetId', '=', 'asset.id') .as('asset2'), (join) => join.onTrue(), ) .select('asset.duplicateId') - .select((eb) => - eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').$castTo().as('assets'), - ) + .select((eb) => eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').as('assets')) .where('asset.ownerId', '=', asUuid(userId)) .where('asset.duplicateId', 'is not', null) .$narrowType<{ duplicateId: NotNull }>() diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 3c36bf62db3f8..fc00d44b3fe38 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -72,6 +72,8 @@ export interface ImmichTags extends Omit { AndroidMake?: string; AndroidModel?: string; + DeviceManufacturer?: string; + DeviceModelName?: string; } @Injectable() diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 7d356dd7b8ea7..6ba8836020e79 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Kysely, OrderByDirection, Selectable, sql } from 'kysely'; +import { Kysely, OrderByDirection, Selectable, ShallowDehydrateObject, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { randomUUID } from 'node:crypto'; import { DummyValue, GenerateSql } from 'src/decorators'; @@ -439,7 +439,7 @@ export class SearchRepository { .select((eb) => eb .fn('to_jsonb', [eb.table('asset_exif')]) - .$castTo>() + .$castTo>>() .as('exifInfo'), ) .orderBy('asset_exif.city') diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 2a8acd6377e5d..bc81e75c81d15 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -1,13 +1,14 @@ import { Injectable } from '@nestjs/common'; -import { Insertable, Kysely, sql, Updateable } from 'kysely'; +import { Insertable, Kysely, Selectable, ShallowDehydrateObject, sql, Updateable } from 'kysely'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import _ from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; import { Album, columns } from 'src/database'; -import { DummyValue, GenerateSql } from 'src/decorators'; -import { MapAsset } from 'src/dtos/asset-response.dto'; +import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { SharedLinkType } from 'src/enum'; import { DB } from 'src/schema'; +import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; +import { AssetTable } from 'src/schema/tables/asset.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; export type SharedLinkSearchOptions = { @@ -106,11 +107,15 @@ export class SharedLinkRepository { .select((eb) => eb.fn .coalesce(eb.fn.jsonAgg('a').filterWhere('a.id', 'is not', null), sql`'[]'`) - .$castTo() + .$castTo< + (ShallowDehydrateObject> & { + exifInfo: ShallowDehydrateObject>; + })[] + >() .as('assets'), ) .groupBy(['shared_link.id', sql`"album".*`]) - .select((eb) => eb.fn.toJson('album').$castTo().as('album')) + .select((eb) => eb.fn.toJson(eb.table('album')).$castTo | null>().as('album')) .where('shared_link.id', '=', id) .where('shared_link.userId', '=', userId) .where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)])) @@ -134,9 +139,7 @@ export class SharedLinkRepository { .selectAll('asset') .orderBy('asset.fileCreatedAt', 'asc') .limit(1), - ) - .$castTo() - .as('assets'), + ).as('assets'), ) .leftJoinLateral( (eb) => @@ -175,7 +178,7 @@ export class SharedLinkRepository { .as('album'), (join) => join.onTrue(), ) - .select((eb) => eb.fn.toJson('album').$castTo().as('album')) + .select((eb) => eb.fn.toJson('album').$castTo | null>().as('album')) .where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)])) .$if(!!albumId, (eb) => eb.where('shared_link.albumId', '=', albumId!)) .$if(!!id, (eb) => eb.where('shared_link.id', '=', id!)) @@ -246,6 +249,21 @@ export class SharedLinkRepository { await this.db.deleteFrom('shared_link').where('shared_link.id', '=', id).execute(); } + @ChunkedArray({ paramIndex: 1 }) + async addAssets(id: string, assetIds: string[]) { + if (assetIds.length === 0) { + return []; + } + + return await this.db + .insertInto('shared_link_asset') + .values(assetIds.map((assetId) => ({ assetId, sharedLinkId: id }))) + .onConflict((oc) => oc.doNothing()) + .returning(['shared_link_asset.assetId']) + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) private getSharedLinks(id: string) { return this.db .selectFrom('shared_link') @@ -269,7 +287,11 @@ export class SharedLinkRepository { .select((eb) => eb.fn .coalesce(eb.fn.jsonAgg('assets').filterWhere('assets.id', 'is not', null), sql`'[]'`) - .$castTo() + .$castTo< + (ShallowDehydrateObject> & { + exifInfo: ShallowDehydrateObject>; + })[] + >() .as('assets'), ) .groupBy('shared_link.id') diff --git a/server/src/repositories/storage-migration.repository.ts b/server/src/repositories/storage-migration.repository.ts index ef7cd6744e88a..3a2a2956a2a0b 100644 --- a/server/src/repositories/storage-migration.repository.ts +++ b/server/src/repositories/storage-migration.repository.ts @@ -46,12 +46,12 @@ export class StorageMigrationRepository { streamEncodedVideos(direction: StorageMigrationDirection) { return this.db - .selectFrom('asset') - .select(['id', 'encodedVideoPath']) - .where('encodedVideoPath', 'is not', null) - .where('encodedVideoPath', '!=', '') - .$if(direction === 'toS3', (qb) => qb.where('encodedVideoPath', 'like', '/%')) - .$if(direction === 'toDisk', (qb) => qb.where('encodedVideoPath', 'not like', '/%')) + .selectFrom('asset_file') + .innerJoin('asset', 'asset.id', 'asset_file.assetId') + .select(['asset_file.id', 'asset_file.assetId', 'asset_file.path', 'asset_file.type']) + .where('asset_file.type', '=', AssetFileType.EncodedVideo) + .$if(direction === 'toS3', (qb) => qb.where('asset_file.path', 'like', '/%')) + .$if(direction === 'toDisk', (qb) => qb.where('asset_file.path', 'not like', '/%')) .stream(); } @@ -112,11 +112,10 @@ export class StorageMigrationRepository { .executeTakeFirstOrThrow(), this.db - .selectFrom('asset') + .selectFrom('asset_file') .select((eb) => eb.fn.countAll().as('count')) - .where('encodedVideoPath', 'is not', null) - .where('encodedVideoPath', '!=', '') - .where('encodedVideoPath', pathFilter, pathPattern) + .where('type', '=', AssetFileType.EncodedVideo) + .where('path', pathFilter, pathPattern) .executeTakeFirstOrThrow(), this.db @@ -161,10 +160,11 @@ export class StorageMigrationRepository { async updateAssetEncodedVideoPath(assetId: string, oldPath: string, newPath: string): Promise { const result = await this.db - .updateTable('asset') - .set({ encodedVideoPath: newPath }) - .where('id', '=', assetId) - .where('encodedVideoPath', '=', oldPath) + .updateTable('asset_file') + .set({ path: newPath }) + .where('assetId', '=', assetId) + .where('type', '=', AssetFileType.EncodedVideo) + .where('path', '=', oldPath) .executeTakeFirst(); return Number(result.numUpdatedRows) > 0; diff --git a/server/src/schema/migrations/1773242919341-EncodedVideoAssetFiles.ts b/server/src/schema/migrations/1773242919341-EncodedVideoAssetFiles.ts new file mode 100644 index 0000000000000..4a62a7e8428a1 --- /dev/null +++ b/server/src/schema/migrations/1773242919341-EncodedVideoAssetFiles.ts @@ -0,0 +1,25 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql` + INSERT INTO "asset_file" ("assetId", "type", "path") + SELECT "id", 'encoded_video', "encodedVideoPath" + FROM "asset" + WHERE "encodedVideoPath" IS NOT NULL AND "encodedVideoPath" != ''; + `.execute(db); + + await sql`ALTER TABLE "asset" DROP COLUMN "encodedVideoPath";`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "asset" ADD "encodedVideoPath" character varying DEFAULT '';`.execute(db); + + await sql` + UPDATE "asset" + SET "encodedVideoPath" = af."path" + FROM "asset_file" af + WHERE "asset"."id" = af."assetId" + AND af."type" = 'encoded_video' + AND af."isEdited" = false; + `.execute(db); +} diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index 12e9c3612582f..8bdaa59bc62f8 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -92,9 +92,6 @@ export class AssetTable { @Column({ type: 'character varying', nullable: true }) duration!: string | null; - @Column({ type: 'character varying', nullable: true, default: '' }) - encodedVideoPath!: string | null; - @Column({ type: 'bytea', index: true }) checksum!: Buffer; // sha1 checksum diff --git a/server/src/services/activity.service.spec.ts b/server/src/services/activity.service.spec.ts index aea547e6dbce0..03cd0132c192f 100644 --- a/server/src/services/activity.service.spec.ts +++ b/server/src/services/activity.service.spec.ts @@ -1,7 +1,10 @@ import { BadRequestException } from '@nestjs/common'; import { ReactionType } from 'src/dtos/activity.dto'; import { ActivityService } from 'src/services/activity.service'; -import { factory, newUuid, newUuids } from 'test/small.factory'; +import { ActivityFactory } from 'test/factories/activity.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { getForActivity } from 'test/mappers'; +import { newUuid, newUuids } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(ActivityService.name, () => { @@ -23,7 +26,7 @@ describe(ActivityService.name, () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.activity.search.mockResolvedValue([]); - await expect(sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId })).resolves.toEqual([]); + await expect(sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId })).resolves.toEqual([]); expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: undefined }); }); @@ -35,7 +38,7 @@ describe(ActivityService.name, () => { mocks.activity.search.mockResolvedValue([]); await expect( - sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId, type: ReactionType.LIKE }), + sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId, type: ReactionType.LIKE }), ).resolves.toEqual([]); expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: true }); @@ -47,7 +50,9 @@ describe(ActivityService.name, () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.activity.search.mockResolvedValue([]); - await expect(sut.getAll(factory.auth(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual([]); + await expect(sut.getAll(AuthFactory.create(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual( + [], + ); expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: false }); }); @@ -60,7 +65,10 @@ describe(ActivityService.name, () => { mocks.activity.getStatistics.mockResolvedValue({ comments: 1, likes: 3 }); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); - await expect(sut.getStatistics(factory.auth(), { assetId, albumId })).resolves.toEqual({ comments: 1, likes: 3 }); + await expect(sut.getStatistics(AuthFactory.create(), { assetId, albumId })).resolves.toEqual({ + comments: 1, + likes: 3, + }); }); }); @@ -69,18 +77,18 @@ describe(ActivityService.name, () => { const [albumId, assetId] = newUuids(); await expect( - sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), + sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), ).rejects.toBeInstanceOf(BadRequestException); }); it('should create a comment', async () => { const [albumId, assetId, userId] = newUuids(); - const activity = factory.activity({ albumId, assetId, userId }); + const activity = ActivityFactory.create({ albumId, assetId, userId }); mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); - mocks.activity.create.mockResolvedValue(activity); + mocks.activity.create.mockResolvedValue(getForActivity(activity)); - await sut.create(factory.auth({ user: { id: userId } }), { + await sut.create(AuthFactory.create({ id: userId }), { albumId, assetId, type: ReactionType.COMMENT, @@ -98,38 +106,38 @@ describe(ActivityService.name, () => { it('should fail because activity is disabled for the album', async () => { const [albumId, assetId] = newUuids(); - const activity = factory.activity({ albumId, assetId }); + const activity = ActivityFactory.create({ albumId, assetId }); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); - mocks.activity.create.mockResolvedValue(activity); + mocks.activity.create.mockResolvedValue(getForActivity(activity)); await expect( - sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), + sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), ).rejects.toBeInstanceOf(BadRequestException); }); it('should create a like', async () => { const [albumId, assetId, userId] = newUuids(); - const activity = factory.activity({ userId, albumId, assetId, isLiked: true }); + const activity = ActivityFactory.create({ userId, albumId, assetId, isLiked: true }); mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); - mocks.activity.create.mockResolvedValue(activity); + mocks.activity.create.mockResolvedValue(getForActivity(activity)); mocks.activity.search.mockResolvedValue([]); - await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE }); + await sut.create(AuthFactory.create({ id: userId }), { albumId, assetId, type: ReactionType.LIKE }); expect(mocks.activity.create).toHaveBeenCalledWith({ userId: activity.userId, albumId, assetId, isLiked: true }); }); it('should skip if like exists', async () => { const [albumId, assetId] = newUuids(); - const activity = factory.activity({ albumId, assetId, isLiked: true }); + const activity = ActivityFactory.create({ albumId, assetId, isLiked: true }); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); - mocks.activity.search.mockResolvedValue([activity]); + mocks.activity.search.mockResolvedValue([getForActivity(activity)]); - await sut.create(factory.auth(), { albumId, assetId, type: ReactionType.LIKE }); + await sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.LIKE }); expect(mocks.activity.create).not.toHaveBeenCalled(); }); @@ -137,29 +145,29 @@ describe(ActivityService.name, () => { describe('delete', () => { it('should require access', async () => { - await expect(sut.delete(factory.auth(), newUuid())).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.delete(AuthFactory.create(), newUuid())).rejects.toBeInstanceOf(BadRequestException); expect(mocks.activity.delete).not.toHaveBeenCalled(); }); it('should let the activity owner delete a comment', async () => { - const activity = factory.activity(); + const activity = ActivityFactory.create(); mocks.access.activity.checkOwnerAccess.mockResolvedValue(new Set([activity.id])); mocks.activity.delete.mockResolvedValue(); - await sut.delete(factory.auth(), activity.id); + await sut.delete(AuthFactory.create(), activity.id); expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id); }); it('should let the album owner delete a comment', async () => { - const activity = factory.activity(); + const activity = ActivityFactory.create(); mocks.access.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set([activity.id])); mocks.activity.delete.mockResolvedValue(); - await sut.delete(factory.auth(), activity.id); + await sut.delete(AuthFactory.create(), activity.id); expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id); }); diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index d21185bd35e28..47646d0c6d208 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -1,5 +1,4 @@ import { BadRequestException } from '@nestjs/common'; -import _ from 'lodash'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { AlbumUserRole, AssetOrder, UserMetadataKey } from 'src/enum'; import { AlbumService } from 'src/services/album.service'; @@ -9,6 +8,7 @@ import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; +import { getForAlbum } from 'test/mappers'; import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -45,7 +45,7 @@ describe(AlbumService.name, () => { it('gets list of albums for auth user', async () => { const album = AlbumFactory.from().albumUser().build(); const sharedWithUserAlbum = AlbumFactory.from().owner(album.owner).albumUser().build(); - mocks.album.getOwned.mockResolvedValue([album, sharedWithUserAlbum]); + mocks.album.getOwned.mockResolvedValue([getForAlbum(album), getForAlbum(sharedWithUserAlbum)]); mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: album.id, @@ -70,8 +70,13 @@ describe(AlbumService.name, () => { }); it('gets list of albums that have a specific asset', async () => { - const album = AlbumFactory.from().owner({ isAdmin: true }).albumUser().asset().asset().build(); - mocks.album.getByAssetId.mockResolvedValue([album]); + const album = AlbumFactory.from() + .owner({ isAdmin: true }) + .albumUser() + .asset({}, (builder) => builder.exif()) + .asset({}, (builder) => builder.exif()) + .build(); + mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]); mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: album.id, @@ -90,7 +95,7 @@ describe(AlbumService.name, () => { it('gets list of albums that are shared', async () => { const album = AlbumFactory.from().albumUser().build(); - mocks.album.getShared.mockResolvedValue([album]); + mocks.album.getShared.mockResolvedValue([getForAlbum(album)]); mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: album.id, @@ -109,7 +114,7 @@ describe(AlbumService.name, () => { it('gets list of albums that are NOT shared', async () => { const album = AlbumFactory.create(); - mocks.album.getNotShared.mockResolvedValue([album]); + mocks.album.getNotShared.mockResolvedValue([getForAlbum(album)]); mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: album.id, @@ -129,7 +134,7 @@ describe(AlbumService.name, () => { it('counts assets correctly', async () => { const album = AlbumFactory.create(); - mocks.album.getOwned.mockResolvedValue([album]); + mocks.album.getOwned.mockResolvedValue([getForAlbum(album)]); mocks.album.getMetadataForIds.mockResolvedValue([ { albumId: album.id, @@ -155,7 +160,7 @@ describe(AlbumService.name, () => { .albumUser(albumUser) .build(); - mocks.album.create.mockResolvedValue(album); + mocks.album.create.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue(UserFactory.create(album.albumUsers[0].user)); mocks.user.getMetadata.mockResolvedValue([]); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId])); @@ -192,7 +197,7 @@ describe(AlbumService.name, () => { .asset({ id: assetId }, (asset) => asset.exif()) .albumUser(albumUser) .build(); - mocks.album.create.mockResolvedValue(album); + mocks.album.create.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue(album.albumUsers[0].user); mocks.user.getMetadata.mockResolvedValue([ { @@ -250,7 +255,7 @@ describe(AlbumService.name, () => { .albumUser() .build(); mocks.user.get.mockResolvedValue(album.albumUsers[0].user); - mocks.album.create.mockResolvedValue(album); + mocks.album.create.mockResolvedValue(getForAlbum(album)); mocks.user.getMetadata.mockResolvedValue([]); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId])); @@ -316,7 +321,7 @@ describe(AlbumService.name, () => { it('should require a valid thumbnail asset id', async () => { const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValue(new Set()); await expect( @@ -330,8 +335,8 @@ describe(AlbumService.name, () => { it('should allow the owner to update the album', async () => { const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); - mocks.album.update.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); + mocks.album.update.mockResolvedValue(getForAlbum(album)); await sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' }); @@ -352,7 +357,7 @@ describe(AlbumService.name, () => { it('should not let a shared user delete the album', async () => { const album = AlbumFactory.create(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.delete(AuthFactory.create(album.owner), album.id)).rejects.toBeInstanceOf(BadRequestException); @@ -363,7 +368,7 @@ describe(AlbumService.name, () => { it('should let the owner delete an album', async () => { const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); await sut.delete(AuthFactory.create(album.owner), album.id); @@ -387,7 +392,7 @@ describe(AlbumService.name, () => { const userId = newUuid(); const album = AlbumFactory.from().albumUser({ userId }).build(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); await expect( sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId }] }), ).rejects.toBeInstanceOf(BadRequestException); @@ -398,7 +403,7 @@ describe(AlbumService.name, () => { it('should throw an error if the userId does not exist', async () => { const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue(void 0); await expect( sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId: 'unknown-user' }] }), @@ -410,7 +415,7 @@ describe(AlbumService.name, () => { it('should throw an error if the userId is the ownerId', async () => { const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); await expect( sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId: album.owner.id }], @@ -424,8 +429,8 @@ describe(AlbumService.name, () => { const album = AlbumFactory.create(); const user = UserFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); - mocks.album.update.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); + mocks.album.update.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue(user); mocks.albumUser.create.mockResolvedValue(AlbumUserFactory.from().album(album).user(user).build()); @@ -456,7 +461,7 @@ describe(AlbumService.name, () => { const userId = newUuid(); const album = AlbumFactory.from().albumUser({ userId }).build(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.albumUser.delete.mockResolvedValue(); await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, userId)).resolves.toBeUndefined(); @@ -470,7 +475,7 @@ describe(AlbumService.name, () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); const album = AlbumFactory.from().albumUser({ userId: user1.id }).albumUser({ userId: user2.id }).build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); await expect(sut.removeUser(AuthFactory.create(user1), album.id, user2.id)).rejects.toBeInstanceOf( BadRequestException, @@ -483,7 +488,7 @@ describe(AlbumService.name, () => { it('should allow a shared user to remove themselves', async () => { const user1 = UserFactory.create(); const album = AlbumFactory.from().albumUser({ userId: user1.id }).build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.albumUser.delete.mockResolvedValue(); await sut.removeUser(AuthFactory.create(user1), album.id, user1.id); @@ -495,7 +500,7 @@ describe(AlbumService.name, () => { it('should allow a shared user to remove themselves using "me"', async () => { const user = UserFactory.create(); const album = AlbumFactory.from().albumUser({ userId: user.id }).build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.albumUser.delete.mockResolvedValue(); await sut.removeUser(AuthFactory.create(user), album.id, 'me'); @@ -506,7 +511,7 @@ describe(AlbumService.name, () => { it('should not allow the owner to be removed', async () => { const album = AlbumFactory.from().albumUser().build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, album.owner.id)).rejects.toBeInstanceOf( BadRequestException, @@ -517,7 +522,7 @@ describe(AlbumService.name, () => { it('should throw an error for a user not in the album', async () => { const album = AlbumFactory.from().albumUser().build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, 'user-3')).rejects.toBeInstanceOf( BadRequestException, @@ -546,7 +551,7 @@ describe(AlbumService.name, () => { describe('getAlbumInfo', () => { it('should get a shared album', async () => { const album = AlbumFactory.from().albumUser().build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.album.getMetadataForIds.mockResolvedValue([ { @@ -566,7 +571,7 @@ describe(AlbumService.name, () => { it('should get a shared album via a shared link', async () => { const album = AlbumFactory.from().albumUser().build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([album.id])); mocks.album.getMetadataForIds.mockResolvedValue([ { @@ -588,7 +593,7 @@ describe(AlbumService.name, () => { it('should get a shared album via shared with user', async () => { const user = UserFactory.create(); const album = AlbumFactory.from().albumUser({ userId: user.id }).build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id])); mocks.album.getMetadataForIds.mockResolvedValue([ { @@ -630,7 +635,7 @@ describe(AlbumService.name, () => { const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect( @@ -654,7 +659,7 @@ describe(AlbumService.name, () => { const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset2.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset2.id] })).resolves.toEqual([ @@ -675,7 +680,7 @@ describe(AlbumService.name, () => { const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect( @@ -703,7 +708,7 @@ describe(AlbumService.name, () => { const album = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Viewer }).build(); const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set()); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); await expect( sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset1.id, asset2.id, asset3.id] }), @@ -718,7 +723,7 @@ describe(AlbumService.name, () => { const auth = AuthFactory.from(album.owner).sharedLink({ allowUpload: true, userId: album.ownerId }).build(); mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(auth, album.id, { ids: [asset1.id, asset2.id, asset3.id] })).resolves.toEqual([ @@ -742,7 +747,7 @@ describe(AlbumService.name, () => { const asset = AssetFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ @@ -762,7 +767,7 @@ describe(AlbumService.name, () => { const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set([asset.id])); await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ @@ -776,7 +781,7 @@ describe(AlbumService.name, () => { const asset = AssetFactory.create(); const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ @@ -791,7 +796,7 @@ describe(AlbumService.name, () => { const user = UserFactory.create(); const album = AlbumFactory.create(); const asset = AssetFactory.create({ ownerId: user.id }); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); await expect(sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset.id] })).rejects.toBeInstanceOf( BadRequestException, @@ -804,7 +809,7 @@ describe(AlbumService.name, () => { it('should not allow unauthorized shared link access to the album', async () => { const album = AlbumFactory.create(); const asset = AssetFactory.create(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); await expect( sut.addAssets(AuthFactory.from().sharedLink({ allowUpload: true }).build(), album.id, { ids: [asset.id] }), @@ -821,7 +826,7 @@ describe(AlbumService.name, () => { const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( @@ -859,7 +864,7 @@ describe(AlbumService.name, () => { const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( @@ -897,7 +902,7 @@ describe(AlbumService.name, () => { const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set([album1.id, album2.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( @@ -943,7 +948,7 @@ describe(AlbumService.name, () => { const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( @@ -965,7 +970,7 @@ describe(AlbumService.name, () => { const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkSharedLinkAccess.mockResolvedValueOnce(new Set([album1.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); const auth = AuthFactory.from(album1.owner).sharedLink({ allowUpload: true }).build(); @@ -1004,7 +1009,7 @@ describe(AlbumService.name, () => { ]; mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id])); mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( @@ -1048,7 +1053,7 @@ describe(AlbumService.name, () => { mocks.album.getAssetIds .mockResolvedValueOnce(new Set([asset1.id, asset2.id, asset3.id])) .mockResolvedValueOnce(new Set()); - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); await expect( sut.addAssetsToAlbums(AuthFactory.create(album1.owner), { @@ -1078,7 +1083,7 @@ describe(AlbumService.name, () => { .mockResolvedValueOnce(new Set([album1.id])) .mockResolvedValueOnce(new Set([album2.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); await expect( @@ -1107,7 +1112,7 @@ describe(AlbumService.name, () => { mocks.access.album.checkSharedAlbumAccess .mockResolvedValueOnce(new Set([album1.id])) .mockResolvedValueOnce(new Set([album2.id])); - mocks.album.getById.mockResolvedValueOnce(_.cloneDeep(album1)).mockResolvedValueOnce(_.cloneDeep(album2)); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( @@ -1138,7 +1143,7 @@ describe(AlbumService.name, () => { const album1 = AlbumFactory.create(); const album2 = AlbumFactory.create(); const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); await expect( sut.addAssetsToAlbums(AuthFactory.create(user), { @@ -1160,7 +1165,7 @@ describe(AlbumService.name, () => { const album1 = AlbumFactory.create(); const album2 = AlbumFactory.create(); const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; - mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); + mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2)); await expect( sut.addAssetsToAlbums(AuthFactory.from().sharedLink({ allowUpload: true }).build(), { @@ -1182,7 +1187,7 @@ describe(AlbumService.name, () => { const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id])); await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ @@ -1196,7 +1201,7 @@ describe(AlbumService.name, () => { const asset = AssetFactory.create(); const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValue(new Set()); await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ @@ -1210,7 +1215,7 @@ describe(AlbumService.name, () => { const asset = AssetFactory.create(); const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id])); await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ @@ -1224,7 +1229,7 @@ describe(AlbumService.name, () => { const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id])); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id])); await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset1.id] })).resolves.toEqual([ diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 18747dbc3a08f..24b9b165c989f 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -21,6 +21,7 @@ import { Permission } from 'src/enum'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository'; import { BaseService } from 'src/services/base.service'; import { addAssets, removeAssets } from 'src/utils/asset.util'; +import { asDateString } from 'src/utils/date'; import { getPreferences } from 'src/utils/preferences'; @Injectable() @@ -64,11 +65,11 @@ export class AlbumService extends BaseService { return albums.map((album) => ({ ...mapAlbumWithoutAssets(album), sharedLinks: undefined, - startDate: albumMetadata[album.id]?.startDate ?? undefined, - endDate: albumMetadata[album.id]?.endDate ?? undefined, + startDate: asDateString(albumMetadata[album.id]?.startDate ?? undefined), + endDate: asDateString(albumMetadata[album.id]?.endDate ?? undefined), assetCount: albumMetadata[album.id]?.assetCount ?? 0, // lastModifiedAssetTimestamp is only used in mobile app, please remove if not need - lastModifiedAssetTimestamp: albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined, + lastModifiedAssetTimestamp: asDateString(albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined), })); } @@ -85,10 +86,10 @@ export class AlbumService extends BaseService { return { ...mapAlbum(album, withAssets, auth), - startDate: albumMetadataForIds?.startDate ?? undefined, - endDate: albumMetadataForIds?.endDate ?? undefined, + startDate: asDateString(albumMetadataForIds?.startDate ?? undefined), + endDate: asDateString(albumMetadataForIds?.endDate ?? undefined), assetCount: albumMetadataForIds?.assetCount ?? 0, - lastModifiedAssetTimestamp: albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined, + lastModifiedAssetTimestamp: asDateString(albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined), contributorCounts: isShared ? await this.albumRepository.getContributorCounts(album.id) : undefined, }; } diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 3a31dbbea1aaa..68165d642fc8b 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -1,7 +1,10 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { Permission } from 'src/enum'; import { ApiKeyService } from 'src/services/api-key.service'; -import { factory, newUuid } from 'test/small.factory'; +import { ApiKeyFactory } from 'test/factories/api-key.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { SessionFactory } from 'test/factories/session.factory'; +import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(ApiKeyService.name, () => { @@ -14,8 +17,8 @@ describe(ApiKeyService.name, () => { describe('create', () => { it('should create a new key', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.All] }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.All] }); const key = 'super-secret'; mocks.crypto.randomBytesAsText.mockReturnValue(key); @@ -34,8 +37,8 @@ describe(ApiKeyService.name, () => { }); it('should not require a name', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); const key = 'super-secret'; mocks.crypto.randomBytesAsText.mockReturnValue(key); @@ -54,7 +57,9 @@ describe(ApiKeyService.name, () => { }); it('should throw an error if the api key does not have sufficient permissions', async () => { - const auth = factory.auth({ apiKey: { permissions: [Permission.AssetRead] } }); + const auth = AuthFactory.from() + .apiKey({ permissions: [Permission.AssetRead] }) + .build(); await expect(sut.create(auth, { permissions: [Permission.AssetUpdate] })).rejects.toBeInstanceOf( BadRequestException, @@ -65,7 +70,7 @@ describe(ApiKeyService.name, () => { describe('update', () => { it('should throw an error if the key is not found', async () => { const id = newUuid(); - const auth = factory.auth(); + const auth = AuthFactory.create(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -77,8 +82,8 @@ describe(ApiKeyService.name, () => { }); it('should update a key', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); const newName = 'New name'; mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -93,8 +98,8 @@ describe(ApiKeyService.name, () => { }); it('should update permissions', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); const newPermissions = [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate]; mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -111,8 +116,8 @@ describe(ApiKeyService.name, () => { describe('api key auth', () => { it('should prevent adding Permission.all', async () => { const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead]; - const auth = factory.auth({ apiKey: { permissions } }); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions }); + const auth = AuthFactory.from().apiKey({ permissions }).build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -125,8 +130,8 @@ describe(ApiKeyService.name, () => { it('should prevent adding a new permission', async () => { const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead]; - const auth = factory.auth({ apiKey: { permissions } }); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions }); + const auth = AuthFactory.from().apiKey({ permissions }).build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -138,8 +143,10 @@ describe(ApiKeyService.name, () => { }); it('should allow removing permissions', async () => { - const auth = factory.auth({ apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] } }); - const apiKey = factory.apiKey({ + const auth = AuthFactory.from() + .apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] }) + .build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.AssetRead, Permission.AssetDelete], }); @@ -158,10 +165,10 @@ describe(ApiKeyService.name, () => { }); it('should allow adding new permissions', async () => { - const auth = factory.auth({ - apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] }, - }); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.AssetRead] }); + const auth = AuthFactory.from() + .apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] }) + .build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.AssetRead] }); mocks.apiKey.getById.mockResolvedValue(apiKey); mocks.apiKey.update.mockResolvedValue(apiKey); @@ -183,7 +190,7 @@ describe(ApiKeyService.name, () => { describe('delete', () => { it('should throw an error if the key is not found', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); const id = newUuid(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -194,8 +201,8 @@ describe(ApiKeyService.name, () => { }); it('should delete a key', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getById.mockResolvedValue(apiKey); mocks.apiKey.delete.mockResolvedValue(); @@ -208,8 +215,8 @@ describe(ApiKeyService.name, () => { describe('getMine', () => { it('should not work with a session token', async () => { - const session = factory.session(); - const auth = factory.auth({ session }); + const session = SessionFactory.create(); + const auth = AuthFactory.from().session(session).build(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -219,8 +226,8 @@ describe(ApiKeyService.name, () => { }); it('should throw an error if the key is not found', async () => { - const apiKey = factory.authApiKey(); - const auth = factory.auth({ apiKey }); + const apiKey = ApiKeyFactory.create(); + const auth = AuthFactory.from().apiKey(apiKey).build(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -230,8 +237,8 @@ describe(ApiKeyService.name, () => { }); it('should get a key by id', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -243,7 +250,7 @@ describe(ApiKeyService.name, () => { describe('getById', () => { it('should throw an error if the key is not found', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); const id = newUuid(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -254,8 +261,8 @@ describe(ApiKeyService.name, () => { }); it('should get a key by id', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -267,8 +274,8 @@ describe(ApiKeyService.name, () => { describe('getAll', () => { it('should return all the keys for a user', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getByUserId.mockResolvedValue([apiKey]); diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index ebe9570926120..bee321d99359d 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -24,6 +24,7 @@ import { AuthFactory } from 'test/factories/auth.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { userStub } from 'test/fixtures/user.stub'; +import { getForAsset } from 'test/mappers'; import { newTestService, ServiceMocks } from 'test/utils'; const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); @@ -172,7 +173,6 @@ const assetEntity = Object.freeze({ fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), updatedAt: new Date('2022-06-19T23:41:36.910Z'), isFavorite: false, - encodedVideoPath: '', duration: '0:00:00.000000', files: [] as AssetFile[], exifInfo: { @@ -442,7 +442,7 @@ describe(AssetMediaService.name, () => { .owner(authStub.user1.user) .build(); const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); - mocks.asset.getById.mockResolvedValueOnce(motionAsset); + mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset)); mocks.asset.create.mockResolvedValueOnce(asset); await expect( @@ -459,7 +459,7 @@ describe(AssetMediaService.name, () => { it('should hide the linked motion asset', async () => { const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build(); const asset = AssetFactory.create(); - mocks.asset.getById.mockResolvedValueOnce(motionAsset); + mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset)); mocks.asset.create.mockResolvedValueOnce(asset); await expect( @@ -478,7 +478,7 @@ describe(AssetMediaService.name, () => { it('should handle a sidecar file', async () => { const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).build(); - mocks.asset.getById.mockResolvedValueOnce(asset); + mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset)); mocks.asset.create.mockResolvedValueOnce(asset); await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({ @@ -745,13 +745,18 @@ describe(AssetMediaService.name, () => { }); it('should return the encoded video path if available', async () => { - const asset = AssetFactory.create({ encodedVideoPath: '/path/to/encoded/video.mp4' }); + const asset = AssetFactory.from() + .file({ type: AssetFileType.EncodedVideo, path: '/path/to/encoded/video.mp4' }) + .build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getForVideo.mockResolvedValue(asset); + mocks.asset.getForVideo.mockResolvedValue({ + originalPath: asset.originalPath, + encodedVideoPath: asset.files[0].path, + }); await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual( new ImmichFileResponse({ - path: asset.encodedVideoPath!, + path: '/path/to/encoded/video.mp4', cacheControl: CacheControl.PrivateWithCache, contentType: 'video/mp4', }), @@ -761,7 +766,10 @@ describe(AssetMediaService.name, () => { it('should fall back to the original path', async () => { const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getForVideo.mockResolvedValue(asset); + mocks.asset.getForVideo.mockResolvedValue({ + originalPath: asset.originalPath, + encodedVideoPath: null, + }); await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual( new ImmichFileResponse({ @@ -805,8 +813,8 @@ describe(AssetMediaService.name, () => { it('should update a photo with no sidecar to photo with no sidecar', async () => { const updatedFile = { ...fileStub.photo, originalPath: '/fake_path/photo1.jpeg' }; const updatedAsset = { ...existingAsset, ...updatedFile }; - mocks.asset.getById.mockResolvedValueOnce(existingAsset); - mocks.asset.getById.mockResolvedValueOnce(updatedAsset); + mocks.asset.getById.mockResolvedValueOnce(existingAsset as any); + mocks.asset.getById.mockResolvedValueOnce(updatedAsset as any); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); // this is the original file size mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); @@ -854,8 +862,8 @@ describe(AssetMediaService.name, () => { const updatedFile = { ...fileStub.photo, originalPath: '/fake_path/photo1.jpeg' }; const sidecarFile = fileStub.photoSidecar; const updatedAsset = { ...sidecarAsset, ...updatedFile }; - mocks.asset.getById.mockResolvedValueOnce(existingAsset); - mocks.asset.getById.mockResolvedValueOnce(updatedAsset); + mocks.asset.getById.mockResolvedValueOnce(existingAsset as any); + mocks.asset.getById.mockResolvedValueOnce(updatedAsset as any); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); // this is the original file size mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); @@ -892,8 +900,8 @@ describe(AssetMediaService.name, () => { const updatedFile = { ...fileStub.photo, originalPath: '/fake_path/photo1.jpeg' }; const updatedAsset = { ...sidecarAsset, ...updatedFile }; - mocks.asset.getById.mockResolvedValueOnce(sidecarAsset); - mocks.asset.getById.mockResolvedValueOnce(updatedAsset); + mocks.asset.getById.mockResolvedValueOnce(sidecarAsset as any); + mocks.asset.getById.mockResolvedValueOnce(updatedAsset as any); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); // this is the original file size mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); @@ -927,8 +935,8 @@ describe(AssetMediaService.name, () => { // S3 uploaded files have relative paths const updatedFile = { ...fileStub.photo, originalPath: 'upload/user/ab/cd/photo1.jpeg' }; const updatedAsset = { ...existingAsset, ...updatedFile }; - mocks.asset.getById.mockResolvedValueOnce(existingAsset); - mocks.asset.getById.mockResolvedValueOnce(updatedAsset); + mocks.asset.getById.mockResolvedValueOnce(existingAsset as any); + mocks.asset.getById.mockResolvedValueOnce(updatedAsset as any); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); // this is the original file size mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats); @@ -949,7 +957,7 @@ describe(AssetMediaService.name, () => { (error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT; mocks.asset.update.mockRejectedValue(error); - mocks.asset.getById.mockResolvedValueOnce(sidecarAsset); + mocks.asset.getById.mockResolvedValueOnce(sidecarAsset as any); mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id])); // this is the original file size @@ -1252,7 +1260,7 @@ describe(AssetMediaService.name, () => { (error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT; mocks.asset.update.mockRejectedValue(error); - mocks.asset.getById.mockResolvedValueOnce(existingAsset); + mocks.asset.getById.mockResolvedValueOnce(existingAsset as any); mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(void 0); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 8740e58f87e7c..638d2f639ab34 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -154,6 +154,10 @@ export class AssetMediaService extends BaseService { } const asset = await this.create(auth.user.id, dto, file, sidecarFile); + if (auth.sharedLink) { + await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [asset.id]); + } + await this.userRepository.updateUsage(auth.user.id, file.size); return { id: asset.id, status: AssetMediaStatus.CREATED }; @@ -335,6 +339,11 @@ export class AssetMediaService extends BaseService { this.logger.error(`Error locating duplicate for checksum constraint`); throw new InternalServerErrorException(); } + + if (auth.sharedLink) { + await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [duplicateId]); + } + return { status: AssetMediaStatus.DUPLICATE, id: duplicateId }; } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index a48a12561aec0..d93223e727096 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -7,7 +7,9 @@ import { AssetStats } from 'src/repositories/asset.repository'; import { AssetService } from 'src/services/asset.service'; import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; import { authStub } from 'test/fixtures/auth.stub'; +import { getForAsset, getForAssetDeletion, getForPartner } from 'test/mappers'; import { factory, newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -81,7 +83,7 @@ describe(AssetService.name, () => { describe('getRandom', () => { it('should get own random assets', async () => { mocks.partner.getAll.mockResolvedValue([]); - mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]); + mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]); await sut.getRandom(authStub.admin, 1); @@ -89,11 +91,11 @@ describe(AssetService.name, () => { }); it('should not include partner assets if not in timeline', async () => { - const partner = factory.partner({ inTimeline: false }); - const auth = factory.auth({ user: { id: partner.sharedWithId } }); + const partner = PartnerFactory.create({ inTimeline: false }); + const auth = AuthFactory.create({ id: partner.sharedWithId }); - mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]); - mocks.partner.getAll.mockResolvedValue([partner]); + mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]); + mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]); await sut.getRandom(auth, 1); @@ -101,11 +103,11 @@ describe(AssetService.name, () => { }); it('should include partner assets if in timeline', async () => { - const partner = factory.partner({ inTimeline: true }); - const auth = factory.auth({ user: { id: partner.sharedWithId } }); + const partner = PartnerFactory.create({ inTimeline: true }); + const auth = AuthFactory.create({ id: partner.sharedWithId }); - mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]); - mocks.partner.getAll.mockResolvedValue([partner]); + mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]); + mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]); await sut.getRandom(auth, 1); @@ -117,7 +119,7 @@ describe(AssetService.name, () => { it('should allow owner access', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(getForAsset(asset)); await sut.get(authStub.admin, asset.id); @@ -131,7 +133,7 @@ describe(AssetService.name, () => { it('should allow shared link access', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(getForAsset(asset)); await sut.get(authStub.adminSharedLink, asset.id); @@ -144,7 +146,7 @@ describe(AssetService.name, () => { it('should strip metadata for shared link if exif is disabled', async () => { const asset = AssetFactory.from().exif({ description: 'foo' }).build(); mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(getForAsset(asset)); const result = await sut.get( { ...authStub.adminSharedLink, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } }, @@ -162,7 +164,7 @@ describe(AssetService.name, () => { it('should delete owner from response for shared link with showExif', async () => { const asset = AssetFactory.from().exif({ description: 'foo' }).build(); mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset as any); const result = await sut.get( { ...authStub.adminSharedLink, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: true } }, @@ -175,7 +177,7 @@ describe(AssetService.name, () => { it('should clear people for non-owner access', async () => { const asset = AssetFactory.from().exif().build(); mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset as any); const result = await sut.get(authStub.admin, asset.id); @@ -185,7 +187,7 @@ describe(AssetService.name, () => { it('should allow partner sharing access', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(getForAsset(asset)); await sut.get(authStub.admin, asset.id); @@ -195,7 +197,7 @@ describe(AssetService.name, () => { it('should allow shared album access', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(getForAsset(asset)); await sut.get(authStub.admin, asset.id); @@ -205,7 +207,7 @@ describe(AssetService.name, () => { it('should allow shared space access', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkSpaceAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset as any); await sut.get(authStub.admin, asset.id); @@ -215,7 +217,7 @@ describe(AssetService.name, () => { it('should clear people for shared space access (non-owner)', async () => { const asset = AssetFactory.from().exif().build(); mocks.access.asset.checkSpaceAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset as any); const result = await sut.get(authStub.admin, asset.id); @@ -257,8 +259,8 @@ describe(AssetService.name, () => { it('should update the asset', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); - mocks.asset.update.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(getForAsset(asset)); + mocks.asset.update.mockResolvedValue(getForAsset(asset)); await sut.update(authStub.admin, asset.id, { isFavorite: true }); @@ -268,8 +270,8 @@ describe(AssetService.name, () => { it('should update the exif description', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); - mocks.asset.update.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(getForAsset(asset)); + mocks.asset.update.mockResolvedValue(getForAsset(asset)); await sut.update(authStub.admin, asset.id, { description: 'Test description' }); @@ -282,8 +284,8 @@ describe(AssetService.name, () => { it('should update the exif rating', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValueOnce(asset); - mocks.asset.update.mockResolvedValueOnce(asset); + mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset)); + mocks.asset.update.mockResolvedValueOnce(getForAsset(asset)); await sut.update(authStub.admin, asset.id, { rating: 3 }); @@ -327,7 +329,7 @@ describe(AssetService.name, () => { const motionAsset = AssetFactory.from().owner(auth.user).build(); const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(getForAsset(asset)); await expect( sut.update(authStub.admin, asset.id, { @@ -354,7 +356,7 @@ describe(AssetService.name, () => { const motionAsset = AssetFactory.create({ type: AssetType.Video }); const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(motionAsset); + mocks.asset.getById.mockResolvedValue(getForAsset(motionAsset)); await expect( sut.update(auth, asset.id, { @@ -380,9 +382,9 @@ describe(AssetService.name, () => { const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Timeline }); const stillAsset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([stillAsset.id])); - mocks.asset.getById.mockResolvedValueOnce(motionAsset); - mocks.asset.getById.mockResolvedValueOnce(stillAsset); - mocks.asset.update.mockResolvedValue(stillAsset); + mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset)); + mocks.asset.getById.mockResolvedValueOnce(getForAsset(stillAsset)); + mocks.asset.update.mockResolvedValue(getForAsset(stillAsset)); const auth = AuthFactory.from(motionAsset.owner).build(); await sut.update(auth, stillAsset.id, { livePhotoVideoId: motionAsset.id }); @@ -407,9 +409,9 @@ describe(AssetService.name, () => { const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); const unlinkedAsset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValueOnce(asset); - mocks.asset.getById.mockResolvedValueOnce(motionAsset); - mocks.asset.update.mockResolvedValueOnce(unlinkedAsset); + mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset)); + mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset)); + mocks.asset.update.mockResolvedValueOnce(getForAsset(unlinkedAsset)); await sut.update(auth, asset.id, { livePhotoVideoId: null }); @@ -444,8 +446,8 @@ describe(AssetService.name, () => { const auth = AuthFactory.create(); const asset = AssetFactory.create({ livePhotoVideoId: null }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValueOnce(asset); - mocks.asset.update.mockResolvedValueOnce(asset); + mocks.asset.getById.mockResolvedValueOnce(asset as any); + mocks.asset.update.mockResolvedValueOnce(asset as any); await sut.update(auth, asset.id, { livePhotoVideoId: null }); @@ -455,7 +457,7 @@ describe(AssetService.name, () => { it('should update latitude and longitude', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.update.mockResolvedValue(asset); + mocks.asset.update.mockResolvedValue(asset as any); await sut.update(authStub.admin, asset.id, { latitude: 40.7128, longitude: -74.006 }); @@ -751,7 +753,7 @@ describe(AssetService.name, () => { .file({ type: AssetFileType.Preview, isEdited: true }) .file({ type: AssetFileType.Thumbnail, isEdited: true }) .build(); - mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset)); await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); @@ -765,7 +767,7 @@ describe(AssetService.name, () => { }, ], ]); - expect(mocks.asset.remove).toHaveBeenCalledWith(asset); + expect(mocks.asset.remove).toHaveBeenCalledWith(getForAssetDeletion(asset)); }); it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => { @@ -773,11 +775,7 @@ describe(AssetService.name, () => { .stack({}, (builder) => builder.asset()) .build(); mocks.stack.delete.mockResolvedValue(); - mocks.assetJob.getForAssetDeletion.mockResolvedValue({ - ...asset, - // TODO the specific query filters out the primary asset from `stack.assets`. This should be in a mapper eventually - stack: { ...asset.stack!, assets: asset.stack!.assets.filter(({ id }) => id !== asset.stack!.primaryAssetId) }, - }); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset)); await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); @@ -801,7 +799,7 @@ describe(AssetService.name, () => { primaryAssetId: asset.id, assets: stackAssets, }, - }); + } as any); await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); @@ -814,7 +812,7 @@ describe(AssetService.name, () => { it('should delete a live photo', async () => { const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build(); const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); - mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset)); mocks.asset.getLivePhotoCount.mockResolvedValue(0); await sut.handleAssetDeletion({ @@ -831,7 +829,7 @@ describe(AssetService.name, () => { it('should not delete a live motion part if it is being used by another asset', async () => { const asset = AssetFactory.create({ livePhotoVideoId: newUuid() }); mocks.asset.getLivePhotoCount.mockResolvedValue(2); - mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset)); await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); @@ -842,7 +840,7 @@ describe(AssetService.name, () => { it('should update usage', async () => { const asset = AssetFactory.from().exif({ fileSizeInByte: 5000 }).build(); - mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset)); await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); expect(mocks.user.updateUsage).toHaveBeenCalledWith(asset.ownerId, -5000); }); @@ -856,7 +854,7 @@ describe(AssetService.name, () => { it('should not update usage for library assets', async () => { const asset = AssetFactory.from({ libraryId: newUuid() }).exif({ fileSizeInByte: 5000 }).build(); - mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset as any); await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); @@ -868,7 +866,7 @@ describe(AssetService.name, () => { .file({ type: AssetFileType.Thumbnail }) .file({ type: AssetFileType.Sidecar }) .build(); - mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset as any); await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: false }); @@ -881,7 +879,7 @@ describe(AssetService.name, () => { it('should emit AssetDelete event', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset as any); await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); @@ -1583,7 +1581,7 @@ describe(AssetService.name, () => { it('should remove asset edits and queue thumbnail generation', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset as any); mocks.assetEdit.replaceAll.mockResolvedValue([]); await sut.removeAssetEdits(authStub.admin, asset.id); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 387b700f011fc..1e5d23a98d8f6 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -370,7 +370,7 @@ export class AssetService extends BaseService { assetFiles.editedFullsizeFile?.path, assetFiles.editedPreviewFile?.path, assetFiles.editedThumbnailFile?.path, - asset.encodedVideoPath, + assetFiles.encodedVideoFile?.path, ]; if (deleteOnDisk && !asset.isOffline) { diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 46c990218ab4b..11533563eb73f 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -6,6 +6,10 @@ import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { AuthType, JobName, Permission } from 'src/enum'; import { AuthService } from 'src/services/auth.service'; import { UserMetadataItem } from 'src/types'; +import { ApiKeyFactory } from 'test/factories/api-key.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { SessionFactory } from 'test/factories/session.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { factory, newUuid } from 'test/small.factory'; @@ -91,8 +95,8 @@ describe(AuthService.name, () => { }); it('should successfully log the user in', async () => { - const user = { ...(factory.user() as UserAdmin), password: 'immich_password' }; - const session = factory.session(); + const user = UserFactory.create({ password: 'immich_password' }); + const session = SessionFactory.create(); mocks.user.getByEmail.mockResolvedValue(user); mocks.session.create.mockResolvedValue(session); @@ -113,8 +117,8 @@ describe(AuthService.name, () => { describe('changePassword', () => { it('should change the password', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password' }; mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' }); @@ -132,8 +136,8 @@ describe(AuthService.name, () => { }); it('should throw when password does not match existing password', async () => { - const user = factory.user(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password' }; mocks.crypto.compareBcrypt.mockReturnValue(false); @@ -144,8 +148,8 @@ describe(AuthService.name, () => { }); it('should throw when user does not have a password', async () => { - const user = factory.user(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password' }; mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: '' }); @@ -154,8 +158,8 @@ describe(AuthService.name, () => { }); it('should change the password and logout other sessions', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password', invalidateSessions: true }; mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' }); @@ -175,7 +179,7 @@ describe(AuthService.name, () => { describe('logout', () => { it('should return the end session endpoint', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); @@ -186,7 +190,7 @@ describe(AuthService.name, () => { }); it('should return the default redirect', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({ successful: true, @@ -262,11 +266,11 @@ describe(AuthService.name, () => { }); it('should validate using authorization header', async () => { - const session = factory.session(); + const session = SessionFactory.create(); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), pinExpiresAt: null, appVersion: null, }; @@ -340,7 +344,7 @@ describe(AuthService.name, () => { }); it('should accept a base64url key', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); const sharedLink = { ...sharedLinkStub.valid, user } as any; mocks.sharedLink.getByKey.mockResolvedValue(sharedLink); @@ -361,7 +365,7 @@ describe(AuthService.name, () => { }); it('should accept a hex key', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); const sharedLink = { ...sharedLinkStub.valid, user } as any; mocks.sharedLink.getByKey.mockResolvedValue(sharedLink); @@ -396,7 +400,7 @@ describe(AuthService.name, () => { }); it('should accept a valid slug', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); const sharedLink = { ...sharedLinkStub.valid, slug: 'slug-123', user } as any; mocks.sharedLink.getBySlug.mockResolvedValue(sharedLink); @@ -428,11 +432,11 @@ describe(AuthService.name, () => { }); it('should return an auth dto', async () => { - const session = factory.session(); + const session = SessionFactory.create(); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), pinExpiresAt: null, appVersion: null, }; @@ -455,11 +459,11 @@ describe(AuthService.name, () => { }); it('should throw if admin route and not an admin', async () => { - const session = factory.session(); + const session = SessionFactory.create(); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), isPendingSyncReset: false, pinExpiresAt: null, appVersion: null, @@ -477,11 +481,11 @@ describe(AuthService.name, () => { }); it('should update when access time exceeds an hour', async () => { - const session = factory.session({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() }); + const session = SessionFactory.create({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() }); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), isPendingSyncReset: false, pinExpiresAt: null, appVersion: null, @@ -517,8 +521,8 @@ describe(AuthService.name, () => { }); it('should throw an error if api key has insufficient permissions', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.create({ permissions: [] }); mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); @@ -533,8 +537,8 @@ describe(AuthService.name, () => { }); it('should default to requiring the all permission when omitted', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [Permission.AssetRead] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.create({ permissions: [Permission.AssetRead] }); mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); @@ -548,10 +552,12 @@ describe(AuthService.name, () => { }); it('should not require any permission when metadata is set to `false`', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [Permission.ActivityRead] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.from({ permissions: [Permission.ActivityRead] }) + .user(authUser) + .build(); - mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); + mocks.apiKey.getKey.mockResolvedValue(authApiKey); const result = sut.authenticate({ headers: { 'x-api-key': 'auth_token' }, @@ -562,10 +568,12 @@ describe(AuthService.name, () => { }); it('should return an auth dto', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [Permission.All] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.from({ permissions: [Permission.All] }) + .user(authUser) + .build(); - mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); + mocks.apiKey.getKey.mockResolvedValue(authApiKey); await expect( sut.authenticate({ @@ -629,12 +637,12 @@ describe(AuthService.name, () => { }); it('should link an existing user', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.user.getByEmail.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -649,7 +657,7 @@ describe(AuthService.name, () => { }); it('should not link to a user with a different oauth sub', async () => { - const user = factory.userAdmin({ isAdmin: true, oauthId: 'existing-sub' }); + const user = UserFactory.create({ isAdmin: true, oauthId: 'existing-sub' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.user.getByEmail.mockResolvedValueOnce(user); @@ -669,13 +677,13 @@ describe(AuthService.name, () => { }); it('should allow auto registering by default', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -690,13 +698,13 @@ describe(AuthService.name, () => { }); it('should throw an error if user should be auto registered but the email claim does not exist', async () => { - const user = factory.userAdmin({ isAdmin: true }); + const user = UserFactory.create({ isAdmin: true }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getAdmin.mockResolvedValue(user); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined }); await expect( @@ -717,11 +725,11 @@ describe(AuthService.name, () => { 'app.immich:///oauth-callback?code=abc123', ]) { it(`should use the mobile redirect override for a url of ${url}`, async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); mocks.user.getByOAuthId.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails); @@ -735,13 +743,13 @@ describe(AuthService.name, () => { } it('should use the default quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -755,14 +763,14 @@ describe(AuthService.name, () => { }); it('should ignore an invalid storage quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 'abc' }); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -776,14 +784,14 @@ describe(AuthService.name, () => { }); it('should ignore a negative quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: -5 }); mocks.user.getAdmin.mockResolvedValue(user); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -797,14 +805,14 @@ describe(AuthService.name, () => { }); it('should set quota for 0 quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 0 }); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -825,15 +833,15 @@ describe(AuthService.name, () => { }); it('should use a valid storage quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 5 }); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -855,7 +863,7 @@ describe(AuthService.name, () => { it('should sync the profile picture', async () => { const fileId = newUuid(); - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg'; mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); @@ -871,7 +879,7 @@ describe(AuthService.name, () => { data: new Uint8Array([1, 2, 3, 4, 5]).buffer, }); mocks.user.update.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -889,7 +897,7 @@ describe(AuthService.name, () => { }); it('should not sync the profile picture if the user already has one', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id', profileImagePath: 'not-empty' }); + const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.oauth.getProfile.mockResolvedValue({ @@ -899,7 +907,7 @@ describe(AuthService.name, () => { }); mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -914,15 +922,15 @@ describe(AuthService.name, () => { }); it('should only allow "admin" and "user" for the role claim', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'foo' }); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -943,14 +951,14 @@ describe(AuthService.name, () => { }); it('should create an admin user if the role claim is set to admin', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'admin' }); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -971,7 +979,7 @@ describe(AuthService.name, () => { }); it('should accept a custom role claim', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue({ oauth: { ...systemConfigStub.oauthWithAutoRegister, roleClaim: 'my_role' }, @@ -980,7 +988,7 @@ describe(AuthService.name, () => { mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -1003,8 +1011,8 @@ describe(AuthService.name, () => { describe('link', () => { it('should link an account', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ apiKey: { permissions: [] }, user }); + const user = UserFactory.create(); + const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.update.mockResolvedValue(user); @@ -1019,8 +1027,8 @@ describe(AuthService.name, () => { }); it('should not link an already linked oauth.sub', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.create({ permissions: [] }); const auth = { user: authUser, apiKey: authApiKey }; mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); @@ -1036,8 +1044,8 @@ describe(AuthService.name, () => { describe('unlink', () => { it('should unlink an account', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user, apiKey: { permissions: [] } }); + const user = UserFactory.create(); + const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.update.mockResolvedValue(user); @@ -1050,8 +1058,8 @@ describe(AuthService.name, () => { describe('setupPinCode', () => { it('should setup a PIN code', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { pinCode: '123456' }; mocks.user.getForPinCode.mockResolvedValue({ pinCode: null, password: '' }); @@ -1065,8 +1073,8 @@ describe(AuthService.name, () => { }); it('should fail if the user already has a PIN code', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); @@ -1076,8 +1084,8 @@ describe(AuthService.name, () => { describe('changePinCode', () => { it('should change the PIN code', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { pinCode: '123456', newPinCode: '012345' }; mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); @@ -1091,37 +1099,37 @@ describe(AuthService.name, () => { }); it('should fail if the PIN code does not match', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); await expect( - sut.changePinCode(factory.auth({ user }), { pinCode: '000000', newPinCode: '012345' }), + sut.changePinCode(AuthFactory.create(user), { pinCode: '000000', newPinCode: '012345' }), ).rejects.toThrow('Wrong PIN code'); }); }); describe('resetPinCode', () => { it('should reset the PIN code', async () => { - const currentSession = factory.session(); - const user = factory.userAdmin(); + const currentSession = SessionFactory.create(); + const user = UserFactory.create(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); mocks.session.lockAll.mockResolvedValue(void 0); mocks.session.update.mockResolvedValue(currentSession); - await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' }); + await sut.resetPinCode(AuthFactory.create(user), { pinCode: '123456' }); expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null }); expect(mocks.session.lockAll).toHaveBeenCalledWith(user.id); }); it('should throw if the PIN code does not match', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); - await expect(sut.resetPinCode(factory.auth({ user }), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code'); + await expect(sut.resetPinCode(AuthFactory.create(user), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code'); }); it('should reset the PIN code using password', async () => { diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index 2156288f16a41..99e40ca0ee30d 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -1,6 +1,7 @@ import { jwtVerify } from 'jose'; import { MaintenanceAction, SystemMetadataKey } from 'src/enum'; import { CliService } from 'src/services/cli.service'; +import { UserFactory } from 'test/factories/user.factory'; import { factory, newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; import { describe, it } from 'vitest'; @@ -141,7 +142,7 @@ describe(CliService.name, () => { describe('listUsers', () => { it('should list users', async () => { - mocks.user.getList.mockResolvedValue([factory.userAdmin({ isAdmin: true })]); + mocks.user.getList.mockResolvedValue([UserFactory.create({ isAdmin: true })]); await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]); expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true }); }); @@ -178,10 +179,10 @@ describe(CliService.name, () => { }); it('should default to a random password', async () => { - const admin = factory.userAdmin({ isAdmin: true }); + const admin = UserFactory.create({ isAdmin: true }); mocks.user.getAdmin.mockResolvedValue(admin); - mocks.user.update.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true })); const ask = vitest.fn().mockImplementation(() => {}); @@ -196,7 +197,7 @@ describe(CliService.name, () => { }); it('should use the supplied password', async () => { - const admin = factory.userAdmin({ isAdmin: true }); + const admin = UserFactory.create({ isAdmin: true }); mocks.user.getAdmin.mockResolvedValue(admin); mocks.user.update.mockResolvedValue(admin); diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index cab2c1e452cae..687f64d916454 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -3,6 +3,7 @@ import { DuplicateService } from 'src/services/duplicate.service'; import { SearchService } from 'src/services/search.service'; import { AssetFactory } from 'test/factories/asset.factory'; import { authStub } from 'test/fixtures/auth.stub'; +import { getForDuplicate } from 'test/mappers'; import { newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { beforeEach, vitest } from 'vitest'; @@ -41,11 +42,11 @@ describe(SearchService.name, () => { describe('getDuplicates', () => { it('should get duplicates', async () => { - const asset = AssetFactory.create(); + const asset = AssetFactory.from().exif().build(); mocks.duplicateRepository.getAll.mockResolvedValue([ { duplicateId: 'duplicate-id', - assets: [asset, asset], + assets: [getForDuplicate(asset), getForDuplicate(asset)], }, ]); await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([ diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index bb9c4da85e464..78eb913923091 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -187,8 +187,8 @@ export class JobService extends BaseService { exifImageHeight: exif.exifImageHeight, fileSizeInByte: exif.fileSizeInByte, orientation: exif.orientation, - dateTimeOriginal: exif.dateTimeOriginal, - modifyDate: exif.modifyDate, + dateTimeOriginal: exif.dateTimeOriginal ? new Date(exif.dateTimeOriginal) : null, + modifyDate: exif.modifyDate ? new Date(exif.modifyDate) : null, timeZone: exif.timeZone, latitude: exif.latitude, longitude: exif.longitude, diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts index d58ae67140f2b..fdf7aee68b36e 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -2,8 +2,9 @@ import { MapService } from 'src/services/map.service'; import { AlbumFactory } from 'test/factories/album.factory'; import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; import { userStub } from 'test/fixtures/user.stub'; -import { factory } from 'test/small.factory'; +import { getForAlbum, getForPartner } from 'test/mappers'; import { newTestService, ServiceMocks } from 'test/utils'; describe(MapService.name, () => { @@ -39,7 +40,7 @@ describe(MapService.name, () => { it('should include partner assets', async () => { const auth = AuthFactory.create(); - const partner = factory.partner({ sharedWithId: auth.user.id }); + const partner = PartnerFactory.create({ sharedWithId: auth.user.id }); const asset = AssetFactory.from() .exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' }) @@ -52,7 +53,7 @@ describe(MapService.name, () => { state: asset.exifInfo.state, country: asset.exifInfo.country, }; - mocks.partner.getAll.mockResolvedValue([partner]); + mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]); mocks.map.getMapMarkers.mockResolvedValue([marker]); const markers = await sut.getMapMarkers(auth, { withPartners: true }); @@ -81,8 +82,10 @@ describe(MapService.name, () => { }; mocks.partner.getAll.mockResolvedValue([]); mocks.map.getMapMarkers.mockResolvedValue([marker]); - mocks.album.getOwned.mockResolvedValue([AlbumFactory.create()]); - mocks.album.getShared.mockResolvedValue([AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()]); + mocks.album.getOwned.mockResolvedValue([getForAlbum(AlbumFactory.create())]); + mocks.album.getShared.mockResolvedValue([ + getForAlbum(AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()), + ]); const markers = await sut.getMapMarkers(auth, { withSharedAlbums: true }); diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index a9f406b7526da..1ce027b4fa2ec 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1,3 +1,4 @@ +import { ShallowDehydrateObject } from 'kysely'; import { OutputInfo } from 'sharp'; import { SystemConfig } from 'src/config'; import { Exif } from 'src/database'; @@ -28,6 +29,7 @@ import { PersonFactory } from 'test/factories/person.factory'; import { probeStub } from 'test/fixtures/media.stub'; import { personThumbnailStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; +import { getForGenerateThumbnail } from 'test/mappers'; import { factory, newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -368,8 +370,10 @@ describe(MediaService.name, () => { }); it('should skip thumbnail generation if asset type is unknown', async () => { - const asset = AssetFactory.create({ type: 'foo' as AssetType }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + const asset = AssetFactory.from({ type: 'foo' as AssetType }) + .exif() + .build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await expect(sut.handleGenerateThumbnails({ id: asset.id })).resolves.toBe(JobStatus.Skipped); expect(mocks.media.probe).not.toHaveBeenCalled(); @@ -378,17 +382,17 @@ describe(MediaService.name, () => { }); it('should skip video thumbnail generation if no video stream', async () => { - const asset = AssetFactory.create({ type: AssetType.Video }); + const asset = AssetFactory.from({ type: AssetType.Video }).exif().build(); mocks.media.probe.mockResolvedValue(probeStub.noVideoStreams); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await expect(sut.handleGenerateThumbnails({ id: asset.id })).rejects.toThrowError(); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should skip invisible assets', async () => { - const asset = AssetFactory.create({ visibility: AssetVisibility.Hidden }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + const asset = AssetFactory.from({ visibility: AssetVisibility.Hidden }).exif().build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); expect(await sut.handleGenerateThumbnails({ id: asset.id })).toEqual(JobStatus.Skipped); @@ -399,7 +403,7 @@ describe(MediaService.name, () => { it('should delete previous preview if different path', async () => { const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -416,7 +420,7 @@ describe(MediaService.name, () => { .exif({ profileDescription: 'Adobe RGB', bitsPerSample: 14 }) .files([AssetFileType.Preview, AssetFileType.Thumbnail]) .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); @@ -491,9 +495,9 @@ describe(MediaService.name, () => { }); it('should generate a thumbnail for a video', async () => { - const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); + const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); @@ -533,9 +537,9 @@ describe(MediaService.name, () => { }); it('should tonemap thumbnail for hdr video', async () => { - const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); + const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); @@ -575,12 +579,12 @@ describe(MediaService.name, () => { }); it('should always generate video thumbnail in one pass', async () => { - const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); + const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '5000k' }, }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -601,9 +605,9 @@ describe(MediaService.name, () => { }); it('should not skip intra frames for MTS file', async () => { - const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); + const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); mocks.media.probe.mockResolvedValue(probeStub.videoStreamMTS); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -619,9 +623,9 @@ describe(MediaService.name, () => { }); it('should override reserved color metadata', async () => { - const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); + const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); mocks.media.probe.mockResolvedValue(probeStub.videoStreamReserved); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -639,10 +643,10 @@ describe(MediaService.name, () => { }); it('should use scaling divisible by 2 even when using quick sync', async () => { - const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); + const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -659,7 +663,7 @@ describe(MediaService.name, () => { it.each(Object.values(ImageFormat))('should generate an image preview in %s format', async (format) => { const asset = AssetFactory.from().exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { format } } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); const previewPath = `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_preview.${format}`; @@ -709,7 +713,7 @@ describe(MediaService.name, () => { it.each(Object.values(ImageFormat))('should generate an image thumbnail in %s format', async (format) => { const asset = AssetFactory.from().exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format } } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); const previewPath = `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_preview.jpeg`; @@ -761,7 +765,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { progressive: true }, thumbnail: { progressive: false } }, }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -800,7 +804,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { progressive: false }, thumbnail: { format: ImageFormat.Jpeg, progressive: true } }, }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -835,12 +839,12 @@ describe(MediaService.name, () => { }); it('should never set isProgressive for videos', async () => { - const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); + const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { progressive: true }, thumbnail: { progressive: true } }, }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -861,7 +865,7 @@ describe(MediaService.name, () => { it('should delete previous thumbnail if different path', async () => { const asset = AssetFactory.from().exif().file({ type: AssetFileType.Preview }).build(); mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -880,7 +884,7 @@ describe(MediaService.name, () => { mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -897,7 +901,7 @@ describe(MediaService.name, () => { .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) .build(); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: false } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -911,7 +915,7 @@ describe(MediaService.name, () => { mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -926,7 +930,7 @@ describe(MediaService.name, () => { mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -942,7 +946,7 @@ describe(MediaService.name, () => { .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) .build(); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -959,7 +963,7 @@ describe(MediaService.name, () => { .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) .build(); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: false } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -978,7 +982,7 @@ describe(MediaService.name, () => { .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1019,7 +1023,7 @@ describe(MediaService.name, () => { }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1057,7 +1061,7 @@ describe(MediaService.name, () => { }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jxl }); mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1105,7 +1109,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1157,7 +1161,7 @@ describe(MediaService.name, () => { bitsPerSample: 14, }) .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1188,7 +1192,7 @@ describe(MediaService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1220,7 +1224,7 @@ describe(MediaService.name, () => { }) .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1265,7 +1269,7 @@ describe(MediaService.name, () => { bitsPerSample: 14, }) .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1304,7 +1308,7 @@ describe(MediaService.name, () => { bitsPerSample: 14, }) .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1339,7 +1343,7 @@ describe(MediaService.name, () => { it('should skip videos', async () => { const asset = AssetFactory.from({ type: AssetType.Video }).exif().build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await expect(sut.handleAssetEditThumbnailGeneration({ id: asset.id })).resolves.toBe(JobStatus.Success); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); @@ -1356,7 +1360,7 @@ describe(MediaService.name, () => { ]) .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.person.getFaces.mockResolvedValue([]); @@ -1378,7 +1382,7 @@ describe(MediaService.name, () => { .exif() .edit({ action: AssetEditAction.Crop, parameters: { height: 1152, width: 1512, x: 216, y: 1512 } }) .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); mocks.person.getFaces.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]); @@ -1406,7 +1410,7 @@ describe(MediaService.name, () => { { type: AssetFileType.FullSize, path: 'edited3.jpg', isEdited: true }, ]) .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); const status = await sut.handleAssetEditThumbnailGeneration({ id: asset.id }); @@ -1424,7 +1428,7 @@ describe(MediaService.name, () => { it('should generate all 3 edited files if an asset has edits', async () => { const asset = AssetFactory.from().exif().edit().build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); mocks.person.getFaces.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]); @@ -1450,7 +1454,7 @@ describe(MediaService.name, () => { it('should generate the original thumbhash if no edits exist', async () => { const asset = AssetFactory.from().exif().build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); mocks.media.generateThumbhash.mockResolvedValue(factory.buffer()); await sut.handleAssetEditThumbnailGeneration({ id: asset.id, source: 'upload' }); @@ -1460,7 +1464,7 @@ describe(MediaService.name, () => { it('should apply thumbhash if job source is edit and edits exist', async () => { const asset = AssetFactory.from().exif().edit().build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); const thumbhashBuffer = factory.buffer(); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.person.getFaces.mockResolvedValue([]); @@ -2265,7 +2269,9 @@ describe(MediaService.name, () => { }); it('should delete existing transcode if current policy does not require transcoding', async () => { - const asset = AssetFactory.create({ type: AssetType.Video, encodedVideoPath: '/encoded/video/path.mp4' }); + const asset = AssetFactory.from({ type: AssetType.Video }) + .file({ type: AssetFileType.EncodedVideo, path: '/encoded/video/path.mp4' }) + .build(); mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } }); mocks.assetJob.getForVideoConversion.mockResolvedValue(asset); @@ -2275,7 +2281,7 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).not.toHaveBeenCalled(); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FileDelete, - data: { files: [asset.encodedVideoPath] }, + data: { files: ['/encoded/video/path.mp4'] }, }); }); @@ -3618,15 +3624,15 @@ describe(MediaService.name, () => { describe('isSRGB', () => { it('should return true for srgb colorspace', () => { - expect(sut.isSRGB({ colorspace: 'sRGB' } as Exif)).toEqual(true); + expect(sut.isSRGB({ colorspace: 'sRGB' } as ShallowDehydrateObject)).toEqual(true); }); it('should return true for srgb profile description', () => { - expect(sut.isSRGB({ profileDescription: 'sRGB v1.31' } as Exif)).toEqual(true); + expect(sut.isSRGB({ profileDescription: 'sRGB v1.31' } as ShallowDehydrateObject)).toEqual(true); }); it('should return true for 8-bit image with no colorspace metadata', () => { - expect(sut.isSRGB({ bitsPerSample: 8 } as Exif)).toEqual(true); + expect(sut.isSRGB({ bitsPerSample: 8 } as ShallowDehydrateObject)).toEqual(true); }); it('should return true for image with no colorspace or bit depth metadata', () => { @@ -3634,23 +3640,25 @@ describe(MediaService.name, () => { }); it('should return false for non-srgb colorspace', () => { - expect(sut.isSRGB({ colorspace: 'Adobe RGB' } as Exif)).toEqual(false); + expect(sut.isSRGB({ colorspace: 'Adobe RGB' } as ShallowDehydrateObject)).toEqual(false); }); it('should return false for non-srgb profile description', () => { - expect(sut.isSRGB({ profileDescription: 'sP3C' } as Exif)).toEqual(false); + expect(sut.isSRGB({ profileDescription: 'sP3C' } as ShallowDehydrateObject)).toEqual(false); }); it('should return false for 16-bit image with no colorspace metadata', () => { - expect(sut.isSRGB({ bitsPerSample: 16 } as Exif)).toEqual(false); + expect(sut.isSRGB({ bitsPerSample: 16 } as ShallowDehydrateObject)).toEqual(false); }); it('should return true for 16-bit image with sRGB colorspace', () => { - expect(sut.isSRGB({ colorspace: 'sRGB', bitsPerSample: 16 } as Exif)).toEqual(true); + expect(sut.isSRGB({ colorspace: 'sRGB', bitsPerSample: 16 } as ShallowDehydrateObject)).toEqual(true); }); it('should return true for 16-bit image with sRGB profile', () => { - expect(sut.isSRGB({ profileDescription: 'sRGB', bitsPerSample: 16 } as Exif)).toEqual(true); + expect(sut.isSRGB({ profileDescription: 'sRGB', bitsPerSample: 16 } as ShallowDehydrateObject)).toEqual( + true, + ); }); }); @@ -4271,13 +4279,14 @@ describe(MediaService.name, () => { describe('handleVideoConversion - delete existing encoded video', () => { it('should delete existing encoded video when transcoding is no longer required', async () => { - const asset = AssetFactory.create({ + const asset = AssetFactory.from({ id: 'video-id', type: AssetType.Video, originalPath: '/original/path.ext', - encodedVideoPath: '/encoded/path.mp4', - }); - mocks.assetJob.getForVideoConversion.mockResolvedValue(asset); + }) + .file({ type: AssetFileType.EncodedVideo, path: '/encoded/path.mp4' }) + .build(); + mocks.assetJob.getForVideoConversion.mockResolvedValue(asset as any); sut.videoInterfaces = { dri: ['renderD128'], mali: true }; mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); @@ -4295,7 +4304,9 @@ describe(MediaService.name, () => { name: JobName.FileDelete, data: { files: ['/encoded/path.mp4'] }, }); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'video-id', encodedVideoPath: null }); + expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([ + expect.objectContaining({ type: AssetFileType.EncodedVideo, path: '/encoded/path.mp4' }), + ]); }); }); @@ -4335,7 +4346,7 @@ describe(MediaService.name, () => { const asset = AssetFactory.from({ type: AssetType.Other as any }) .exif() .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset as any); await expect(sut.handleGenerateThumbnails({ id: asset.id })).resolves.toBe(JobStatus.Skipped); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); @@ -4343,7 +4354,7 @@ describe(MediaService.name, () => { it('should skip hidden assets', async () => { const asset = AssetFactory.from({ visibility: AssetVisibility.Hidden }).exif().build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset as any); await expect(sut.handleGenerateThumbnails({ id: asset.id })).resolves.toBe(JobStatus.Skipped); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 2cd06c662e3db..d1f5d2b7eed63 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -6,7 +6,7 @@ import { DiskStorageBackend } from 'src/backends/disk-storage.backend'; import { SystemConfig } from 'src/config'; import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core'; -import { AssetFile, Exif } from 'src/database'; +import { AssetFile } from 'src/database'; import { OnEvent, OnJob } from 'src/decorators'; import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; @@ -44,7 +44,7 @@ import { VideoInterfaces, VideoStreamInfo, } from 'src/types'; -import { getDimensions } from 'src/utils/asset.util'; +import { getAssetFile, getDimensions } from 'src/utils/asset.util'; import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; @@ -343,7 +343,7 @@ export class MediaService extends BaseService { return extracted; } - private async decodeImage(thumbSource: string | Buffer, exifInfo: Exif, targetSize?: number) { + private async decodeImage(thumbSource: string | Buffer, exifInfo: ThumbnailAsset['exifInfo'], targetSize?: number) { const { image } = await this.getConfig({ withCache: true }); const colorspace = this.isSRGB(exifInfo) ? Colorspace.Srgb : image.colorspace; const decodeOptions: DecodeToBufferOptions = { @@ -725,10 +725,11 @@ export class MediaService extends BaseService { let { ffmpeg } = await this.getConfig({ withCache: true }); const target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream); if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) { - if (asset.encodedVideoPath) { + const encodedVideo = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false }); + if (encodedVideo) { this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`); - await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [asset.encodedVideoPath] } }); - await this.assetRepository.update({ id: asset.id, encodedVideoPath: null }); + await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [encodedVideo.path] } }); + await this.assetRepository.deleteFiles([encodedVideo]); } else { this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`); } @@ -784,7 +785,12 @@ export class MediaService extends BaseService { const relativeKey = StorageCore.getRelativeEncodedVideoPath(asset); const finalPath = await this.persistFile(output, relativeKey, 'video/mp4'); - await this.assetRepository.update({ id: asset.id, encodedVideoPath: finalPath }); + await this.assetRepository.upsertFile({ + assetId: asset.id, + type: AssetFileType.EncodedVideo, + path: finalPath, + isEdited: false, + }); return JobStatus.Success; } finally { @@ -885,7 +891,15 @@ export class MediaService extends BaseService { return name !== VideoContainer.Mp4 && !ffmpegConfig.acceptedContainers.includes(name); } - isSRGB({ colorspace, profileDescription, bitsPerSample }: Exif): boolean { + isSRGB({ + colorspace, + profileDescription, + bitsPerSample, + }: { + colorspace: string | null; + profileDescription: string | null; + bitsPerSample: number | null; + }): boolean { if (colorspace || profileDescription) { return [colorspace, profileDescription].some((s) => s?.toLowerCase().includes('srgb')); } else if (bitsPerSample) { diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index 4089c2bd8460a..9a694ff4f4a57 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -4,6 +4,7 @@ import { MemoryService } from 'src/services/memory.service'; import { OnThisDayData } from 'src/types'; import { AssetFactory } from 'test/factories/asset.factory'; import { MemoryFactory } from 'test/factories/memory.factory'; +import { getForMemory } from 'test/mappers'; import { factory, newUuid, newUuids } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -59,8 +60,8 @@ describe(MemoryService.name, () => { const asset = AssetFactory.create({ ownerId: user.id }); mocks.user.getList.mockResolvedValue([user]); mocks.systemMetadata.get.mockResolvedValue(null); - mocks.asset.getByDayOfYear.mockResolvedValue([{ year: 2023, assets: [asset] }]); - mocks.memory.create.mockResolvedValue(MemoryFactory.create()); + mocks.asset.getByDayOfYear.mockResolvedValue([{ year: 2023, assets: [asset] }] as any); + mocks.memory.create.mockResolvedValue(MemoryFactory.create() as any); await sut.onMemoriesCreate(); @@ -88,7 +89,7 @@ describe(MemoryService.name, () => { const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build(); const memory2 = MemoryFactory.create({ ownerId: userId }); - mocks.memory.search.mockResolvedValue([memory1, memory2]); + mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]); await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual( expect.arrayContaining([ @@ -147,7 +148,7 @@ describe(MemoryService.name, () => { const userId = newUuid(); const memory = MemoryFactory.create({ ownerId: userId }); - mocks.memory.get.mockResolvedValue(memory); + mocks.memory.get.mockResolvedValue(getForMemory(memory)); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); await expect(sut.get(factory.auth({ user: { id: userId } }), memory.id)).resolves.toMatchObject({ @@ -164,7 +165,7 @@ describe(MemoryService.name, () => { const [assetId, userId] = newUuids(); const memory = MemoryFactory.create({ ownerId: userId }); - mocks.memory.create.mockResolvedValue(memory); + mocks.memory.create.mockResolvedValue(getForMemory(memory)); await expect( sut.create(factory.auth({ user: { id: userId } }), { @@ -194,7 +195,7 @@ describe(MemoryService.name, () => { const memory = MemoryFactory.from().asset(asset).build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.memory.create.mockResolvedValue(memory); + mocks.memory.create.mockResolvedValue(getForMemory(memory)); await expect( sut.create(factory.auth({ user: { id: userId } }), { @@ -214,7 +215,7 @@ describe(MemoryService.name, () => { it('should create a memory without assets', async () => { const memory = MemoryFactory.create(); - mocks.memory.create.mockResolvedValue(memory); + mocks.memory.create.mockResolvedValue(getForMemory(memory)); await expect( sut.create(factory.auth(), { @@ -232,7 +233,7 @@ describe(MemoryService.name, () => { const hideAt = new Date(); const seenAt = new Date(); - mocks.memory.create.mockResolvedValue(memory); + mocks.memory.create.mockResolvedValue(memory as any); await sut.create(factory.auth({ user: { id: userId } }), { type: memory.type, @@ -270,7 +271,7 @@ describe(MemoryService.name, () => { const memory = MemoryFactory.create(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); - mocks.memory.update.mockResolvedValue(memory); + mocks.memory.update.mockResolvedValue(getForMemory(memory)); await expect(sut.update(factory.auth(), memory.id, { isSaved: true })).resolves.toBeDefined(); @@ -282,7 +283,7 @@ describe(MemoryService.name, () => { const seenAt = new Date(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); - mocks.memory.update.mockResolvedValue(memory); + mocks.memory.update.mockResolvedValue(memory as any); await sut.update(factory.auth(), memory.id, { seenAt }); @@ -294,7 +295,7 @@ describe(MemoryService.name, () => { const memoryAt = new Date(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); - mocks.memory.update.mockResolvedValue(memory); + mocks.memory.update.mockResolvedValue(memory as any); await sut.update(factory.auth(), memory.id, { memoryAt }); @@ -337,7 +338,7 @@ describe(MemoryService.name, () => { const memory = MemoryFactory.create(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); - mocks.memory.get.mockResolvedValue(memory); + mocks.memory.get.mockResolvedValue(getForMemory(memory)); mocks.memory.getAssetIds.mockResolvedValue(new Set()); await expect(sut.addAssets(factory.auth(), memory.id, { ids: [assetId] })).resolves.toEqual([ @@ -352,7 +353,7 @@ describe(MemoryService.name, () => { const memory = MemoryFactory.from().asset(asset).build(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); - mocks.memory.get.mockResolvedValue(memory); + mocks.memory.get.mockResolvedValue(getForMemory(memory)); mocks.memory.getAssetIds.mockResolvedValue(new Set([asset.id])); await expect(sut.addAssets(factory.auth(), memory.id, { ids: [asset.id] })).resolves.toEqual([ @@ -368,8 +369,8 @@ describe(MemoryService.name, () => { mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId])); - mocks.memory.get.mockResolvedValue(memory); - mocks.memory.update.mockResolvedValue(memory); + mocks.memory.get.mockResolvedValue(getForMemory(memory)); + mocks.memory.update.mockResolvedValue(getForMemory(memory)); mocks.memory.getAssetIds.mockResolvedValue(new Set()); mocks.memory.addAssetIds.mockResolvedValue(); @@ -386,8 +387,8 @@ describe(MemoryService.name, () => { mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId])); - mocks.memory.get.mockResolvedValue(memory); - mocks.memory.update.mockResolvedValue(memory); + mocks.memory.get.mockResolvedValue(memory as any); + mocks.memory.update.mockResolvedValue(memory as any); mocks.memory.getAssetIds.mockResolvedValue(new Set()); mocks.memory.addAssetIds.mockResolvedValue(); @@ -401,7 +402,7 @@ describe(MemoryService.name, () => { const memory = MemoryFactory.from().asset(asset).build(); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); - mocks.memory.get.mockResolvedValue(memory); + mocks.memory.get.mockResolvedValue(memory as any); mocks.memory.getAssetIds.mockResolvedValue(new Set([asset.id])); await sut.addAssets(factory.auth(), memory.id, { ids: [asset.id] }); @@ -438,7 +439,7 @@ describe(MemoryService.name, () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.memory.getAssetIds.mockResolvedValue(new Set([asset.id])); mocks.memory.removeAssetIds.mockResolvedValue(); - mocks.memory.update.mockResolvedValue(memory); + mocks.memory.update.mockResolvedValue(getForMemory(memory)); await expect(sut.removeAssets(factory.auth(), memory.id, { ids: [asset.id] })).resolves.toEqual([ { id: asset.id, success: true }, @@ -455,7 +456,7 @@ describe(MemoryService.name, () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.memory.getAssetIds.mockResolvedValue(new Set([asset.id])); mocks.memory.removeAssetIds.mockResolvedValue(); - mocks.memory.update.mockResolvedValue(memory); + mocks.memory.update.mockResolvedValue(memory as any); await sut.removeAssets(factory.auth(), memory.id, { ids: [asset.id] }); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 5794eed16b2da..5b2f64b612673 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -21,6 +21,7 @@ import { AssetFactory } from 'test/factories/asset.factory'; import { PersonFactory } from 'test/factories/person.factory'; import { probeStub } from 'test/fixtures/media.stub'; import { tagStub } from 'test/fixtures/tag.stub'; +import { getForMetadataExtraction, getForSidecarWrite } from 'test/mappers'; import { factory } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -189,7 +190,7 @@ describe(MetadataService.name, () => { const originalDate = new Date('2023-11-21T16:13:17.517Z'); const sidecarDate = new Date('2022-01-01T00:00:00.000Z'); const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).build(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -211,7 +212,7 @@ describe(MetadataService.name, () => { const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z'); const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: fileModifiedAt, @@ -241,7 +242,7 @@ describe(MetadataService.name, () => { const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z'); const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: fileModifiedAt, @@ -270,7 +271,7 @@ describe(MetadataService.name, () => { it('should determine dateTimeOriginal regardless of the server time zone', async () => { process.env.TZ = 'America/Los_Angeles'; const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -290,7 +291,7 @@ describe(MetadataService.name, () => { it('should handle lists of numbers', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: asset.fileModifiedAt, @@ -318,7 +319,7 @@ describe(MetadataService.name, () => { it('should not delete latituide and longitude without reverse geocode', async () => { // regression test for issue 17511 const asset = AssetFactory.from().exif().build(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: false } }); mocks.storage.stat.mockResolvedValue({ size: 123_456, @@ -350,7 +351,7 @@ describe(MetadataService.name, () => { it('should apply reverse geocoding', async () => { const asset = AssetFactory.from().exif({ latitude: 10, longitude: 20 }).build(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); mocks.storage.stat.mockResolvedValue({ @@ -380,7 +381,7 @@ describe(MetadataService.name, () => { it('should discard latitude and longitude on null island', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ GPSLatitude: 0, GPSLongitude: 0, @@ -396,7 +397,7 @@ describe(MetadataService.name, () => { it('should extract tags from TagsList', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] }); mockReadTags({ TagsList: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -408,7 +409,7 @@ describe(MetadataService.name, () => { it('should extract hierarchy from TagsList', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child'] }); mockReadTags({ TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); @@ -430,7 +431,7 @@ describe(MetadataService.name, () => { it('should extract tags from Keywords as a string', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] }); mockReadTags({ Keywords: 'Parent' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -442,7 +443,7 @@ describe(MetadataService.name, () => { it('should extract tags from Keywords as a list', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] }); mockReadTags({ Keywords: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -454,7 +455,7 @@ describe(MetadataService.name, () => { it('should extract tags from Keywords as a list with a number', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent', '2024'] }); mockReadTags({ Keywords: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -467,7 +468,7 @@ describe(MetadataService.name, () => { it('should extract hierarchal tags from Keywords', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child'] }); mockReadTags({ Keywords: 'Parent/Child' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -487,7 +488,7 @@ describe(MetadataService.name, () => { it('should ignore Keywords when TagsList is present', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'Child'] }); mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -508,7 +509,7 @@ describe(MetadataService.name, () => { it('should extract hierarchy from HierarchicalSubject', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'TagA'] }); mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); @@ -535,7 +536,7 @@ describe(MetadataService.name, () => { it('should extract tags from HierarchicalSubject as a list with a number', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent', '2024'] }); mockReadTags({ HierarchicalSubject: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -548,7 +549,7 @@ describe(MetadataService.name, () => { it('should extract ignore / characters in a HierarchicalSubject tag', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Mom|Dad'] }); mockReadTags({ HierarchicalSubject: ['Mom/Dad'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); @@ -564,7 +565,7 @@ describe(MetadataService.name, () => { it('should ignore HierarchicalSubject when TagsList is present', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'Parent2/Child2'] }); mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -585,7 +586,7 @@ describe(MetadataService.name, () => { it('should remove existing tags', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({}); await sut.handleMetadataExtraction({ id: asset.id }); @@ -595,7 +596,7 @@ describe(MetadataService.name, () => { it('should not apply motion photos if asset is video', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); await sut.handleMetadataExtraction({ id: asset.id }); @@ -610,7 +611,7 @@ describe(MetadataService.name, () => { it('should handle an invalid Directory Item', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ MotionPhoto: 1, ContainerDirectory: [{ Foo: 100 }], @@ -621,7 +622,7 @@ describe(MetadataService.name, () => { it('should extract the correct video orientation', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); mockReadTags({}); @@ -636,7 +637,7 @@ describe(MetadataService.name, () => { it('should extract Rotate90CW orientation for video with rotation -90', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset as any); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, videoStreams: [{ ...probeStub.videoStreamH264.videoStreams[0], rotation: -90 }], @@ -653,7 +654,7 @@ describe(MetadataService.name, () => { it('should extract Horizontal orientation for video with rotation 0', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset as any); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, videoStreams: [{ ...probeStub.videoStreamH264.videoStreams[0], rotation: 0 }], @@ -670,7 +671,7 @@ describe(MetadataService.name, () => { it('should extract Rotate180 orientation for video with rotation 180', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset as any); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, videoStreams: [{ ...probeStub.videoStreamH264.videoStreams[0], rotation: 180 }], @@ -687,7 +688,7 @@ describe(MetadataService.name, () => { it('should not set orientation for video with unsupported rotation value', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset as any); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, videoStreams: [{ ...probeStub.videoStreamH264.videoStreams[0], rotation: 45 }], @@ -703,7 +704,7 @@ describe(MetadataService.name, () => { it('should extract video dimensions from probe', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset as any); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, videoStreams: [{ ...probeStub.videoStreamH264.videoStreams[0], width: 3840, height: 2160 }], @@ -720,7 +721,7 @@ describe(MetadataService.name, () => { it('should handle video with no video streams', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset as any); mocks.media.probe.mockResolvedValue(probeStub.noVideoStreams); mockReadTags({}); @@ -734,7 +735,7 @@ describe(MetadataService.name, () => { it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => { const asset = AssetFactory.create(); const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: asset.fileModifiedAt, @@ -796,7 +797,7 @@ describe(MetadataService.name, () => { mtimeMs: asset.fileModifiedAt.valueOf(), birthtimeMs: asset.fileCreatedAt.valueOf(), } as Stats); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Directory: 'foo/bar/', EmbeddedVideoFile: new BinaryField(0, ''), @@ -843,7 +844,7 @@ describe(MetadataService.name, () => { it('should extract the motion photo video from the XMP directory entry ', async () => { const asset = AssetFactory.create(); const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: asset.fileModifiedAt, @@ -896,7 +897,7 @@ describe(MetadataService.name, () => { it('should delete old motion photo video assets if they do not match what is extracted', async () => { const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden }); const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -918,7 +919,7 @@ describe(MetadataService.name, () => { it('should not create a new motion photo video asset if the hash of the extracted video matches an existing asset', async () => { const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden }); const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -942,7 +943,7 @@ describe(MetadataService.name, () => { it('should link and hide motion video asset to still asset if the hash of the extracted video matches an existing asset', async () => { const motionAsset = AssetFactory.create({ type: AssetType.Video }); const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -969,7 +970,7 @@ describe(MetadataService.name, () => { it('should not update storage usage if motion photo is external', async () => { const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden }); const asset = AssetFactory.create({ isExternal: true }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Directory: 'foo/bar/', MotionPhoto: 1, @@ -1014,7 +1015,7 @@ describe(MetadataService.name, () => { Rating: 3, }; - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags(tags); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1079,7 +1080,7 @@ describe(MetadataService.name, () => { DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'), zone: undefined, }; - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags(tags); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1094,7 +1095,7 @@ describe(MetadataService.name, () => { it('should extract duration', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -1117,7 +1118,7 @@ describe(MetadataService.name, () => { it('should only extract duration for videos', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -1139,7 +1140,7 @@ describe(MetadataService.name, () => { it('should omit duration of zero', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -1162,7 +1163,7 @@ describe(MetadataService.name, () => { it('should a handle duration of 1 week', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -1185,7 +1186,7 @@ describe(MetadataService.name, () => { it('should handle string Duration from exif', async () => { const asset = AssetFactory.create({ originalFileName: 'file.webp' }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset as any); mockReadTags({ Duration: '01:30:00.000' }, {}); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1196,7 +1197,7 @@ describe(MetadataService.name, () => { it('should use Duration from exif', async () => { const asset = AssetFactory.create({ originalFileName: 'file.webp' }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Duration: 123 }, {}); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1207,7 +1208,7 @@ describe(MetadataService.name, () => { it('should prefer Duration from exif over sidecar', async () => { const asset = AssetFactory.from({ originalFileName: 'file.webp' }).file({ type: AssetFileType.Sidecar }).build(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Duration: 123 }, { Duration: 456 }); @@ -1219,7 +1220,7 @@ describe(MetadataService.name, () => { it('should ignore all Duration tags for definitely static images', async () => { const asset = AssetFactory.from({ originalFileName: 'file.dng' }).build(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Duration: 123 }, { Duration: 456 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1230,7 +1231,7 @@ describe(MetadataService.name, () => { it('should ignore Duration from exif for videos', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Duration: 123 }, {}); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, @@ -1248,7 +1249,7 @@ describe(MetadataService.name, () => { it('should trim whitespace from description', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Description: '\t \v \f \n \r' }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1271,7 +1272,7 @@ describe(MetadataService.name, () => { it('should handle a numeric description', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Description: 1000 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1285,7 +1286,7 @@ describe(MetadataService.name, () => { it('should skip importing metadata when the feature is disabled', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } }); mockReadTags(makeFaceTags({ Name: 'Person 1' })); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1294,7 +1295,7 @@ describe(MetadataService.name, () => { it('should skip importing metadata face for assets without tags.RegionInfo', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1303,7 +1304,7 @@ describe(MetadataService.name, () => { it('should skip importing faces without name', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags()); mocks.person.getDistinctNames.mockResolvedValue([]); @@ -1316,7 +1317,7 @@ describe(MetadataService.name, () => { it('should skip importing faces with empty name', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: '' })); mocks.person.getDistinctNames.mockResolvedValue([]); @@ -1331,7 +1332,7 @@ describe(MetadataService.name, () => { const asset = AssetFactory.create(); const person = PersonFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: person.name })); mocks.person.getDistinctNames.mockResolvedValue([]); @@ -1373,7 +1374,7 @@ describe(MetadataService.name, () => { const asset = AssetFactory.from().face({ id: 'face-1', sourceType: SourceType.Exif }).build(); const person = PersonFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset as any); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: person.name })); mocks.person.getDistinctNames.mockResolvedValue([{ id: person.id, name: person.name }]); @@ -1388,7 +1389,7 @@ describe(MetadataService.name, () => { const asset = AssetFactory.create(); const person = PersonFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: person.name })); mocks.person.getDistinctNames.mockResolvedValue([{ id: person.id, name: person.name }]); @@ -1475,7 +1476,7 @@ describe(MetadataService.name, () => { const asset = AssetFactory.create(); const person = PersonFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: person.name }, orientation)); mocks.person.getDistinctNames.mockResolvedValue([]); @@ -1519,7 +1520,7 @@ describe(MetadataService.name, () => { it('should convert per-pixel bit depth to per-channel for bitsPerSample', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset as any); mockReadTags({ BitsPerSample: 24 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1530,7 +1531,7 @@ describe(MetadataService.name, () => { it('should parse BitsPerSample from string tag', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset as any); mockReadTags({ BitsPerSample: '12 12 12' as unknown as number }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1542,7 +1543,7 @@ describe(MetadataService.name, () => { it('should use ComponentBitDepth when BitsPerSample is not available', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset as any); mockReadTags({ ComponentBitDepth: 10 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1553,7 +1554,7 @@ describe(MetadataService.name, () => { it('should extract autoStackId from BurstID', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset as any); mockReadTags({ BurstID: 'burst-123' }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1564,7 +1565,7 @@ describe(MetadataService.name, () => { it('should extract autoStackId from BurstUUID when BurstID is not present', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset as any); mockReadTags({ BurstUUID: 'burst-uuid-456' }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1575,7 +1576,7 @@ describe(MetadataService.name, () => { it('should parse ImageSize dimensions from exif tags', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset as any); mockReadTags({ ImageSize: '4000x3000' }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1587,7 +1588,7 @@ describe(MetadataService.name, () => { it('should handle invalid modify date', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ ModifyDate: '00:00:00.000' }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1601,7 +1602,7 @@ describe(MetadataService.name, () => { it('should handle invalid rating value', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Rating: 6 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1615,7 +1616,7 @@ describe(MetadataService.name, () => { it('should handle valid rating value', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Rating: 5 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1629,7 +1630,7 @@ describe(MetadataService.name, () => { it('should handle 0 as unrated -> null', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Rating: 0 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1643,7 +1644,7 @@ describe(MetadataService.name, () => { it('should handle valid negative rating value', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Rating: -1 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1657,7 +1658,7 @@ describe(MetadataService.name, () => { it('should handle livePhotoCID not set', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1672,7 +1673,7 @@ describe(MetadataService.name, () => { it('should handle not finding a match', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ ContentIdentifier: 'CID' }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1694,7 +1695,7 @@ describe(MetadataService.name, () => { it('should link photo and video', async () => { const motionAsset = AssetFactory.create({ type: AssetType.Video }); const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.findLivePhotoMatch.mockResolvedValue(motionAsset); mockReadTags({ ContentIdentifier: 'CID' }); @@ -1722,7 +1723,7 @@ describe(MetadataService.name, () => { it('should notify clients on live photo link', async () => { const motionAsset = AssetFactory.create({ type: AssetType.Video }); const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.findLivePhotoMatch.mockResolvedValue(motionAsset); mockReadTags({ ContentIdentifier: 'CID' }); @@ -1737,7 +1738,7 @@ describe(MetadataService.name, () => { it('should search by libraryId', async () => { const motionAsset = AssetFactory.create({ type: AssetType.Video, libraryId: 'library-id' }); const asset = AssetFactory.create({ libraryId: 'library-id' }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.asset.findLivePhotoMatch.mockResolvedValue(motionAsset); mockReadTags({ ContentIdentifier: 'CID' }); @@ -1772,9 +1773,14 @@ describe(MetadataService.name, () => { expected: { make: '1', model: '2' }, }, { exif: { AndroidMake: '1', AndroidModel: '2' }, expected: { make: '1', model: '2' } }, + { exif: { DeviceManufacturer: '1', DeviceModelName: '2' }, expected: { make: '1', model: '2' } }, + { + exif: { Make: '1', Model: '2', DeviceManufacturer: '3', DeviceModelName: '4' }, + expected: { make: '1', model: '2' }, + }, ])('should read camera make and model $exif -> $expected', async ({ exif, expected }) => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags(exif); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1799,7 +1805,7 @@ describe(MetadataService.name, () => { { exif: { LensID: '' }, expected: null }, ])('should read camera lens information $exif -> $expected', async ({ exif, expected }) => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags(exif); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1813,7 +1819,7 @@ describe(MetadataService.name, () => { it('should properly set width/height for normal images', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ ImageWidth: 1000, ImageHeight: 2000 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1827,7 +1833,7 @@ describe(MetadataService.name, () => { it('should properly swap asset width/height for rotated images', async () => { const asset = AssetFactory.create(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ ImageWidth: 1000, ImageHeight: 2000, Orientation: 6 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1841,7 +1847,7 @@ describe(MetadataService.name, () => { it('should not overwrite existing width/height if they already exist', async () => { const asset = AssetFactory.create({ width: 1920, height: 1080 }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ ImageWidth: 1280, ImageHeight: 720 }); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1972,17 +1978,20 @@ describe(MetadataService.name, () => { it('should skip jobs with no metadata', async () => { mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([]); - const asset = factory.jobAssets.sidecarWrite(); - mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset); + const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).exif().build(); + mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(getForSidecarWrite(asset)); await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Skipped); expect(mocks.metadata.writeTags).not.toHaveBeenCalled(); }); it('should write tags', async () => { - const asset = factory.jobAssets.sidecarWrite(); const description = 'this is a description'; const gps = 12; const date = '2023-11-21T22:56:12.196-06:00'; + const asset = AssetFactory.from() + .file({ type: AssetFileType.Sidecar }) + .exif({ description, dateTimeOriginal: new Date(date), latitude: gps, longitude: gps }) + .build(); mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([ 'description', @@ -1991,7 +2000,7 @@ describe(MetadataService.name, () => { 'dateTimeOriginal', 'timeZone', ]); - mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset); + mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(getForSidecarWrite(asset)); await expect( sut.handleSidecarWrite({ id: asset.id, @@ -2014,22 +2023,22 @@ describe(MetadataService.name, () => { }); it('should write rating', async () => { - const asset = factory.jobAssets.sidecarWrite(); + const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).exif().build(); asset.exifInfo.rating = 4; mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']); - mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset); + mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(getForSidecarWrite(asset)); await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success); expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Rating: 4 }); expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, ['rating']); }); it('should write null rating as 0', async () => { - const asset = factory.jobAssets.sidecarWrite(); + const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).exif().build(); asset.exifInfo.rating = null; mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']); - mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset); + mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(getForSidecarWrite(asset)); await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success); expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Rating: 0 }); expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, ['rating']); @@ -2041,7 +2050,7 @@ describe(MetadataService.name, () => { asset.exifInfo.rating = 3; mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']); - mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset); + mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset as any); await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success); @@ -2058,7 +2067,7 @@ describe(MetadataService.name, () => { asset.exifInfo.tags = ['tag1', 'tag2']; mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['tags']); - mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset); + mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset as any); await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success); @@ -2195,7 +2204,7 @@ describe(MetadataService.name, () => { it('should download S3 asset to temp and use local path for readTags/stat', async () => { const asset = AssetFactory.create({ originalPath: 'upload/user1/ab/cd/file.jpg' }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset as any); mockReadTags(); await sut.handleMetadataExtraction({ id: asset.id }); @@ -2207,7 +2216,7 @@ describe(MetadataService.name, () => { it('should not call downloadToTemp for absolute (disk) paths', async () => { const asset = AssetFactory.create({ originalPath: '/data/library/file.jpg' }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset as any); mockReadTags(); await sut.handleMetadataExtraction({ id: asset.id }); @@ -2221,7 +2230,7 @@ describe(MetadataService.name, () => { const asset = AssetFactory.from({ originalPath: 'upload/user1/ab/cd/file.jpg' }) .file({ type: AssetFileType.Sidecar, path: 'upload/user1/ab/cd/file.jpg.xmp' }) .build(); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset as any); mockBackend.downloadToTemp // eslint-disable-next-line unicorn/no-useless-undefined @@ -2247,7 +2256,7 @@ describe(MetadataService.name, () => { // eslint-disable-next-line unicorn/no-useless-undefined const cleanupOriginal = vi.fn().mockResolvedValue(undefined); const asset = AssetFactory.create({ originalPath: 'upload/user1/ab/cd/file.jpg' }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset as any); mockBackend.downloadToTemp.mockResolvedValue({ tempPath: s3TempPath, cleanup: cleanupOriginal }); mockReadTags(); @@ -2258,7 +2267,7 @@ describe(MetadataService.name, () => { it('should use local temp path for video probe on S3 video assets', async () => { const asset = AssetFactory.create({ originalPath: 'upload/user1/ab/cd/video.mp4', type: AssetType.Video }); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset as any); mockBackend.downloadToTemp.mockResolvedValue({ tempPath: s3TempPath, // eslint-disable-next-line unicorn/no-useless-undefined @@ -2354,7 +2363,7 @@ describe(MetadataService.name, () => { mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']); asset.exifInfo.rating = 3; - mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset); + mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset as any); await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success); @@ -2373,7 +2382,7 @@ describe(MetadataService.name, () => { asset.exifInfo.rating = 4; mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']); - mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset); + mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset as any); await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 4f4448b64e026..8b2af6c4ba9b4 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -8,7 +8,7 @@ import { constants } from 'node:fs/promises'; import { isAbsolute, join, parse } from 'node:path'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { Asset, AssetFace, AssetFile } from 'src/database'; +import { Asset, AssetFile } from 'src/database'; import { OnEvent, OnJob } from 'src/decorators'; import { AssetFileType, @@ -314,8 +314,13 @@ export class MetadataService extends BaseService { colorspace: exifTags.ColorSpace === undefined ? null : String(exifTags.ColorSpace), // camera - make: exifTags.Make ?? exifTags.Device?.Manufacturer ?? exifTags.AndroidMake ?? null, - model: exifTags.Model ?? exifTags.Device?.ModelName ?? exifTags.AndroidModel ?? null, + make: + exifTags.Make ?? + exifTags.Device?.Manufacturer ?? + exifTags.AndroidMake ?? + (exifTags.DeviceManufacturer || null), + model: + exifTags.Model ?? exifTags.Device?.ModelName ?? exifTags.AndroidModel ?? (exifTags.DeviceModelName || null), fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)), iso: validate(exifTags.ISO) as number, exposureTime: exifTags.ExposureTime ?? null, @@ -484,8 +489,7 @@ export class MetadataService extends BaseService { const { description, dateTimeOriginal, latitude, longitude, rating, tags, timeZone } = _.pick( { description: asset.exifInfo.description, - // the kysely type is wrong here; fixed in 0.28.3 - dateTimeOriginal: asset.exifInfo.dateTimeOriginal as string | null, + dateTimeOriginal: asset.exifInfo.dateTimeOriginal, latitude: asset.exifInfo.latitude, longitude: asset.exifInfo.longitude, rating: asset.exifInfo.rating ?? 0, @@ -913,7 +917,7 @@ export class MetadataService extends BaseService { } private async applyTaggedFaces( - asset: { id: string; ownerId: string; faces: AssetFace[]; originalPath: string }, + asset: { id: string; ownerId: string; faces: { id: string; sourceType: SourceType }[]; originalPath: string }, tags: ImmichTags, ) { if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) { diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 4e33a68c66e58..158c1382f2180 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -10,6 +10,7 @@ import { AssetFactory } from 'test/factories/asset.factory'; import { UserFactory } from 'test/factories/user.factory'; import { notificationStub } from 'test/fixtures/notification.stub'; import { userStub } from 'test/fixtures/user.stub'; +import { getForAlbum } from 'test/mappers'; import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -269,14 +270,14 @@ describe(NotificationService.name, () => { }); it('should skip if recipient could not be found', async () => { - mocks.album.getById.mockResolvedValue(AlbumFactory.create()); + mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create())); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped); expect(mocks.job.queue).not.toHaveBeenCalled(); }); it('should skip if the recipient has email notifications disabled', async () => { - mocks.album.getById.mockResolvedValue(AlbumFactory.create()); + mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create())); mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ @@ -292,7 +293,7 @@ describe(NotificationService.name, () => { }); it('should skip if the recipient has email notifications for album invite disabled', async () => { - mocks.album.getById.mockResolvedValue(AlbumFactory.create()); + mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create())); mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ @@ -308,7 +309,7 @@ describe(NotificationService.name, () => { }); it('should send invite email', async () => { - mocks.album.getById.mockResolvedValue(AlbumFactory.create()); + mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create())); mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ @@ -331,7 +332,7 @@ describe(NotificationService.name, () => { it('should send invite email without album thumbnail if thumbnail asset does not exist', async () => { const album = AlbumFactory.create({ albumThumbnailAssetId: newUuid() }); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ @@ -363,7 +364,7 @@ describe(NotificationService.name, () => { it('should send invite email with album thumbnail as jpeg', async () => { const assetFile = AssetFileFactory.create({ type: AssetFileType.Thumbnail }); const album = AlbumFactory.create({ albumThumbnailAssetId: assetFile.assetId }); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ @@ -394,8 +395,10 @@ describe(NotificationService.name, () => { it('should send invite email with album thumbnail and arbitrary extension', async () => { const asset = AssetFactory.from().file({ type: AssetFileType.Thumbnail }).build(); - const album = AlbumFactory.from({ albumThumbnailAssetId: asset.id }).asset(asset).build(); - mocks.album.getById.mockResolvedValue(album); + const album = AlbumFactory.from({ albumThumbnailAssetId: asset.id }) + .asset(asset, (builder) => builder.exif()) + .build(); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue({ ...userStub.user1, metadata: [ @@ -432,7 +435,7 @@ describe(NotificationService.name, () => { }); it('should skip if owner could not be found', async () => { - mocks.album.getById.mockResolvedValue(AlbumFactory.create({ ownerId: 'non-existent' })); + mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create({ ownerId: 'non-existent' }))); await expect(sut.handleAlbumUpdate({ id: '', recipientId: '1' })).resolves.toBe(JobStatus.Skipped); expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); @@ -440,7 +443,7 @@ describe(NotificationService.name, () => { it('should skip recipient that could not be looked up', async () => { const album = AlbumFactory.from().albumUser({ userId: 'non-existent' }).build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValueOnce(album.owner); mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); @@ -459,7 +462,7 @@ describe(NotificationService.name, () => { }) .build(); const album = AlbumFactory.from().albumUser({ userId: user.id }).build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue(user); mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); @@ -478,7 +481,7 @@ describe(NotificationService.name, () => { }) .build(); const album = AlbumFactory.from().albumUser({ userId: user.id }).build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue(user); mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); @@ -492,7 +495,7 @@ describe(NotificationService.name, () => { it('should send email', async () => { const user = UserFactory.create(); const album = AlbumFactory.from().albumUser({ userId: user.id }).build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue(user); mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); @@ -781,7 +784,7 @@ describe(NotificationService.name, () => { const album = AlbumFactory.from({ albumThumbnailAssetId: assetFile.assetId }) .albumUser({ userId: user.id }) .build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(album as any); mocks.user.get.mockResolvedValue(user); mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); @@ -800,7 +803,7 @@ describe(NotificationService.name, () => { it('should send email without thumbnail when no albumThumbnailAssetId', async () => { const user = UserFactory.create(); const album = AlbumFactory.from({ albumThumbnailAssetId: null }).albumUser({ userId: user.id }).build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(album as any); mocks.user.get.mockResolvedValue(user); mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); @@ -822,7 +825,7 @@ describe(NotificationService.name, () => { const album = AlbumFactory.from({ albumThumbnailAssetId: editedFile.assetId }) .albumUser({ userId: user.id }) .build(); - mocks.album.getById.mockResolvedValue(album); + mocks.album.getById.mockResolvedValue(album as any); mocks.user.get.mockResolvedValue(user); mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); diff --git a/server/src/services/partner.service.spec.ts b/server/src/services/partner.service.spec.ts index db057a453a500..029462a865d58 100644 --- a/server/src/services/partner.service.spec.ts +++ b/server/src/services/partner.service.spec.ts @@ -1,7 +1,10 @@ import { BadRequestException } from '@nestjs/common'; import { PartnerDirection } from 'src/repositories/partner.repository'; import { PartnerService } from 'src/services/partner.service'; -import { factory } from 'test/small.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; +import { UserFactory } from 'test/factories/user.factory'; +import { getForPartner } from 'test/mappers'; import { newTestService, ServiceMocks } from 'test/utils'; describe(PartnerService.name, () => { @@ -18,26 +21,26 @@ describe(PartnerService.name, () => { describe('search', () => { it("should return a list of partners with whom I've shared my library", async () => { - const user1 = factory.user(); - const user2 = factory.user(); - const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 }); - const sharedWithUser1 = factory.partner({ sharedBy: user2, sharedWith: user1 }); - const auth = factory.auth({ user: { id: user1.id } }); + const user1 = UserFactory.create(); + const user2 = UserFactory.create(); + const sharedWithUser2 = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const sharedWithUser1 = PartnerFactory.from().sharedBy(user2).sharedWith(user1).build(); + const auth = AuthFactory.create({ id: user1.id }); - mocks.partner.getAll.mockResolvedValue([sharedWithUser1, sharedWithUser2]); + mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]); await expect(sut.search(auth, { direction: PartnerDirection.SharedBy })).resolves.toBeDefined(); expect(mocks.partner.getAll).toHaveBeenCalledWith(user1.id); }); it('should return a list of partners who have shared their libraries with me', async () => { - const user1 = factory.user(); - const user2 = factory.user(); - const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 }); - const sharedWithUser1 = factory.partner({ sharedBy: user2, sharedWith: user1 }); - const auth = factory.auth({ user: { id: user1.id } }); + const user1 = UserFactory.create(); + const user2 = UserFactory.create(); + const sharedWithUser2 = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const sharedWithUser1 = PartnerFactory.from().sharedBy(user2).sharedWith(user1).build(); + const auth = AuthFactory.create({ id: user1.id }); - mocks.partner.getAll.mockResolvedValue([sharedWithUser1, sharedWithUser2]); + mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]); await expect(sut.search(auth, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined(); expect(mocks.partner.getAll).toHaveBeenCalledWith(user1.id); }); @@ -45,13 +48,13 @@ describe(PartnerService.name, () => { describe('create', () => { it('should create a new partner', async () => { - const user1 = factory.user(); - const user2 = factory.user(); - const partner = factory.partner({ sharedBy: user1, sharedWith: user2 }); - const auth = factory.auth({ user: { id: user1.id } }); + const user1 = UserFactory.create(); + const user2 = UserFactory.create(); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.partner.get.mockResolvedValue(void 0); - mocks.partner.create.mockResolvedValue(partner); + mocks.partner.create.mockResolvedValue(getForPartner(partner)); await expect(sut.create(auth, { sharedWithId: user2.id })).resolves.toBeDefined(); @@ -62,12 +65,12 @@ describe(PartnerService.name, () => { }); it('should throw an error when the partner already exists', async () => { - const user1 = factory.user(); - const user2 = factory.user(); - const partner = factory.partner({ sharedBy: user1, sharedWith: user2 }); - const auth = factory.auth({ user: { id: user1.id } }); + const user1 = UserFactory.create(); + const user2 = UserFactory.create(); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); - mocks.partner.get.mockResolvedValue(partner); + mocks.partner.get.mockResolvedValue(getForPartner(partner)); await expect(sut.create(auth, { sharedWithId: user2.id })).rejects.toBeInstanceOf(BadRequestException); @@ -77,12 +80,12 @@ describe(PartnerService.name, () => { describe('remove', () => { it('should remove a partner', async () => { - const user1 = factory.user(); - const user2 = factory.user(); - const partner = factory.partner({ sharedBy: user1, sharedWith: user2 }); - const auth = factory.auth({ user: { id: user1.id } }); + const user1 = UserFactory.create(); + const user2 = UserFactory.create(); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); - mocks.partner.get.mockResolvedValue(partner); + mocks.partner.get.mockResolvedValue(getForPartner(partner)); await sut.remove(auth, user2.id); @@ -90,8 +93,8 @@ describe(PartnerService.name, () => { }); it('should throw an error when the partner does not exist', async () => { - const user2 = factory.user(); - const auth = factory.auth(); + const user2 = UserFactory.create(); + const auth = AuthFactory.create(); mocks.partner.get.mockResolvedValue(void 0); @@ -103,20 +106,20 @@ describe(PartnerService.name, () => { describe('update', () => { it('should require access', async () => { - const user2 = factory.user(); - const auth = factory.auth(); + const user2 = UserFactory.create(); + const auth = AuthFactory.create(); await expect(sut.update(auth, user2.id, { inTimeline: false })).rejects.toBeInstanceOf(BadRequestException); }); it('should update partner', async () => { - const user1 = factory.user(); - const user2 = factory.user(); - const partner = factory.partner({ sharedBy: user1, sharedWith: user2 }); - const auth = factory.auth({ user: { id: user1.id } }); + const user1 = UserFactory.create(); + const user2 = UserFactory.create(); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set([user2.id])); - mocks.partner.update.mockResolvedValue(partner); + mocks.partner.update.mockResolvedValue(getForPartner(partner)); await expect(sut.update(auth, user2.id, { inTimeline: true })).resolves.toBeDefined(); expect(mocks.partner.update).toHaveBeenCalledWith( diff --git a/server/src/services/partner.service.ts b/server/src/services/partner.service.ts index 628efa9d49856..cc950edb5b7b3 100644 --- a/server/src/services/partner.service.ts +++ b/server/src/services/partner.service.ts @@ -49,9 +49,8 @@ export class PartnerService extends BaseService { private mapPartner(partner: Partner, direction: PartnerDirection): PartnerResponseDto { // this is opposite to return the non-me user of the "partner" - const user = mapUser( - direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy, - ) as PartnerResponseDto; + const sharedUser = direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy; + const user = mapUser(sharedUser); return { ...user, inTimeline: partner.inTimeline }; } diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 965e24774cda3..a932a3d6d2bf1 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -22,7 +22,7 @@ import { PersonFactory } from 'test/factories/person.factory'; import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { getAsDetectedFace, getForFacialRecognitionJob } from 'test/mappers'; +import { getAsDetectedFace, getForAssetFace, getForDetectedFaces, getForFacialRecognitionJob } from 'test/mappers'; import { newDate, newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -216,7 +216,7 @@ describe(PersonService.name, () => { mocks.person.update.mockResolvedValue(person); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - await expect(sut.update(auth, person.id, { birthDate: new Date('1976-06-30') })).resolves.toEqual({ + await expect(sut.update(auth, person.id, { birthDate: '1976-06-30' })).resolves.toEqual({ id: person.id, name: person.name, birthDate: '1976-06-30', @@ -226,9 +226,9 @@ describe(PersonService.name, () => { color: undefined, type: 'person', species: null, - updatedAt: expect.any(Date), + updatedAt: expect.any(String), }); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, birthDate: new Date('1976-06-30') }); + expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, birthDate: '1976-06-30' }); expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); @@ -336,7 +336,7 @@ describe(PersonService.name, () => { mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); mocks.person.getById.mockResolvedValue(person); mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); - mocks.person.getFacesByIds.mockResolvedValue([face]); + mocks.person.getFacesByIds.mockResolvedValue([getForAssetFace(face)]); mocks.person.reassignFace.mockResolvedValue(1); mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create()); mocks.person.refreshFaces.mockResolvedValue(); @@ -370,15 +370,17 @@ describe(PersonService.name, () => { const face = AssetFaceFactory.create(); const asset = AssetFactory.from({ id: face.assetId }).exif().build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.person.getFaces.mockResolvedValue([face]); + mocks.person.getFaces.mockResolvedValue([getForAssetFace(face)]); mocks.asset.getForFaces.mockResolvedValue({ edits: [], ...asset.exifInfo }); - await expect(sut.getFacesById(auth, { id: face.assetId })).resolves.toStrictEqual([mapFaces(face, auth)]); + await expect(sut.getFacesById(auth, { id: face.assetId })).resolves.toStrictEqual([ + mapFaces(getForAssetFace(face), auth), + ]); }); it('should reject if the user has not access to the asset', async () => { const face = AssetFaceFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set()); - mocks.person.getFaces.mockResolvedValue([face]); + mocks.person.getFaces.mockResolvedValue([getForAssetFace(face)]); await expect(sut.getFacesById(AuthFactory.create(), { id: face.assetId })).rejects.toBeInstanceOf( BadRequestException, ); @@ -407,7 +409,7 @@ describe(PersonService.name, () => { mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); - mocks.person.getFaceById.mockResolvedValue(face); + mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face)); mocks.person.reassignFace.mockResolvedValue(1); mocks.person.getById.mockResolvedValue(person); await expect(sut.reassignFacesById(AuthFactory.create(), person.id, { id: face.id })).resolves.toEqual({ @@ -420,7 +422,7 @@ describe(PersonService.name, () => { color: undefined, type: 'person', species: null, - updatedAt: expect.any(Date), + updatedAt: expect.any(String), }); expect(mocks.job.queue).not.toHaveBeenCalledWith(); @@ -432,7 +434,7 @@ describe(PersonService.name, () => { const person = PersonFactory.create(); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - mocks.person.getFaceById.mockResolvedValue(face); + mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face)); mocks.person.reassignFace.mockResolvedValue(1); mocks.person.getById.mockResolvedValue(person); await expect( @@ -767,18 +769,18 @@ describe(PersonService.name, () => { }); it('should skip when no resize path', async () => { - const asset = AssetFactory.create(); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + const asset = AssetFactory.from().exif().build(); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset)); await sut.handleDetectFaces({ id: asset.id }); expect(mocks.machineLearning.detectFaces).not.toHaveBeenCalled(); }); it('should handle no results', async () => { const start = Date.now(); - const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).exif().build(); mocks.machineLearning.detectFaces.mockResolvedValue({ imageHeight: 500, imageWidth: 400, faces: [] }); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset)); await sut.handleDetectFaces({ id: asset.id }); expect(mocks.machineLearning.detectFaces).toHaveBeenCalledWith( asset.files[0].path, @@ -796,12 +798,12 @@ describe(PersonService.name, () => { }); it('should create a face with no person and queue recognition job', async () => { - const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).exif().build(); const face = AssetFaceFactory.create({ assetId: asset.id }); mocks.crypto.randomUUID.mockReturnValue(face.id); mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); mocks.search.searchFaces.mockResolvedValue([{ ...face, distance: 0.7 }]); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset)); mocks.person.refreshFaces.mockResolvedValue(); await sut.handleDetectFaces({ id: asset.id }); @@ -820,9 +822,9 @@ describe(PersonService.name, () => { }); it('should delete an existing face not among the new detected faces', async () => { - const asset = AssetFactory.from().face().file({ type: AssetFileType.Preview }).build(); + const asset = AssetFactory.from().face().file({ type: AssetFileType.Preview }).exif().build(); mocks.machineLearning.detectFaces.mockResolvedValue({ faces: [], imageHeight: 500, imageWidth: 400 }); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset)); await sut.handleDetectFaces({ id: asset.id }); @@ -841,9 +843,9 @@ describe(PersonService.name, () => { boundingBoxY1: 200, boundingBoxY2: 300, }); - const asset = AssetFactory.from({ id: assetId }).face().file({ type: AssetFileType.Preview }).build(); + const asset = AssetFactory.from({ id: assetId }).face().file({ type: AssetFileType.Preview }).exif().build(); mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset)); mocks.crypto.randomUUID.mockReturnValue(face.id); mocks.person.refreshFaces.mockResolvedValue(); @@ -864,9 +866,9 @@ describe(PersonService.name, () => { it('should add embedding to matching metadata face', async () => { const face = AssetFaceFactory.create({ sourceType: SourceType.Exif }); - const asset = AssetFactory.from().face(face).file({ type: AssetFileType.Preview }).build(); + const asset = AssetFactory.from().face(face).file({ type: AssetFileType.Preview }).exif().build(); mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset)); mocks.person.refreshFaces.mockResolvedValue(); await sut.handleDetectFaces({ id: asset.id }); @@ -880,9 +882,9 @@ describe(PersonService.name, () => { it('should not add embedding to non-matching metadata face', async () => { const assetId = newUuid(); const face = AssetFaceFactory.create({ assetId, sourceType: SourceType.Exif }); - const asset = AssetFactory.from({ id: assetId }).file({ type: AssetFileType.Preview }).build(); + const asset = AssetFactory.from({ id: assetId }).file({ type: AssetFileType.Preview }).exif().build(); mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset)); mocks.crypto.randomUUID.mockReturnValue(face.id); await sut.handleDetectFaces({ id: asset.id }); @@ -1432,7 +1434,7 @@ describe(PersonService.name, () => { mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); mocks.person.getById.mockResolvedValue(person); mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); - mocks.person.getFacesByIds.mockResolvedValue([face]); + mocks.person.getFacesByIds.mockResolvedValue([face] as any); mocks.person.reassignFace.mockResolvedValue(1); mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create()); mocks.person.update.mockResolvedValue(person); @@ -1461,7 +1463,7 @@ describe(PersonService.name, () => { mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([newPerson.id])); mocks.person.getById.mockResolvedValue(newPerson); mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); - mocks.person.getFacesByIds.mockResolvedValue([face]); + mocks.person.getFacesByIds.mockResolvedValue([face] as any); mocks.person.reassignFace.mockResolvedValue(1); mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create()); mocks.person.update.mockResolvedValue(newPerson); @@ -1481,7 +1483,7 @@ describe(PersonService.name, () => { mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); - mocks.person.getFaceById.mockResolvedValue(face); + mocks.person.getFaceById.mockResolvedValue(face as any); mocks.person.getById.mockResolvedValue(person); mocks.person.reassignFace.mockResolvedValue(1); mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create()); @@ -1502,7 +1504,7 @@ describe(PersonService.name, () => { mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([newPerson.id])); mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); - mocks.person.getFaceById.mockResolvedValue(face); + mocks.person.getFaceById.mockResolvedValue(face as any); mocks.person.getById.mockResolvedValue(newPerson); mocks.person.reassignFace.mockResolvedValue(1); mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create()); @@ -1692,7 +1694,7 @@ describe(PersonService.name, () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset as any); await sut.createFace(auth, { assetId: asset.id, @@ -1785,7 +1787,7 @@ describe(PersonService.name, () => { const asset = AssetFactory.from({ visibility: AssetVisibility.Hidden }) .file({ type: AssetFileType.Preview }) .build(); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset as any); await expect(sut.handleDetectFaces({ id: asset.id })).resolves.toBe(JobStatus.Skipped); expect(mocks.machineLearning.detectFaces).not.toHaveBeenCalled(); @@ -1799,7 +1801,7 @@ describe(PersonService.name, () => { const person = PersonFactory.create({ ownerId: user.id }); const face = AssetFaceFactory.from().person(person).build(); - expect(mapFaces(face, auth)).toEqual({ + expect(mapFaces(getForAssetFace(face), auth)).toEqual({ boundingBoxX1: 100, boundingBoxX2: 200, boundingBoxY1: 100, @@ -1813,11 +1815,13 @@ describe(PersonService.name, () => { }); it('should not map person if person is null', () => { - expect(mapFaces(AssetFaceFactory.create(), AuthFactory.create()).person).toBeNull(); + expect(mapFaces(getForAssetFace(AssetFaceFactory.create()), AuthFactory.create()).person).toBeNull(); }); it('should not map person if person does not match auth user id', () => { - expect(mapFaces(AssetFaceFactory.from().person().build(), AuthFactory.create()).person).toBeNull(); + expect( + mapFaces(getForAssetFace(AssetFaceFactory.from().person().build()), AuthFactory.create()).person, + ).toBeNull(); }); }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index c135622be2dbd..7134a34efd254 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -492,7 +492,7 @@ export class PersonService extends BaseService { embedding: face.faceSearch.embedding, maxDistance: machineLearning.facialRecognition.maxDistance, numResults: machineLearning.facialRecognition.minFaces, - minBirthDate: face.asset.fileCreatedAt ?? undefined, + minBirthDate: new Date(face.asset.fileCreatedAt), }); // `matches` also includes the face itself @@ -520,7 +520,7 @@ export class PersonService extends BaseService { maxDistance: machineLearning.facialRecognition.maxDistance, numResults: 1, hasPerson: true, - minBirthDate: face.asset.fileCreatedAt ?? undefined, + minBirthDate: new Date(face.asset.fileCreatedAt), }); if (matchWithPerson.length > 0) { diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index 1d4ee7e596e3a..c8f81f01d2600 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -6,6 +6,7 @@ import { SearchService } from 'src/services/search.service'; import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; import { authStub } from 'test/fixtures/auth.stub'; +import { getForAsset } from 'test/mappers'; import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; import { beforeEach, vitest } from 'vitest'; @@ -76,7 +77,9 @@ describe(SearchService.name, () => { items: [{ value: 'city', data: asset.id }], }); mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([asset as never]); - const expectedResponse = [{ fieldName: 'exifInfo.city', items: [{ value: 'city', data: mapAsset(asset) }] }]; + const expectedResponse = [ + { fieldName: 'exifInfo.city', items: [{ value: 'city', data: mapAsset(getForAsset(asset)) }] }, + ]; const result = await sut.getExploreData(auth); diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index 8ab13b14a3499..f117a73f47b8c 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -1,6 +1,8 @@ import { BadRequestException } from '@nestjs/common'; import { JobStatus } from 'src/enum'; import { SessionService } from 'src/services/session.service'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { SessionFactory } from 'test/factories/session.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { factory, newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -115,9 +117,9 @@ describe('SessionService', () => { describe('getAll', () => { it('should get the devices', async () => { - const currentSession = factory.session(); - const otherSession = factory.session(); - const auth = factory.auth({ session: currentSession }); + const currentSession = SessionFactory.create(); + const otherSession = SessionFactory.create(); + const auth = AuthFactory.from().session(currentSession).build(); mocks.session.getByUserId.mockResolvedValue([currentSession, otherSession]); diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 97fcb43be6862..4e1905e774cec 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -1,12 +1,14 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto'; +import { mapSharedLink } from 'src/dtos/shared-link.dto'; import { SharedLinkType } from 'src/enum'; import { SharedLinkService } from 'src/services/shared-link.service'; import { AlbumFactory } from 'test/factories/album.factory'; import { AssetFactory } from 'test/factories/asset.factory'; import { SharedLinkFactory } from 'test/factories/shared-link.factory'; import { authStub } from 'test/fixtures/auth.stub'; -import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub'; +import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; +import { getForSharedLink } from 'test/mappers'; import { factory, newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -24,16 +26,18 @@ describe(SharedLinkService.name, () => { describe('getAll', () => { it('should return all shared links for a user', async () => { - mocks.sharedLink.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]); - await expect(sut.getAll(authStub.user1, {})).resolves.toEqual([ - sharedLinkResponseStub.expired, - sharedLinkResponseStub.valid, - ]); + const [sharedLink1, sharedLink2] = [SharedLinkFactory.create(), SharedLinkFactory.create()]; + mocks.sharedLink.getAll.mockResolvedValue([getForSharedLink(sharedLink1), getForSharedLink(sharedLink2)]); + await expect(sut.getAll(authStub.user1, {})).resolves.toEqual( + [getForSharedLink(sharedLink1), getForSharedLink(sharedLink2)].map((link) => + mapSharedLink(link, { stripAssetMetadata: false }), + ), + ); expect(mocks.sharedLink.getAll).toHaveBeenCalledWith({ userId: authStub.user1.user.id }); }); it('should pass id filter to getAll', async () => { - mocks.sharedLink.getAll.mockResolvedValue([sharedLinkStub.valid]); + mocks.sharedLink.getAll.mockResolvedValue([sharedLinkStub.valid] as any); await sut.getAll(authStub.user1, { id: 'link-id' }); @@ -41,7 +45,7 @@ describe(SharedLinkService.name, () => { }); it('should pass albumId filter to getAll', async () => { - mocks.sharedLink.getAll.mockResolvedValue([sharedLinkStub.valid]); + mocks.sharedLink.getAll.mockResolvedValue([sharedLinkStub.valid] as any); await sut.getAll(authStub.user1, { albumId: 'album-id' }); @@ -55,7 +59,7 @@ describe(SharedLinkService.name, () => { }); it('should throw BadRequestException when shared link is not password protected', async () => { - mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.valid, password: null }); + mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.valid, password: null } as any); await expect(sut.login(authStub.adminSharedLink, { password: 'test' })).rejects.toBeInstanceOf( BadRequestException, @@ -63,7 +67,7 @@ describe(SharedLinkService.name, () => { }); it('should throw UnauthorizedException for invalid password', async () => { - mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: 'correct-password' }); + mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: 'correct-password' } as any); await expect(sut.login(authStub.adminSharedLink, { password: 'wrong-password' })).rejects.toBeInstanceOf( UnauthorizedException, @@ -72,7 +76,7 @@ describe(SharedLinkService.name, () => { it('should return shared link and token for valid password', async () => { const sharedLink = { ...sharedLinkStub.individual, password: 'correct-password' }; - mocks.sharedLink.get.mockResolvedValue(sharedLink); + mocks.sharedLink.get.mockResolvedValue(sharedLink as any); mocks.crypto.hashSha256.mockReturnValue(Buffer.from('hashed-token')); const result = await sut.login(authStub.adminSharedLink, { password: 'correct-password' }); @@ -94,8 +98,11 @@ describe(SharedLinkService.name, () => { it('should return the shared link for the public user', async () => { const authDto = authStub.adminSharedLink; - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); - await expect(sut.getMine(authDto, [])).resolves.toEqual(sharedLinkResponseStub.valid); + const sharedLink = SharedLinkFactory.create(); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); + await expect(sut.getMine(authDto, [])).resolves.toEqual( + mapSharedLink(getForSharedLink(sharedLink), { stripAssetMetadata: false }), + ); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); @@ -107,7 +114,13 @@ describe(SharedLinkService.name, () => { allowUpload: true, }, }); - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); + mocks.sharedLink.get.mockResolvedValue( + getForSharedLink( + SharedLinkFactory.from({ showExif: false }) + .asset({}, (builder) => builder.exif()) + .build(), + ), + ); const response = await sut.getMine(authDto, []); expect(response.assets[0]).toMatchObject({ hasMetadata: false }); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); @@ -121,7 +134,8 @@ describe(SharedLinkService.name, () => { }); it('should accept a valid shared link auth token', async () => { - mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' }); + const sharedLink = SharedLinkFactory.create({ password: '123' }); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); const secret = Buffer.from('auth-token-123'); mocks.crypto.hashSha256.mockReturnValue(secret); await expect(sut.getMine(authStub.adminSharedLink, [secret.toString('base64')])).resolves.toBeDefined(); @@ -132,7 +146,7 @@ describe(SharedLinkService.name, () => { }); it('should throw UnauthorizedException for invalid token on password-protected link', async () => { - mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' }); + mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' } as any); const secret = Buffer.from('auth-token-123'); mocks.crypto.hashSha256.mockReturnValue(secret); @@ -153,9 +167,12 @@ describe(SharedLinkService.name, () => { }); it('should get a shared link by id', async () => { - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); - await expect(sut.get(authStub.user1, sharedLinkStub.valid.id)).resolves.toEqual(sharedLinkResponseStub.valid); - expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); + const sharedLink = SharedLinkFactory.create(); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); + await expect(sut.get(authStub.user1, sharedLink.id)).resolves.toEqual( + mapSharedLink(getForSharedLink(sharedLink), { stripAssetMetadata: true }), + ); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLink.id); }); }); @@ -186,8 +203,9 @@ describe(SharedLinkService.name, () => { it('should create an album shared link', async () => { const album = AlbumFactory.from().asset().build(); + const sharedLink = SharedLinkFactory.from().album(album).build(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink)); await sut.create(authStub.admin, { type: SharedLinkType.Album, albumId: album.id }); @@ -208,8 +226,11 @@ describe(SharedLinkService.name, () => { it('should create an individual shared link', async () => { const asset = AssetFactory.create(); + const sharedLink = SharedLinkFactory.from() + .asset(asset, (builder) => builder.exif()) + .build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); + mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink)); await sut.create(authStub.admin, { type: SharedLinkType.Individual, @@ -241,8 +262,11 @@ describe(SharedLinkService.name, () => { it('should create a shared link with allowDownload set to false when showMetadata is false', async () => { const asset = AssetFactory.create(); + const sharedLink = SharedLinkFactory.from({ allowDownload: false }) + .asset(asset, (builder) => builder.exif()) + .build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); + mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink)); await sut.create(authStub.admin, { type: SharedLinkType.Individual, @@ -276,7 +300,7 @@ describe(SharedLinkService.name, () => { const asset = AssetFactory.create(); const expiresAt = new Date('2025-12-31'); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); + mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual as any); await sut.create(authStub.admin, { type: SharedLinkType.Individual, @@ -296,7 +320,7 @@ describe(SharedLinkService.name, () => { it('should create a shared link with a password', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); + mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual as any); await sut.create(authStub.admin, { type: SharedLinkType.Individual, @@ -349,8 +373,9 @@ describe(SharedLinkService.name, () => { }); it('should update a shared link', async () => { - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); - mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid); + const sharedLink = SharedLinkFactory.create(); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); + mocks.sharedLink.update.mockResolvedValue(getForSharedLink(sharedLink)); await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false }); @@ -364,8 +389,8 @@ describe(SharedLinkService.name, () => { }); it('should set expiresAt to null when changeExpiryTime is true and expiresAt is not provided', async () => { - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); - mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid as any); + mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid as any); await sut.update(authStub.user1, sharedLinkStub.valid.id, { changeExpiryTime: true }); @@ -374,8 +399,8 @@ describe(SharedLinkService.name, () => { it('should use provided expiresAt when both changeExpiryTime and expiresAt are set', async () => { const expiresAt = new Date('2025-12-31'); - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); - mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid as any); + mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid as any); await sut.update(authStub.user1, sharedLinkStub.valid.id, { changeExpiryTime: true, @@ -388,8 +413,8 @@ describe(SharedLinkService.name, () => { }); it('should update shared link with showMetadata', async () => { - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); - mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid as any); + mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid as any); await sut.update(authStub.user1, sharedLinkStub.valid.id, { showMetadata: false }); @@ -397,8 +422,8 @@ describe(SharedLinkService.name, () => { }); it('should update shared link with password', async () => { - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); - mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid as any); + mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid as any); await sut.update(authStub.user1, sharedLinkStub.valid.id, { password: 'new-password' }); @@ -406,8 +431,8 @@ describe(SharedLinkService.name, () => { }); it('should update shared link description', async () => { - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); - mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid as any); + mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid as any); await sut.update(authStub.user1, sharedLinkStub.valid.id, { description: 'Updated description' }); @@ -417,7 +442,7 @@ describe(SharedLinkService.name, () => { }); it('should throw BadRequestException for duplicate slug on update', async () => { - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid as any); const error = new Error('duplicate slug'); (error as any).constraint_name = 'shared_link_slug_uq'; mocks.sharedLink.update.mockRejectedValue(error); @@ -428,7 +453,7 @@ describe(SharedLinkService.name, () => { }); it('should rethrow unknown errors on update', async () => { - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid as any); const error = new Error('unknown error'); mocks.sharedLink.update.mockRejectedValue(error); @@ -447,19 +472,21 @@ describe(SharedLinkService.name, () => { }); it('should remove a key', async () => { - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); + const sharedLink = SharedLinkFactory.create(); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); mocks.sharedLink.remove.mockResolvedValue(); - await sut.remove(authStub.user1, sharedLinkStub.valid.id); + await sut.remove(authStub.user1, sharedLink.id); - expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); - expect(mocks.sharedLink.remove).toHaveBeenCalledWith(sharedLinkStub.valid.id); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLink.id); + expect(mocks.sharedLink.remove).toHaveBeenCalledWith(sharedLink.id); }); }); describe('addAssets', () => { it('should not work on album shared links', async () => { - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); + const sharedLink = SharedLinkFactory.from().album().build(); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( BadRequestException, @@ -468,11 +495,13 @@ describe(SharedLinkService.name, () => { it('should add assets to a shared link', async () => { const asset = AssetFactory.create(); - const sharedLink = SharedLinkFactory.from().asset(asset).build(); + const sharedLink = SharedLinkFactory.from() + .asset(asset, (builder) => builder.exif()) + .build(); const newAsset = AssetFactory.create(); - mocks.sharedLink.get.mockResolvedValue(sharedLink); - mocks.sharedLink.create.mockResolvedValue(sharedLink); - mocks.sharedLink.update.mockResolvedValue(sharedLink); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); + mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink)); + mocks.sharedLink.update.mockResolvedValue(getForSharedLink(sharedLink)); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([newAsset.id])); await expect( @@ -486,7 +515,7 @@ describe(SharedLinkService.name, () => { expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledTimes(1); expect(mocks.sharedLink.update).toHaveBeenCalled(); expect(mocks.sharedLink.update).toHaveBeenCalledWith({ - ...sharedLink, + ...getForSharedLink(sharedLink), slug: null, assetIds: [newAsset.id], }); @@ -495,8 +524,8 @@ describe(SharedLinkService.name, () => { it('should handle all assets being duplicates', async () => { const asset = AssetFactory.create(); const sharedLink = SharedLinkFactory.from().asset(asset).build(); - mocks.sharedLink.get.mockResolvedValue(sharedLink); - mocks.sharedLink.update.mockResolvedValue(sharedLink); + mocks.sharedLink.get.mockResolvedValue(sharedLink as any); + mocks.sharedLink.update.mockResolvedValue(sharedLink as any); const result = await sut.addAssets(authStub.admin, sharedLink.id, { assetIds: [asset.id] }); @@ -507,19 +536,22 @@ describe(SharedLinkService.name, () => { describe('removeAssets', () => { it('should not work on album shared links', async () => { - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); + const sharedLink = SharedLinkFactory.from().album().build(); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); - await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( + await expect(sut.removeAssets(authStub.admin, sharedLink.id, { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( BadRequestException, ); }); it('should remove assets from a shared link', async () => { const asset = AssetFactory.create(); - const sharedLink = SharedLinkFactory.from().asset(asset).build(); - mocks.sharedLink.get.mockResolvedValue(sharedLink); - mocks.sharedLink.create.mockResolvedValue(sharedLink); - mocks.sharedLink.update.mockResolvedValue(sharedLink); + const sharedLink = SharedLinkFactory.from() + .asset(asset, (builder) => builder.exif()) + .build(); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); + mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink)); + mocks.sharedLink.update.mockResolvedValue(getForSharedLink(sharedLink)); mocks.sharedLinkAsset.remove.mockResolvedValue([asset.id]); await expect( @@ -535,9 +567,9 @@ describe(SharedLinkService.name, () => { it('should handle all assets not found', async () => { const sharedLink = SharedLinkFactory.from().build(); - mocks.sharedLink.get.mockResolvedValue(sharedLink); + mocks.sharedLink.get.mockResolvedValue(sharedLink as any); mocks.sharedLinkAsset.remove.mockResolvedValue([]); - mocks.sharedLink.update.mockResolvedValue(sharedLink); + mocks.sharedLink.update.mockResolvedValue(sharedLink as any); const result = await sut.removeAssets(authStub.admin, sharedLink.id, { assetIds: ['missing-1', 'missing-2'] }); @@ -564,11 +596,14 @@ describe(SharedLinkService.name, () => { }); it('should return metadata tags', async () => { - mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.individual); + const sharedLink = SharedLinkFactory.from({ description: null }) + .asset({}, (builder) => builder.exif()) + .build(); + mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink)); await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ description: '1 shared photos & videos', - imageUrl: `https://my.immich.app/api/assets/${sharedLinkStub.individual.assets[0].id}/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`, + imageUrl: `https://my.immich.app/api/assets/${sharedLink.assets[0].id}/thumbnail?key=${sharedLink.key.toString('base64url')}`, title: 'Public Share', }); @@ -593,7 +628,7 @@ describe(SharedLinkService.name, () => { album: { ...album, albumThumbnailAssetId: null }, assets: [], }; - mocks.sharedLink.get.mockResolvedValue(sharedLink); + mocks.sharedLink.get.mockResolvedValue(sharedLink as any); const result = await sut.getMetadataTags(authStub.adminSharedLink); @@ -608,7 +643,7 @@ describe(SharedLinkService.name, () => { album: { ...album, albumThumbnailAssetId: thumbnailAssetId, assets: [] }, assets: [], }; - mocks.sharedLink.get.mockResolvedValue(sharedLink); + mocks.sharedLink.get.mockResolvedValue(sharedLink as any); const result = await sut.getMetadataTags(authStub.adminSharedLink); @@ -637,7 +672,7 @@ describe(SharedLinkService.name, () => { album: { ...album, albumThumbnailAssetId: null, assets }, assets: [], }; - mocks.sharedLink.get.mockResolvedValue(sharedLink); + mocks.sharedLink.get.mockResolvedValue(sharedLink as any); const result = await sut.getMetadataTags(authStub.adminSharedLink); diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index b942c32326435..26b15031ee7d4 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -150,6 +150,12 @@ export class SharedLinkService extends BaseService { } async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise { + if (auth.sharedLink) { + this.logger.deprecate( + 'Assets uploaded using shared link authentication are now automatically added to the shared link during upload and in the next major release this endpoint will no longer accept shared link authentication', + ); + } + const sharedLink = await this.findOrFail(auth.user.id, id); if (sharedLink.type !== SharedLinkType.Individual) { diff --git a/server/src/services/stack.service.spec.ts b/server/src/services/stack.service.spec.ts index 87f7a7be667f2..eb6b4742d2f44 100644 --- a/server/src/services/stack.service.spec.ts +++ b/server/src/services/stack.service.spec.ts @@ -4,6 +4,7 @@ import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; import { StackFactory } from 'test/factories/stack.factory'; import { authStub } from 'test/fixtures/auth.stub'; +import { getForStack } from 'test/mappers'; import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -22,9 +23,11 @@ describe(StackService.name, () => { describe('search', () => { it('should search stacks', async () => { const auth = AuthFactory.create(); - const asset = AssetFactory.create(); - const stack = StackFactory.from().primaryAsset(asset).build(); - mocks.stack.search.mockResolvedValue([stack]); + const asset = AssetFactory.from().exif().build(); + const stack = StackFactory.from() + .primaryAsset(asset, (builder) => builder.exif()) + .build(); + mocks.stack.search.mockResolvedValue([getForStack(stack)]); await sut.search(auth, { primaryAssetId: asset.id }); expect(mocks.stack.search).toHaveBeenCalledWith({ @@ -49,11 +52,14 @@ describe(StackService.name, () => { it('should create a stack', async () => { const auth = AuthFactory.create(); - const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; - const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build(); + const [primaryAsset, asset] = [AssetFactory.from().exif().build(), AssetFactory.from().exif().build()]; + const stack = StackFactory.from() + .primaryAsset(primaryAsset, (builder) => builder.exif()) + .asset(asset, (builder) => builder.exif()) + .build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([primaryAsset.id, asset.id])); - mocks.stack.create.mockResolvedValue(stack); + mocks.stack.create.mockResolvedValue(getForStack(stack)); await expect(sut.create(auth, { assetIds: [primaryAsset.id, asset.id] })).resolves.toEqual({ id: stack.id, @@ -96,11 +102,14 @@ describe(StackService.name, () => { it('should get stack', async () => { const auth = AuthFactory.create(); - const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; - const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build(); + const [primaryAsset, asset] = [AssetFactory.from().exif().build(), AssetFactory.from().exif().build()]; + const stack = StackFactory.from() + .primaryAsset(primaryAsset, (builder) => builder.exif()) + .asset(asset, (builder) => builder.exif()) + .build(); mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id])); - mocks.stack.getById.mockResolvedValue(stack); + mocks.stack.getById.mockResolvedValue(getForStack(stack)); await expect(sut.get(auth, stack.id)).resolves.toEqual({ id: stack.id, @@ -133,10 +142,13 @@ describe(StackService.name, () => { it('should fail if the provided primary asset id is not in the stack', async () => { const auth = AuthFactory.create(); - const stack = StackFactory.from().primaryAsset().asset().build(); + const stack = StackFactory.from() + .primaryAsset({}, (builder) => builder.exif()) + .asset({}, (builder) => builder.exif()) + .build(); mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id])); - mocks.stack.getById.mockResolvedValue(stack); + mocks.stack.getById.mockResolvedValue(getForStack(stack)); await expect(sut.update(auth, stack.id, { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf( BadRequestException, @@ -149,12 +161,15 @@ describe(StackService.name, () => { it('should update stack', async () => { const auth = AuthFactory.create(); - const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; - const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build(); + const [primaryAsset, asset] = [AssetFactory.from().exif().build(), AssetFactory.from().exif().build()]; + const stack = StackFactory.from() + .primaryAsset(primaryAsset, (builder) => builder.exif()) + .asset(asset, (builder) => builder.exif()) + .build(); mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id])); - mocks.stack.getById.mockResolvedValue(stack); - mocks.stack.update.mockResolvedValue(stack); + mocks.stack.getById.mockResolvedValue(getForStack(stack)); + mocks.stack.update.mockResolvedValue(getForStack(stack)); await sut.update(auth, stack.id, { primaryAssetId: asset.id }); diff --git a/server/src/services/storage-migration.service.spec.ts b/server/src/services/storage-migration.service.spec.ts index bb12d6f13c5eb..776d44014b3b7 100644 --- a/server/src/services/storage-migration.service.spec.ts +++ b/server/src/services/storage-migration.service.spec.ts @@ -229,7 +229,14 @@ describe(StorageMigrationService.name, () => { ]), ); mocks.storageMigration.streamEncodedVideos.mockReturnValue( - makeStream([{ id: 'asset-2', encodedVideoPath: '/usr/src/app/upload/encoded-video/user/ab/cd/video.mp4' }]), + makeStream([ + { + id: 'file-2', + assetId: 'asset-2', + path: '/usr/src/app/upload/encoded-video/user/ab/cd/video.mp4', + type: AssetFileType.EncodedVideo, + }, + ]), ); mocks.storageMigration.streamPersonThumbnails.mockReturnValue( makeStream([{ id: 'person-1', thumbnailPath: '/usr/src/app/upload/thumbs/user/ab/cd/person.jpeg' }]), diff --git a/server/src/services/storage-migration.service.ts b/server/src/services/storage-migration.service.ts index d9d55e1000658..53c1b02ca9fb9 100644 --- a/server/src/services/storage-migration.service.ts +++ b/server/src/services/storage-migration.service.ts @@ -195,9 +195,9 @@ export class StorageMigrationService extends BaseService { for await (const row of this.storageMigrationRepository.streamEncodedVideos(direction)) { await enqueue({ entityType: 'asset', - entityId: row.id, + entityId: row.assetId, fileType: 'encodedVideo', - sourcePath: row.encodedVideoPath!, + sourcePath: row.path, batchId, direction, deleteSource, diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 44f833210d407..87d97ba803b86 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -6,7 +6,7 @@ import { AlbumFactory } from 'test/factories/album.factory'; import { AssetFactory } from 'test/factories/asset.factory'; import { UserFactory } from 'test/factories/user.factory'; import { userStub } from 'test/fixtures/user.stub'; -import { getForStorageTemplate } from 'test/mappers'; +import { getForAlbum, getForStorageTemplate } from 'test/mappers'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; const motionAsset = AssetFactory.from({ type: AssetType.Video }).exif().build(); @@ -181,7 +181,9 @@ describe(StorageTemplateService.name, () => { .exif() .build(); - const album = AlbumFactory.from().asset().build(); + const album = AlbumFactory.from() + .asset({}, (builder) => builder.exif()) + .build(); const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}'; sut.onConfigInit({ newConfig: config }); @@ -193,7 +195,7 @@ describe(StorageTemplateService.name, () => { mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(stillAsset)); mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset)); - mocks.album.getByAssetId.mockResolvedValue([album]); + mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]); mocks.move.create.mockResolvedValueOnce({ id: '123', @@ -222,7 +224,9 @@ describe(StorageTemplateService.name, () => { it('should use handlebar if condition for album', async () => { const user = UserFactory.create(); const asset = AssetFactory.from().owner(user).exif().build(); - const album = AlbumFactory.from().asset().build(); + const album = AlbumFactory.from() + .asset({}, (builder) => builder.exif()) + .build(); const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}'; @@ -230,7 +234,7 @@ describe(StorageTemplateService.name, () => { mocks.user.get.mockResolvedValue(user); mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset)); - mocks.album.getByAssetId.mockResolvedValueOnce([album]); + mocks.album.getByAssetId.mockResolvedValueOnce([getForAlbum(album)]); expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success); @@ -270,7 +274,9 @@ describe(StorageTemplateService.name, () => { it('should handle album startDate', async () => { const user = UserFactory.create(); const asset = AssetFactory.from().owner(user).exif().build(); - const album = AlbumFactory.from().asset().build(); + const album = AlbumFactory.from() + .asset({}, (builder) => builder.exif()) + .build(); const config = structuredClone(defaults); config.storageTemplate.template = '{{#if album}}{{album-startDate-y}}/{{album-startDate-MM}} - {{album}}{{else}}{{y}}/{{MM}}/{{/if}}/{{filename}}'; @@ -279,7 +285,7 @@ describe(StorageTemplateService.name, () => { mocks.user.get.mockResolvedValue(user); mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset)); - mocks.album.getByAssetId.mockResolvedValueOnce([album]); + mocks.album.getByAssetId.mockResolvedValueOnce([getForAlbum(album)]); mocks.album.getMetadataForIds.mockResolvedValueOnce([ { startDate: asset.fileCreatedAt, @@ -824,7 +830,9 @@ describe(StorageTemplateService.name, () => { }) .exif() .build(); - const album = AlbumFactory.from().asset().build(); + const album = AlbumFactory.from() + .asset({}, (builder) => builder.exif()) + .build(); const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}'; sut.onConfigInit({ newConfig: config }); @@ -835,7 +843,7 @@ describe(StorageTemplateService.name, () => { mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)])); mocks.user.getList.mockResolvedValue([userStub.user1]); mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset)); - mocks.album.getByAssetId.mockResolvedValue([album]); + mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]); mocks.move.create.mockResolvedValueOnce({ id: '123', @@ -863,7 +871,9 @@ describe(StorageTemplateService.name, () => { it('should use still photo album info when migrating live photo motion video', async () => { const user = userStub.user1; - const album = AlbumFactory.from().asset().build(); + const album = AlbumFactory.from() + .asset({}, (builder) => builder.exif()) + .build(); const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other{{/if}}/{{filename}}'; @@ -872,7 +882,7 @@ describe(StorageTemplateService.name, () => { mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)])); mocks.user.getList.mockResolvedValue([user]); mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset)); - mocks.album.getByAssetId.mockResolvedValue([album]); + mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]); mocks.move.create.mockResolvedValueOnce({ id: '123', diff --git a/server/src/services/sync.service.spec.ts b/server/src/services/sync.service.spec.ts index 235e3685c5144..3b56ee14cf986 100644 --- a/server/src/services/sync.service.spec.ts +++ b/server/src/services/sync.service.spec.ts @@ -5,7 +5,9 @@ import { AssetVisibility, SyncEntityType, SyncRequestType } from 'src/enum'; import { SyncService } from 'src/services/sync.service'; import { toAck } from 'src/utils/sync'; import { AssetFactory } from 'test/factories/asset.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; import { authStub } from 'test/fixtures/auth.stub'; +import { getForAsset, getForPartner } from 'test/mappers'; import { factory, newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -1116,10 +1118,10 @@ describe(SyncService.name, () => { AssetFactory.from({ libraryId: 'library-id', isExternal: true }).owner(authStub.user1.user).build(), AssetFactory.from().owner(authStub.user1.user).build(), ]; - mocks.asset.getAllForUserFullSync.mockResolvedValue([asset1, asset2]); + mocks.asset.getAllForUserFullSync.mockResolvedValue([getForAsset(asset1), getForAsset(asset2)]); await expect(sut.getFullSync(authStub.user1, { limit: 2, updatedUntil: untilDate })).resolves.toEqual([ - mapAsset(asset1, mapAssetOpts), - mapAsset(asset2, mapAssetOpts), + mapAsset(getForAsset(asset1), mapAssetOpts), + mapAsset(getForAsset(asset2), mapAssetOpts), ]); expect(mocks.asset.getAllForUserFullSync).toHaveBeenCalledWith({ ownerId: authStub.user1.user.id, @@ -1145,10 +1147,10 @@ describe(SyncService.name, () => { describe('getDeltaSync', () => { it('should return a response requiring a full sync when partners are out of sync', async () => { - const partner = factory.partner(); + const partner = PartnerFactory.create(); const auth = factory.auth({ user: { id: partner.sharedWithId } }); - mocks.partner.getAll.mockResolvedValue([partner]); + mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [auth.user.id] }), @@ -1170,7 +1172,9 @@ describe(SyncService.name, () => { it('should return a response requiring a full sync when there are too many changes', async () => { const asset = AssetFactory.create(); mocks.partner.getAll.mockResolvedValue([]); - mocks.asset.getChangedDeltaSync.mockResolvedValue(Array.from({ length: 10_000 }).fill(asset)); + mocks.asset.getChangedDeltaSync.mockResolvedValue( + Array.from>({ length: 10_000 }).fill(getForAsset(asset)), + ); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); @@ -1182,13 +1186,13 @@ describe(SyncService.name, () => { const asset = AssetFactory.create({ ownerId: authStub.user1.user.id }); const deletedAsset = AssetFactory.create({ libraryId: 'library-id', isExternal: true }); mocks.partner.getAll.mockResolvedValue([]); - mocks.asset.getChangedDeltaSync.mockResolvedValue([asset]); + mocks.asset.getChangedDeltaSync.mockResolvedValue([getForAsset(asset)]); mocks.audit.getAfter.mockResolvedValue([deletedAsset.id]); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ needsFullSync: false, - upserted: [mapAsset(asset, mapAssetOpts)], + upserted: [mapAsset(getForAsset(asset), mapAssetOpts)], deleted: [deletedAsset.id], }); expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1); @@ -1211,8 +1215,8 @@ describe(SyncService.name, () => { sharedById: 'partner-id', sharedWithId: authStub.user1.user.id, }), - ]); - mocks.asset.getChangedDeltaSync.mockResolvedValue([ownAsset, partnerAssetTimeline, partnerAssetArchived]); + ] as any); + mocks.asset.getChangedDeltaSync.mockResolvedValue([ownAsset, partnerAssetTimeline, partnerAssetArchived] as any); mocks.audit.getAfter.mockResolvedValue([]); mocks.access.timeline.checkPartnerAccess.mockResolvedValue(new Set(['partner-id'])); diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index f221c6f3c430f..e9225f05802d1 100644 --- a/server/src/services/user-admin.service.spec.ts +++ b/server/src/services/user-admin.service.spec.ts @@ -2,6 +2,8 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { mapUserAdmin, UserAdminCreateDto } from 'src/dtos/user.dto'; import { JobName, UserMetadataKey, UserStatus } from 'src/enum'; import { UserAdminService } from 'src/services/user-admin.service'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; import { factory } from 'test/small.factory'; @@ -305,8 +307,8 @@ describe(UserAdminService.name, () => { }); it('should not allow deleting own account', async () => { - const user = factory.userAdmin({ isAdmin: false }); - const auth = factory.auth({ user }); + const user = UserFactory.create({ isAdmin: false }); + const auth = AuthFactory.create(user); mocks.user.get.mockResolvedValue(user); await expect(sut.delete(auth, user.id, {})).rejects.toBeInstanceOf(ForbiddenException); diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index cf39ec87f48e4..cfa0080de4571 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -5,6 +5,8 @@ import { CacheControl, JobName, JobStatus, UserMetadataKey } from 'src/enum'; import { StorageService } from 'src/services/storage.service'; import { UserService } from 'src/services/user.service'; import { ImmichFileResponse } from 'src/utils/file'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; @@ -34,8 +36,8 @@ describe(UserService.name, () => { describe('getAll', () => { it('admin should get all users', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); mocks.user.getList.mockResolvedValue([user]); @@ -45,8 +47,8 @@ describe(UserService.name, () => { }); it('non-admin should get all users when publicUsers enabled', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); mocks.user.getList.mockResolvedValue([user]); @@ -245,7 +247,7 @@ describe(UserService.name, () => { it('should throw an error if the user profile could not be updated with the new image', async () => { const file = { path: '/profile/path' } as Express.Multer.File; - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); mocks.user.get.mockResolvedValue(user); mocks.user.update.mockRejectedValue(new InternalServerErrorException('mocked error')); @@ -253,7 +255,7 @@ describe(UserService.name, () => { }); it('should delete the previous profile image', async () => { - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); const file = { path: '/profile/path' } as Express.Multer.File; const files = [user.profileImagePath]; @@ -305,7 +307,7 @@ describe(UserService.name, () => { }); it('should delete the profile image if user has one', async () => { - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); const files = [user.profileImagePath]; mocks.user.get.mockResolvedValue(user); @@ -352,7 +354,7 @@ describe(UserService.name, () => { }); it('should return the profile picture', async () => { - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); mocks.user.get.mockResolvedValue(user); await expect(sut.getProfileImage(user.id)).resolves.toEqual( @@ -465,7 +467,7 @@ describe(UserService.name, () => { }); it('should queue user ready for deletion', async () => { - const user = factory.user(); + const user = UserFactory.create(); mocks.user.getDeletedAfter.mockResolvedValue([{ id: user.id }]); await sut.handleUserDeleteCheck(); diff --git a/server/src/services/view.service.spec.ts b/server/src/services/view.service.spec.ts index 7b26fb5eb3ad5..a4bc51b0cc267 100644 --- a/server/src/services/view.service.spec.ts +++ b/server/src/services/view.service.spec.ts @@ -2,6 +2,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { ViewService } from 'src/services/view.service'; import { AssetFactory } from 'test/factories/asset.factory'; import { authStub } from 'test/fixtures/auth.stub'; +import { getForAsset } from 'test/mappers'; import { newTestService, ServiceMocks } from 'test/utils'; describe(ViewService.name, () => { @@ -37,7 +38,7 @@ describe(ViewService.name, () => { const mockAssets = [asset1, asset2]; - const mockAssetReponseDto = mockAssets.map((a) => mapAsset(a, { auth: authStub.admin })); + const mockAssetReponseDto = mockAssets.map((asset) => mapAsset(getForAsset(asset), { auth: authStub.admin })); mocks.view.getAssetsByOriginalPath.mockResolvedValue(mockAssets as any); diff --git a/server/src/types.ts b/server/src/types.ts index 85bbd741c11c1..c10a42486f124 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,3 +1,4 @@ +import { ShallowDehydrateObject } from 'kysely'; import { SystemConfig } from 'src/config'; import { VECTOR_EXTENSIONS } from 'src/constants'; import { Asset, AssetFile } from 'src/database'; @@ -599,3 +600,5 @@ export interface UserMetadata extends Record = T | ShallowDehydrateObject; diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index d6ab825028684..5420e6036161d 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -26,6 +26,8 @@ export const getAssetFiles = (files: AssetFile[]) => ({ editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }), editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }), editedThumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: true }), + + encodedVideoFile: getAssetFile(files, AssetFileType.EncodedVideo, { isEdited: false }), }); export const addAssets = async ( diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 49f2d635671a2..a1ac6dbc5fc83 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -4,23 +4,23 @@ import { DeduplicateJoinsPlugin, Expression, ExpressionBuilder, - ExpressionWrapper, Kysely, KyselyConfig, - Nullable, + NotNull, Selectable, SelectQueryBuilder, - Simplify, + ShallowDehydrateObject, sql, } from 'kysely'; import { PostgresJSDialect } from 'kysely-postgres-js'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { Notice, PostgresError } from 'postgres'; -import { columns, Exif, lockableProperties, LockableProperty, Person } from 'src/database'; +import { columns, lockableProperties, LockableProperty, Person } from 'src/database'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { AssetFileType, AssetVisibility, DatabaseExtension } from 'src/enum'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { DB } from 'src/schema'; +import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { VectorExtension } from 'src/types'; export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyConfig => { @@ -70,28 +70,6 @@ export const removeUndefinedKeys = (update: T, template: unkno return update; }; -/** Modifies toJson return type to not set all properties as nullable */ -export function toJson>( - eb: ExpressionBuilder, - table: T, -) { - return eb.fn.toJson(table) as ExpressionWrapper< - DB, - TB, - Simplify< - T extends TB - ? Selectable extends Nullable - ? N | null - : Selectable - : T extends Expression - ? O extends Nullable - ? N | null - : O - : never - > - >; -} - export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum'; export const isAssetChecksumConstraint = (error: unknown) => { @@ -106,19 +84,25 @@ export function withDefaultVisibility(qb: SelectQueryBuilder) export function withExif(qb: SelectQueryBuilder) { return qb .leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId') - .select((eb) => eb.fn.toJson(eb.table('asset_exif')).$castTo().as('exifInfo')); + .select((eb) => + eb.fn + .toJson(eb.table('asset_exif')) + .$castTo> | null>() + .as('exifInfo'), + ); } export function withExifInner(qb: SelectQueryBuilder) { return qb .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') - .select((eb) => eb.fn.toJson(eb.table('asset_exif')).$castTo().as('exifInfo')); + .select((eb) => eb.fn.toJson(eb.table('asset_exif')).as('exifInfo')) + .$narrowType<{ exifInfo: NotNull }>(); } export function withSmartSearch(qb: SelectQueryBuilder) { return qb .leftJoin('smart_search', 'asset.id', 'smart_search.assetId') - .select((eb) => toJson(eb, 'smart_search').as('smartSearch')); + .select((eb) => jsonObjectFrom(eb.table('smart_search')).as('smartSearch')); } export function withFaces(eb: ExpressionBuilder, withHidden?: boolean, withDeletedFace?: boolean) { @@ -164,7 +148,7 @@ export function withFacesAndPeople( (join) => join.onTrue(), ) .selectAll('asset_face') - .select((eb) => eb.table('person').$castTo().as('person')) + .select((eb) => eb.table('person').$castTo>().as('person')) .whereRef('asset_face.assetId', '=', 'asset.id') .$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null)) .$if(!withHidden, (qb) => qb.where('asset_face.isVisible', 'is', true)), @@ -393,7 +377,16 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!))) .$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!))) .$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!))) - .$if(!!options.encodedVideoPath, (qb) => qb.where('asset.encodedVideoPath', '=', options.encodedVideoPath!)) + .$if(!!options.encodedVideoPath, (qb) => + qb + .innerJoin('asset_file', (join) => + join + .onRef('asset.id', '=', 'asset_file.assetId') + .on('asset_file.type', '=', AssetFileType.EncodedVideo) + .on('asset_file.isEdited', '=', false), + ) + .where('asset_file.path', '=', options.encodedVideoPath!), + ) .$if(!!options.originalPath, (qb) => qb.where(sql`f_unaccent(asset."originalPath")`, 'ilike', sql`'%' || f_unaccent(${options.originalPath}) || '%'`), ) @@ -418,7 +411,15 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) .$if(options.isOffline !== undefined, (qb) => qb.where('asset.isOffline', '=', options.isOffline!)) .$if(options.isEncoded !== undefined, (qb) => - qb.where('asset.encodedVideoPath', options.isEncoded ? 'is not' : 'is', null), + qb.where((eb) => { + const exists = eb.exists((eb) => + eb + .selectFrom('asset_file') + .whereRef('assetId', '=', 'asset.id') + .where('type', '=', AssetFileType.EncodedVideo), + ); + return options.isEncoded ? exists : eb.not(exists); + }), ) .$if(options.isMotion !== undefined, (qb) => qb.where('asset.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null), diff --git a/server/src/utils/date.ts b/server/src/utils/date.ts index 6cef48ecf88c3..092a0e6619d0b 100644 --- a/server/src/utils/date.ts +++ b/server/src/utils/date.ts @@ -1,6 +1,10 @@ import { DateTime } from 'luxon'; -export const asDateString = (x: Date | string | null): string | null => { +export const asDateString = (x: T) => { + return x instanceof Date ? x.toISOString() : (x as Exclude); +}; + +export const asBirthDateString = (x: Date | string | null): string | null => { return x instanceof Date ? x.toISOString().split('T')[0] : x; }; diff --git a/server/test/factories/activity.factory.ts b/server/test/factories/activity.factory.ts new file mode 100644 index 0000000000000..861b115158a61 --- /dev/null +++ b/server/test/factories/activity.factory.ts @@ -0,0 +1,42 @@ +import { Selectable } from 'kysely'; +import { ActivityTable } from 'src/schema/tables/activity.table'; +import { build } from 'test/factories/builder.factory'; +import { ActivityLike, FactoryBuilder, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class ActivityFactory { + #user!: UserFactory; + + private constructor(private value: Selectable) {} + + static create(dto: ActivityLike = {}) { + return ActivityFactory.from(dto).build(); + } + + static from(dto: ActivityLike = {}) { + const userId = dto.userId ?? newUuid(); + return new ActivityFactory({ + albumId: newUuid(), + assetId: null, + comment: null, + createdAt: newDate(), + id: newUuid(), + isLiked: false, + userId, + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }).user({ id: userId }); + } + + user(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#user = build(UserFactory.from(dto), builder); + this.value.userId = this.#user.build().id; + return this; + } + + build() { + return { ...this.value, user: this.#user.build() }; + } +} diff --git a/server/test/factories/api-key.factory.ts b/server/test/factories/api-key.factory.ts new file mode 100644 index 0000000000000..d16b50ba571e8 --- /dev/null +++ b/server/test/factories/api-key.factory.ts @@ -0,0 +1,42 @@ +import { Selectable } from 'kysely'; +import { Permission } from 'src/enum'; +import { ApiKeyTable } from 'src/schema/tables/api-key.table'; +import { build } from 'test/factories/builder.factory'; +import { ApiKeyLike, FactoryBuilder, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class ApiKeyFactory { + #user!: UserFactory; + + private constructor(private value: Selectable) {} + + static create(dto: ApiKeyLike = {}) { + return ApiKeyFactory.from(dto).build(); + } + + static from(dto: ApiKeyLike = {}) { + const userId = dto.userId ?? newUuid(); + return new ApiKeyFactory({ + createdAt: newDate(), + id: newUuid(), + key: Buffer.from('api-key-buffer'), + name: 'API Key', + permissions: [Permission.All], + updatedAt: newDate(), + updateId: newUuidV7(), + userId, + ...dto, + }).user({ id: userId }); + } + + user(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#user = build(UserFactory.from(dto), builder); + this.value.userId = this.#user.build().id; + return this; + } + + build() { + return { ...this.value, user: this.#user.build() }; + } +} diff --git a/server/test/factories/asset.factory.ts b/server/test/factories/asset.factory.ts index 4d54ba820b098..ec596dc86e1ab 100644 --- a/server/test/factories/asset.factory.ts +++ b/server/test/factories/asset.factory.ts @@ -55,7 +55,6 @@ export class AssetFactory { deviceId: '', duplicateId: null, duration: null, - encodedVideoPath: null, fileCreatedAt: newDate(), fileModifiedAt: newDate(), isExternal: false, diff --git a/server/test/factories/auth.factory.ts b/server/test/factories/auth.factory.ts index 9c738aabacc3e..fd38c42649585 100644 --- a/server/test/factories/auth.factory.ts +++ b/server/test/factories/auth.factory.ts @@ -1,12 +1,16 @@ import { AuthDto } from 'src/dtos/auth.dto'; +import { ApiKeyFactory } from 'test/factories/api-key.factory'; import { build } from 'test/factories/builder.factory'; import { SharedLinkFactory } from 'test/factories/shared-link.factory'; -import { FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types'; +import { ApiKeyLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types'; import { UserFactory } from 'test/factories/user.factory'; +import { newUuid } from 'test/small.factory'; export class AuthFactory { #user: UserFactory; #sharedLink?: SharedLinkFactory; + #apiKey?: ApiKeyFactory; + #session?: AuthDto['session']; private constructor(user: UserFactory) { this.#user = user; @@ -20,8 +24,8 @@ export class AuthFactory { return new AuthFactory(UserFactory.from(dto)); } - apiKey() { - // TODO + apiKey(dto: ApiKeyLike = {}, builder?: FactoryBuilder) { + this.#apiKey = build(ApiKeyFactory.from(dto), builder); return this; } @@ -30,6 +34,11 @@ export class AuthFactory { return this; } + session(dto: Partial = {}) { + this.#session = { id: newUuid(), hasElevatedPermission: false, ...dto }; + return this; + } + build(): AuthDto { const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes } = this.#user.build(); @@ -43,6 +52,8 @@ export class AuthFactory { quotaSizeInBytes, }, sharedLink: this.#sharedLink?.build(), + apiKey: this.#apiKey?.build(), + session: this.#session, }; } } diff --git a/server/test/factories/partner.factory.ts b/server/test/factories/partner.factory.ts new file mode 100644 index 0000000000000..f631db1eb5be9 --- /dev/null +++ b/server/test/factories/partner.factory.ts @@ -0,0 +1,50 @@ +import { Selectable } from 'kysely'; +import { PartnerTable } from 'src/schema/tables/partner.table'; +import { build } from 'test/factories/builder.factory'; +import { FactoryBuilder, PartnerLike, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class PartnerFactory { + #sharedWith!: UserFactory; + #sharedBy!: UserFactory; + + private constructor(private value: Selectable) {} + + static create(dto: PartnerLike = {}) { + return PartnerFactory.from(dto).build(); + } + + static from(dto: PartnerLike = {}) { + const sharedById = dto.sharedById ?? newUuid(); + const sharedWithId = dto.sharedWithId ?? newUuid(); + return new PartnerFactory({ + createdAt: newDate(), + createId: newUuidV7(), + inTimeline: true, + sharedById, + sharedWithId, + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }) + .sharedBy({ id: sharedById }) + .sharedWith({ id: sharedWithId }); + } + + sharedWith(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#sharedWith = build(UserFactory.from(dto), builder); + this.value.sharedWithId = this.#sharedWith.build().id; + return this; + } + + sharedBy(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#sharedBy = build(UserFactory.from(dto), builder); + this.value.sharedById = this.#sharedBy.build().id; + return this; + } + + build() { + return { ...this.value, sharedWith: this.#sharedWith.build(), sharedBy: this.#sharedBy.build() }; + } +} diff --git a/server/test/factories/session.factory.ts b/server/test/factories/session.factory.ts new file mode 100644 index 0000000000000..8d4cb28727bb7 --- /dev/null +++ b/server/test/factories/session.factory.ts @@ -0,0 +1,35 @@ +import { Selectable } from 'kysely'; +import { SessionTable } from 'src/schema/tables/session.table'; +import { SessionLike } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class SessionFactory { + private constructor(private value: Selectable) {} + + static create(dto: SessionLike = {}) { + return SessionFactory.from(dto).build(); + } + + static from(dto: SessionLike = {}) { + return new SessionFactory({ + appVersion: null, + createdAt: newDate(), + deviceOS: 'android', + deviceType: 'mobile', + expiresAt: null, + id: newUuid(), + isPendingSyncReset: false, + parentId: null, + pinExpiresAt: null, + token: Buffer.from('abc123'), + updateId: newUuidV7(), + updatedAt: newDate(), + userId: newUuid(), + ...dto, + }); + } + + build() { + return { ...this.value }; + } +} diff --git a/server/test/factories/shared-link.factory.ts b/server/test/factories/shared-link.factory.ts index 5ac5f1756b475..a37283df75135 100644 --- a/server/test/factories/shared-link.factory.ts +++ b/server/test/factories/shared-link.factory.ts @@ -51,12 +51,14 @@ export class SharedLinkFactory { album(dto: AlbumLike = {}, builder?: FactoryBuilder) { this.#album = build(AlbumFactory.from(dto), builder); + this.value.type = SharedLinkType.Album; return this; } asset(dto: AssetLike = {}, builder?: FactoryBuilder) { const asset = build(AssetFactory.from(dto), builder); this.#assets.push(asset); + this.value.type = SharedLinkType.Individual; return this; } diff --git a/server/test/factories/types.ts b/server/test/factories/types.ts index 0e070c1bcc7e4..e2d9e4e1c3c53 100644 --- a/server/test/factories/types.ts +++ b/server/test/factories/types.ts @@ -1,13 +1,17 @@ import { Selectable } from 'kysely'; +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'; +import { ApiKeyTable } from 'src/schema/tables/api-key.table'; import { AssetEditTable } from 'src/schema/tables/asset-edit.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { MemoryTable } from 'src/schema/tables/memory.table'; +import { PartnerTable } from 'src/schema/tables/partner.table'; import { PersonTable } from 'src/schema/tables/person.table'; +import { SessionTable } from 'src/schema/tables/session.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; import { StackTable } from 'src/schema/tables/stack.table'; import { UserTable } from 'src/schema/tables/user.table'; @@ -26,3 +30,7 @@ export type AssetFaceLike = Partial>; export type PersonLike = Partial>; export type StackLike = Partial>; export type MemoryLike = Partial>; +export type PartnerLike = Partial>; +export type ActivityLike = Partial>; +export type ApiKeyLike = Partial>; +export type SessionLike = Partial>; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index a42ff743bc16d..ac073c299d8b1 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -1,7 +1,6 @@ -import { UserAdmin } from 'src/database'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto'; -import { AssetStatus, AssetType, AssetVisibility, SharedLinkType } from 'src/enum'; +import { SharedLinkType } from 'src/enum'; import { AssetFactory } from 'test/factories/asset.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; @@ -83,86 +82,7 @@ export const sharedLinkStub = { showExif: false, description: null, password: null, - assets: [ - { - id: 'id_1', - status: AssetStatus.Active, - owner: undefined as unknown as UserAdmin, - ownerId: 'user_id_1', - deviceAssetId: 'device_asset_id_1', - deviceId: 'device_id_1', - type: AssetType.Video, - originalPath: 'fake_path/jpeg', - checksum: Buffer.from('file hash', 'utf8'), - fileModifiedAt: today, - fileCreatedAt: today, - localDateTime: today, - createdAt: today, - updatedAt: today, - isFavorite: false, - isArchived: false, - isExternal: false, - isOffline: false, - files: [], - thumbhash: null, - encodedVideoPath: '', - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - originalFileName: 'asset_1.jpeg', - exifInfo: { - projectionType: null, - livePhotoCID: null, - assetId: 'id_1', - description: 'description', - exifImageWidth: 500, - exifImageHeight: 500, - fileSizeInByte: 100, - orientation: 'orientation', - dateTimeOriginal: today, - modifyDate: today, - timeZone: 'America/Los_Angeles', - latitude: 100, - longitude: 100, - city: 'city', - state: 'state', - country: 'country', - make: 'camera-make', - model: 'camera-model', - lensModel: 'fancy', - fNumber: 100, - focalLength: 100, - iso: 100, - exposureTime: '1/16', - fps: 100, - profileDescription: 'sRGB', - bitsPerSample: 8, - colorspace: 'sRGB', - autoStackId: null, - rating: 3, - updatedAt: today, - updateId: '42', - libraryId: null, - stackId: null, - visibility: AssetVisibility.Timeline, - width: 500, - height: 500, - tags: [], - }, - sharedLinks: [], - faces: [], - sidecarPath: null, - deletedAt: null, - duplicateId: null, - updateId: '42', - libraryId: null, - stackId: null, - visibility: AssetVisibility.Timeline, - width: 500, - height: 500, - isEdited: false, - }, - ], + assets: [], albumId: null, album: null, slug: null, diff --git a/server/test/fixtures/tag.stub.ts b/server/test/fixtures/tag.stub.ts index ca66af7b94f93..8382dec14296e 100644 --- a/server/test/fixtures/tag.stub.ts +++ b/server/test/fixtures/tag.stub.ts @@ -55,15 +55,15 @@ export const tagStub = { export const tagResponseStub = { tag1: Object.freeze({ id: 'tag-1', - createdAt: new Date('2021-01-01T00:00:00Z'), - updatedAt: new Date('2021-01-01T00:00:00Z'), + createdAt: '2021-01-01T00:00:00.000Z', + updatedAt: '2021-01-01T00:00:00.000Z', name: 'Tag1', value: 'Tag1', }), color1: Object.freeze({ id: 'tag-1', - createdAt: new Date('2021-01-01T00:00:00Z'), - updatedAt: new Date('2021-01-01T00:00:00Z'), + createdAt: '2021-01-01T00:00:00.000Z', + updatedAt: '2021-01-01T00:00:00.000Z', color: '#000000', name: 'Tag1', value: 'Tag1', diff --git a/server/test/mappers.ts b/server/test/mappers.ts index 7ccd61a48cbf8..7f324663be402 100644 --- a/server/test/mappers.ts +++ b/server/test/mappers.ts @@ -1,7 +1,15 @@ -import { Selectable } from 'kysely'; +import { Selectable, ShallowDehydrateObject } from 'kysely'; +import { AssetEditActionItem } from 'src/dtos/editing.dto'; +import { ActivityTable } from 'src/schema/tables/activity.table'; import { AssetTable } from 'src/schema/tables/asset.table'; +import { PartnerTable } from 'src/schema/tables/partner.table'; +import { AlbumFactory } from 'test/factories/album.factory'; import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFactory } from 'test/factories/asset.factory'; +import { MemoryFactory } from 'test/factories/memory.factory'; +import { SharedLinkFactory } from 'test/factories/shared-link.factory'; +import { StackFactory } from 'test/factories/stack.factory'; +import { UserFactory } from 'test/factories/user.factory'; export const getForStorageTemplate = (asset: ReturnType) => { return { @@ -47,6 +55,170 @@ export const getForFacialRecognitionJob = ( asset: Pick, 'ownerId' | 'visibility' | 'fileCreatedAt'> | null, ) => ({ ...face, - asset, + asset: asset + ? { ownerId: asset.ownerId, visibility: asset.visibility, fileCreatedAt: asset.fileCreatedAt.toISOString() } + : null, faceSearch: { faceId: face.id, embedding: '[1, 2, 3, 4]' }, }); + +export const getDehydrated = >(entity: T) => { + const copiedEntity = structuredClone(entity); + for (const [key, value] of Object.entries(copiedEntity)) { + if (value instanceof Date) { + Object.assign(copiedEntity, { [key]: value.toISOString() }); + continue; + } + } + + return copiedEntity as ShallowDehydrateObject; +}; + +export const getForAlbum = (album: ReturnType) => ({ + ...album, + assets: album.assets.map((asset) => + getDehydrated({ ...getForAsset(asset), exifInfo: getDehydrated(asset.exifInfo) }), + ), + albumUsers: album.albumUsers.map((albumUser) => ({ + ...albumUser, + createdAt: albumUser.createdAt.toISOString(), + user: getDehydrated(albumUser.user), + })), + owner: getDehydrated(album.owner), + sharedLinks: album.sharedLinks.map((sharedLink) => getDehydrated(sharedLink)), +}); + +export const getForActivity = (activity: Selectable & { user: ReturnType }) => ({ + ...activity, + user: getDehydrated(activity.user), +}); + +export const getForAsset = (asset: ReturnType) => { + return { + ...asset, + faces: asset.faces.map((face) => ({ + ...getDehydrated(face), + person: face.person ? getDehydrated(face.person) : null, + })), + owner: getDehydrated(asset.owner), + stack: asset.stack + ? { ...getDehydrated(asset.stack), assets: asset.stack.assets.map((asset) => getDehydrated(asset)) } + : null, + files: asset.files.map((file) => getDehydrated(file)), + exifInfo: asset.exifInfo ? getDehydrated(asset.exifInfo) : null, + edits: asset.edits.map(({ action, parameters }) => ({ action, parameters })) as AssetEditActionItem[], + }; +}; + +export const getForPartner = ( + partner: Selectable & Record<'sharedWith' | 'sharedBy', ReturnType>, +) => ({ + ...partner, + sharedBy: getDehydrated(partner.sharedBy), + sharedWith: getDehydrated(partner.sharedWith), +}); + +export const getForMemory = (memory: ReturnType) => ({ + ...memory, + assets: memory.assets.map((asset) => getDehydrated(asset)), +}); + +export const getForMetadataExtraction = (asset: ReturnType) => ({ + id: asset.id, + checksum: asset.checksum, + deviceAssetId: asset.deviceAssetId, + deviceId: asset.deviceId, + fileCreatedAt: asset.fileCreatedAt, + fileModifiedAt: asset.fileModifiedAt, + isExternal: asset.isExternal, + visibility: asset.visibility, + libraryId: asset.libraryId, + livePhotoVideoId: asset.livePhotoVideoId, + localDateTime: asset.localDateTime, + originalFileName: asset.originalFileName, + originalPath: asset.originalPath, + ownerId: asset.ownerId, + type: asset.type, + width: asset.width, + height: asset.height, + faces: asset.faces.map((face) => getDehydrated(face)), + files: asset.files.map((file) => getDehydrated(file)), +}); + +export const getForGenerateThumbnail = (asset: ReturnType) => ({ + id: asset.id, + visibility: asset.visibility, + originalFileName: asset.originalFileName, + originalPath: asset.originalPath, + ownerId: asset.ownerId, + thumbhash: asset.thumbhash, + type: asset.type, + files: asset.files.map((file) => getDehydrated(file)), + exifInfo: getDehydrated(asset.exifInfo), + edits: asset.edits.map(({ action, parameters }) => ({ action, parameters })) as AssetEditActionItem[], +}); + +export const getForAssetFace = (face: ReturnType) => ({ + ...face, + person: face.person ? getDehydrated(face.person) : null, +}); + +export const getForDetectedFaces = (asset: ReturnType) => ({ + id: asset.id, + visibility: asset.visibility, + exifInfo: getDehydrated(asset.exifInfo), + faces: asset.faces.map((face) => getDehydrated(face)), + files: asset.files.map((file) => getDehydrated(file)), +}); + +export const getForSidecarWrite = (asset: ReturnType) => ({ + id: asset.id, + originalPath: asset.originalPath, + files: asset.files.map((file) => getDehydrated(file)), + exifInfo: getDehydrated(asset.exifInfo), +}); + +export const getForAssetDeletion = (asset: ReturnType) => ({ + id: asset.id, + visibility: asset.visibility, + libraryId: asset.libraryId, + ownerId: asset.ownerId, + livePhotoVideoId: asset.livePhotoVideoId, + originalPath: asset.originalPath, + isOffline: asset.isOffline, + exifInfo: asset.exifInfo ? getDehydrated(asset.exifInfo) : null, + files: asset.files.map((file) => getDehydrated(file)), + stack: asset.stack + ? { + ...getDehydrated(asset.stack), + assets: asset.stack.assets.filter(({ id }) => id !== asset.stack?.primaryAssetId).map(({ id }) => ({ id })), + } + : null, +}); + +export const getForStack = (stack: ReturnType) => ({ + ...stack, + assets: stack.assets.map((asset) => ({ + ...getDehydrated(asset), + exifInfo: getDehydrated(asset.exifInfo), + })), +}); + +export const getForDuplicate = (asset: ReturnType) => ({ + ...getDehydrated(asset), + exifInfo: getDehydrated(asset.exifInfo), +}); + +export const getForSharedLink = (sharedLink: ReturnType) => ({ + ...sharedLink, + assets: sharedLink.assets.map((asset) => ({ + ...getDehydrated({ ...getForAsset(asset) }), + exifInfo: getDehydrated(asset.exifInfo), + })), + album: sharedLink.album + ? { + ...getDehydrated(sharedLink.album), + owner: getDehydrated(sharedLink.album.owner), + assets: sharedLink.album.assets.map((asset) => getDehydrated(asset)), + } + : null, +}); diff --git a/server/test/medium/specs/repositories/asset.repository.spec.ts b/server/test/medium/specs/repositories/asset.repository.spec.ts index 97f503e9edbc7..896489672ec91 100644 --- a/server/test/medium/specs/repositories/asset.repository.spec.ts +++ b/server/test/medium/specs/repositories/asset.repository.spec.ts @@ -1,9 +1,11 @@ import { Kysely } from 'kysely'; +import { AssetOrder, AssetVisibility } from 'src/enum'; import { AssetRepository } from 'src/repositories/asset.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { DB } from 'src/schema'; import { BaseService } from 'src/services/base.service'; import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; import { getKyselyDB } from 'test/utils'; let defaultDatabase: Kysely; @@ -22,6 +24,61 @@ beforeAll(async () => { }); describe(AssetRepository.name, () => { + describe('getTimeBucket', () => { + it('should order assets by local day first and fileCreatedAt within each day', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + const [{ asset: previousLocalDayAsset }, { asset: nextLocalDayEarlierAsset }, { asset: nextLocalDayLaterAsset }] = + await Promise.all([ + ctx.newAsset({ + ownerId: user.id, + fileCreatedAt: new Date('2026-03-09T00:30:00.000Z'), + localDateTime: new Date('2026-03-08T22:30:00.000Z'), + }), + ctx.newAsset({ + ownerId: user.id, + fileCreatedAt: new Date('2026-03-08T23:30:00.000Z'), + localDateTime: new Date('2026-03-09T01:30:00.000Z'), + }), + ctx.newAsset({ + ownerId: user.id, + fileCreatedAt: new Date('2026-03-08T23:45:00.000Z'), + localDateTime: new Date('2026-03-09T01:45:00.000Z'), + }), + ]); + + await Promise.all([ + ctx.newExif({ assetId: previousLocalDayAsset.id, timeZone: 'UTC-2' }), + ctx.newExif({ assetId: nextLocalDayEarlierAsset.id, timeZone: 'UTC+2' }), + ctx.newExif({ assetId: nextLocalDayLaterAsset.id, timeZone: 'UTC+2' }), + ]); + + const descendingBucket = await sut.getTimeBucket( + '2026-03-01', + { order: AssetOrder.Desc, userIds: [user.id], visibility: AssetVisibility.Timeline }, + auth, + ); + expect(JSON.parse(descendingBucket.assets)).toEqual( + expect.objectContaining({ + id: [nextLocalDayLaterAsset.id, nextLocalDayEarlierAsset.id, previousLocalDayAsset.id], + }), + ); + + const ascendingBucket = await sut.getTimeBucket( + '2026-03-01', + { order: AssetOrder.Asc, userIds: [user.id], visibility: AssetVisibility.Timeline }, + auth, + ); + expect(JSON.parse(ascendingBucket.assets)).toEqual( + expect.objectContaining({ + id: [previousLocalDayAsset.id, nextLocalDayEarlierAsset.id, nextLocalDayLaterAsset.id], + }), + ); + }); + }); + describe('upsertExif', () => { it('should append to locked columns', async () => { const { ctx, sut } = setup(); diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index db59437735b43..744d3b0f1e422 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -119,8 +119,8 @@ const partnerFactory = ({ sharedWith: sharedWithProvided, ...partner }: Partial = {}) => { - const sharedBy = UserFactory.create(sharedByProvided ?? {}); - const sharedWith = UserFactory.create(sharedWithProvided ?? {}); + const sharedBy = UserFactory.create((sharedByProvided as any) ?? {}); + const sharedWith = UserFactory.create((sharedWithProvided as any) ?? {}); return { sharedById: sharedBy.id, diff --git a/web/package.json b/web/package.json index 0bd2b3dfcdc01..a0d34d9afea8e 100644 --- a/web/package.json +++ b/web/package.json @@ -61,6 +61,8 @@ "svelte-maplibre": "^1.2.5", "svelte-persisted-store": "^0.12.0", "tabbable": "^6.2.0", + "tailwind-merge": "^3.5.0", + "tailwind-variants": "^3.2.2", "thumbhash": "^0.1.1", "transformation-matrix": "^3.1.0", "uplot": "^1.6.32" @@ -99,7 +101,7 @@ "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^3.3.3", "rollup-plugin-visualizer": "^6.0.0", - "svelte": "5.53.5", + "svelte": "5.53.7", "svelte-check": "^4.1.5", "svelte-eslint-parser": "^1.3.3", "tailwindcss": "^4.1.7", diff --git a/web/src/lib/actions/image-loader.svelte.ts b/web/src/lib/actions/image-loader.svelte.ts new file mode 100644 index 0000000000000..49a53dac2676b --- /dev/null +++ b/web/src/lib/actions/image-loader.svelte.ts @@ -0,0 +1,25 @@ +import { cancelImageUrl } from '$lib/utils/sw-messaging'; + +export function loadImage(src: string, onLoad: () => void, onError: () => void, onStart?: () => void) { + let destroyed = false; + + const handleLoad = () => !destroyed && onLoad(); + const handleError = () => !destroyed && onError(); + + const img = document.createElement('img'); + img.addEventListener('load', handleLoad); + img.addEventListener('error', handleError); + + onStart?.(); + img.src = src; + + return () => { + destroyed = true; + img.removeEventListener('load', handleLoad); + img.removeEventListener('error', handleError); + cancelImageUrl(src); + img.remove(); + }; +} + +export type LoadImageFunction = typeof loadImage; diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index 602ed9bd63f47..35c3d3a10650c 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -2,7 +2,11 @@ import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { createZoomImageWheel } from '@zoom-image/core'; export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => { - const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState }); + const zoomInstance = createZoomImageWheel(node, { + maxZoom: 10, + initialState: assetViewerManager.zoomState, + zoomTarget: null, + }); const unsubscribes = [ assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }), @@ -19,7 +23,25 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea node.addEventListener('wheel', onInteractionStart, { capture: true }); node.addEventListener('pointerdown', onInteractionStart, { capture: true }); + // Suppress Safari's synthetic dblclick on double-tap. Without this, zoom-image's touchstart + // handler zooms to maxZoom (10x), then Safari's synthetic dblclick triggers photo-viewer's + // handler which conflicts. Chrome does not fire synthetic dblclick on touch. + let lastPointerWasTouch = false; + const trackPointerType = (event: PointerEvent) => { + lastPointerWasTouch = event.pointerType === 'touch'; + }; + const suppressTouchDblClick = (event: MouseEvent) => { + if (lastPointerWasTouch) { + event.stopImmediatePropagation(); + } + }; + node.addEventListener('pointerdown', trackPointerType, { capture: true }); + node.addEventListener('dblclick', suppressTouchDblClick, { capture: true }); + + // Allow zoomed content to render outside the container bounds node.style.overflow = 'visible'; + // Prevent browser handling of touch gestures so zoom-image can manage them + node.style.touchAction = 'none'; return { update(newOptions?: { disabled?: boolean }) { options = newOptions; @@ -30,6 +52,8 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea } node.removeEventListener('wheel', onInteractionStart, { capture: true }); node.removeEventListener('pointerdown', onInteractionStart, { capture: true }); + node.removeEventListener('pointerdown', trackPointerType, { capture: true }); + node.removeEventListener('dblclick', suppressTouchDblClick, { capture: true }); zoomInstance.cleanup(); }, }; diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte new file mode 100644 index 0000000000000..fad4d49d1b7e7 --- /dev/null +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -0,0 +1,229 @@ + + +
+ {@render backdrop?.()} + + +
+
+ {#if show.alphaBackground} + + {/if} + + {#if show.thumbhash} + {#if asset.thumbhash} + + + {:else if show.spinner} + + {/if} + {/if} + + {#if show.thumbnail} + + {/if} + + {#if show.brokenAsset} + + {/if} + + {#if show.preview} + + {/if} + + {#if show.original} + + {/if} +
+
+
diff --git a/web/src/lib/components/AlphaBackground.svelte b/web/src/lib/components/AlphaBackground.svelte new file mode 100644 index 0000000000000..5c3869d587232 --- /dev/null +++ b/web/src/lib/components/AlphaBackground.svelte @@ -0,0 +1,12 @@ + + +
diff --git a/web/src/lib/components/DelayedLoadingSpinner.svelte b/web/src/lib/components/DelayedLoadingSpinner.svelte new file mode 100644 index 0000000000000..d18d373566c7f --- /dev/null +++ b/web/src/lib/components/DelayedLoadingSpinner.svelte @@ -0,0 +1,20 @@ + + +
+ +
+ + diff --git a/web/src/lib/components/Image.svelte b/web/src/lib/components/Image.svelte index 5c1f6006f3ac6..d6da045a6bd84 100644 --- a/web/src/lib/components/Image.svelte +++ b/web/src/lib/components/Image.svelte @@ -1,4 +1,5 @@ + +{#key adaptiveImageLoader} +
+ adaptiveImageLoader.onStart(quality)} + onLoad={() => adaptiveImageLoader.onLoad(quality)} + onError={() => adaptiveImageLoader.onError(quality)} + bind:ref + class="h-full w-full bg-transparent" + {alt} + {role} + draggable={false} + data-testid={quality} + /> + {@render overlays?.()} +
+{/key} diff --git a/web/src/lib/components/LoadingDots.svelte b/web/src/lib/components/LoadingDots.svelte new file mode 100644 index 0000000000000..7e6692021fd68 --- /dev/null +++ b/web/src/lib/components/LoadingDots.svelte @@ -0,0 +1,47 @@ + + +
+ {#each [0, 1, 2] as i (i)} + + {/each} +
+ + diff --git a/web/src/lib/components/QueueCard.svelte b/web/src/lib/components/QueueCard.svelte index b7cde7b8f1557..448558ed9f411 100644 --- a/web/src/lib/components/QueueCard.svelte +++ b/web/src/lib/components/QueueCard.svelte @@ -1,4 +1,5 @@ - -
+
{@render children?.()}
diff --git a/web/src/lib/components/QueueCardButton.svelte b/web/src/lib/components/QueueCardButton.svelte index f71d8a3e447af..9964b8fd1a99f 100644 --- a/web/src/lib/components/QueueCardButton.svelte +++ b/web/src/lib/components/QueueCardButton.svelte @@ -4,6 +4,7 @@ - diff --git a/web/src/lib/components/QueueGraph.svelte b/web/src/lib/components/QueueGraph.svelte index 21c2d3c2d934d..46de7adf15f33 100644 --- a/web/src/lib/components/QueueGraph.svelte +++ b/web/src/lib/components/QueueGraph.svelte @@ -1,4 +1,5 @@ -
+
{#if data[0].length === 0} {/if} diff --git a/web/src/lib/components/admin-settings/AuthSettings.svelte b/web/src/lib/components/admin-settings/AuthSettings.svelte index aec17619985cc..25af7bf2c1e30 100644 --- a/web/src/lib/components/admin-settings/AuthSettings.svelte +++ b/web/src/lib/components/admin-settings/AuthSettings.svelte @@ -11,7 +11,7 @@ import AuthDisableLoginConfirmModal from '$lib/modals/AuthDisableLoginConfirmModal.svelte'; import { handleError } from '$lib/utils/handle-error'; import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin } from '@immich/sdk'; - import { Button, modalManager, Text, toastManager } from '@immich/ui'; + import { Button, Link, modalManager, Text, toastManager } from '@immich/ui'; import { mdiRestart } from '@mdi/js'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -75,14 +75,7 @@ {#snippet children({ message })} - - {message} - + {message} {/snippet} diff --git a/web/src/lib/components/admin-settings/BackupSettings.svelte b/web/src/lib/components/admin-settings/BackupSettings.svelte index fc374ddd6f0c4..7fd22a2b6d819 100644 --- a/web/src/lib/components/admin-settings/BackupSettings.svelte +++ b/web/src/lib/components/admin-settings/BackupSettings.svelte @@ -7,6 +7,7 @@ import FormatMessage from '$lib/elements/FormatMessage.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; + import { Link } from '@immich/ui'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -52,15 +53,10 @@

{#snippet children({ message })} - + {message}
-
+ {/snippet}

diff --git a/web/src/lib/components/admin-settings/FFmpegSettings.svelte b/web/src/lib/components/admin-settings/FFmpegSettings.svelte index e062b616b3837..95aa9d74f2d35 100644 --- a/web/src/lib/components/admin-settings/FFmpegSettings.svelte +++ b/web/src/lib/components/admin-settings/FFmpegSettings.svelte @@ -18,7 +18,7 @@ VideoCodec, VideoContainer, } from '@immich/sdk'; - import { Icon } from '@immich/ui'; + import { Icon, Link } from '@immich/ui'; import { mdiHelpCircleOutline } from '@mdi/js'; import { isEqual, sortBy } from 'lodash-es'; import { t } from 'svelte-i18n'; @@ -38,17 +38,11 @@ {#snippet children({ tag, message })} {#if tag === 'h264-link'} - - {message} - + {message} {:else if tag === 'hevc-link'} - - {message} - + {message} {:else if tag === 'vp9-link'} - - {message} - + {message} {/if} {/snippet} diff --git a/web/src/lib/components/admin-settings/LibrarySettings.svelte b/web/src/lib/components/admin-settings/LibrarySettings.svelte index a91a5eb97adf5..52c2eb8d4ff36 100644 --- a/web/src/lib/components/admin-settings/LibrarySettings.svelte +++ b/web/src/lib/components/admin-settings/LibrarySettings.svelte @@ -8,6 +8,7 @@ import FormatMessage from '$lib/elements/FormatMessage.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; + import { Link } from '@immich/ui'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -73,14 +74,11 @@

{#snippet children({ message })} - {message} - + {/snippet}

diff --git a/web/src/lib/components/admin-settings/MapSettings.svelte b/web/src/lib/components/admin-settings/MapSettings.svelte index 692a5cfcf5e20..5888c82611c83 100644 --- a/web/src/lib/components/admin-settings/MapSettings.svelte +++ b/web/src/lib/components/admin-settings/MapSettings.svelte @@ -7,6 +7,7 @@ import FormatMessage from '$lib/elements/FormatMessage.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; + import { Link } from '@immich/ui'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -54,14 +55,7 @@

{#snippet children({ message })} - - {message} - + {message} {/snippet}

diff --git a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte index 6a07f817f2363..bb29b0001c27f 100644 --- a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte +++ b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte @@ -12,7 +12,7 @@ import { handleSystemConfigSave } from '$lib/services/system-config.service'; import { user } from '$lib/stores/user.store'; import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk'; - import { Heading, Text } from '@immich/ui'; + import { Heading, Link, Text } from '@immich/ui'; import handlebar from 'handlebars'; import * as luxon from 'luxon'; import { onDestroy } from 'svelte'; @@ -113,23 +113,11 @@ {#snippet children({ tag, message })} {#if tag === 'template-link'} - - {message} - + {message} {:else if tag === 'implications-link'} - + {message} - + {/if} {/snippet} diff --git a/web/src/lib/components/album-page/album-cover.svelte b/web/src/lib/components/album-page/album-cover.svelte index c6242c5fad5a8..2af2be77f624e 100644 --- a/web/src/lib/components/album-page/album-cover.svelte +++ b/web/src/lib/components/album-page/album-cover.svelte @@ -11,7 +11,7 @@ class?: string; } - let { album, preload = false, class: className = '' }: Props = $props(); + let { album, preload = false, class: className }: Props = $props(); let alt = $derived(album.albumName || $t('unnamed_album')); let thumbnailUrl = $derived( diff --git a/web/src/lib/components/asset-viewer/PreloadManager.svelte.ts b/web/src/lib/components/asset-viewer/PreloadManager.svelte.ts new file mode 100644 index 0000000000000..38da1dc08d17b --- /dev/null +++ b/web/src/lib/components/asset-viewer/PreloadManager.svelte.ts @@ -0,0 +1,104 @@ +import { loadImage } from '$lib/actions/image-loader.svelte'; +import { getAssetUrls } from '$lib/utils'; +import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte'; +import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk'; + +type AssetCursor = { + current: AssetResponseDto; + nextAsset?: AssetResponseDto; + previousAsset?: AssetResponseDto; +}; + +export class PreloadManager { + private nextPreloader: AdaptiveImageLoader | undefined; + private previousPreloader: AdaptiveImageLoader | undefined; + + private startPreloader( + asset: AssetResponseDto | undefined, + sharedlink: SharedLinkResponseDto | undefined, + ): AdaptiveImageLoader | undefined { + if (!asset) { + return; + } + const urls = getAssetUrls(asset, sharedlink); + const afterThumbnail = (loader: AdaptiveImageLoader) => loader.trigger('preview'); + const qualityList: QualityList = [ + { + quality: 'thumbnail', + url: urls.thumbnail, + onAfterLoad: afterThumbnail, + onAfterError: afterThumbnail, + }, + { + quality: 'preview', + url: urls.preview, + onAfterError: (loader) => loader.trigger('original'), + }, + { quality: 'original', url: urls.original }, + ]; + const loader = new AdaptiveImageLoader(qualityList, undefined, loadImage); + loader.start(); + return loader; + } + + private destroyPreviousPreloader() { + this.previousPreloader?.destroy(); + this.previousPreloader = undefined; + } + + private destroyNextPreloader() { + this.nextPreloader?.destroy(); + this.nextPreloader = undefined; + } + + cancelBeforeNavigation(direction: 'previous' | 'next') { + switch (direction) { + case 'next': { + this.destroyPreviousPreloader(); + break; + } + case 'previous': { + this.destroyNextPreloader(); + break; + } + } + } + + updateAfterNavigation(oldCursor: AssetCursor, newCursor: AssetCursor, sharedlink: SharedLinkResponseDto | undefined) { + const movedForward = newCursor.current.id === oldCursor.nextAsset?.id; + const movedBackward = newCursor.current.id === oldCursor.previousAsset?.id; + + if (!movedBackward) { + this.destroyPreviousPreloader(); + } + + if (!movedForward) { + this.destroyNextPreloader(); + } + + if (movedForward) { + this.nextPreloader = this.startPreloader(newCursor.nextAsset, sharedlink); + } else if (movedBackward) { + this.previousPreloader = this.startPreloader(newCursor.previousAsset, sharedlink); + } else { + this.previousPreloader = this.startPreloader(newCursor.previousAsset, sharedlink); + this.nextPreloader = this.startPreloader(newCursor.nextAsset, sharedlink); + } + } + + initializePreloads(cursor: AssetCursor, sharedlink: SharedLinkResponseDto | undefined) { + if (cursor.nextAsset) { + this.nextPreloader = this.startPreloader(cursor.nextAsset, sharedlink); + } + if (cursor.previousAsset) { + this.previousPreloader = this.startPreloader(cursor.previousAsset, sharedlink); + } + } + + destroy() { + this.destroyNextPreloader(); + this.destroyPreviousPreloader(); + } +} + +export const preloadManager = new PreloadManager(); diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 9515d0c0270a7..2fd53b6c4623d 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -34,7 +34,9 @@ type PersonResponseDto, type StackResponseDto, } from '@immich/sdk'; - import { ActionButton, CommandPaletteDefaultProvider, type ActionItem } from '@immich/ui'; + import { ActionButton, CommandPaletteDefaultProvider, Tooltip, type ActionItem } from '@immich/ui'; + import LoadingDots from '$lib/components/LoadingDots.svelte'; + import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { mdiArrowLeft, mdiArrowRight, @@ -104,7 +106,16 @@
-
+
+ {#if assetViewerManager.isImageLoading} + + {#snippet child({ props })} +
+ +
+ {/snippet} +
+ {/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.spec.ts b/web/src/lib/components/asset-viewer/asset-viewer.spec.ts new file mode 100644 index 0000000000000..a1f50da86ad47 --- /dev/null +++ b/web/src/lib/components/asset-viewer/asset-viewer.spec.ts @@ -0,0 +1,76 @@ +import { getAnimateMock } from '$lib/__mocks__/animate.mock'; +import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock'; +import { preferences as preferencesStore, resetSavedUser, user as userStore } from '$lib/stores/user.store'; +import { renderWithTooltips } from '$tests/helpers'; +import { updateAsset } from '@immich/sdk'; +import { assetFactory } from '@test-data/factories/asset-factory'; +import { preferencesFactory } from '@test-data/factories/preferences-factory'; +import { userAdminFactory } from '@test-data/factories/user-factory'; +import { fireEvent, waitFor } from '@testing-library/svelte'; +import AssetViewer from './asset-viewer.svelte'; + +vi.mock('$lib/managers/feature-flags-manager.svelte', () => ({ + featureFlagsManager: { + init: vi.fn(), + loadFeatureFlags: vi.fn(), + value: { smartSearch: true, trash: true }, + } as never, +})); + +vi.mock('$lib/stores/ocr.svelte', () => ({ + ocrManager: { + clear: vi.fn(), + getAssetOcr: vi.fn(), + hasOcrData: false, + showOverlay: false, + }, +})); + +vi.mock('@immich/sdk', async () => { + const sdk = await vi.importActual('@immich/sdk'); + return { + ...sdk, + updateAsset: vi.fn(), + }; +}); + +describe('AssetViewer', () => { + beforeAll(() => { + Element.prototype.animate = getAnimateMock(); + vi.stubGlobal('ResizeObserver', getResizeObserverMock()); + }); + + afterEach(() => { + resetSavedUser(); + vi.clearAllMocks(); + }); + + afterAll(() => { + vi.restoreAllMocks(); + }); + + it('updates the top bar favorite action after pressing favorite', async () => { + const ownerId = 'owner-id'; + const user = userAdminFactory.build({ id: ownerId }); + const asset = assetFactory.build({ ownerId, isFavorite: false, isTrashed: false }); + + userStore.set(user); + preferencesStore.set(preferencesFactory.build({ cast: { gCastEnabled: false } })); + vi.mocked(updateAsset).mockResolvedValue({ ...asset, isFavorite: true }); + + const { getByLabelText, queryByLabelText } = renderWithTooltips(AssetViewer, { + cursor: { current: asset }, + showNavigation: false, + }); + + expect(getByLabelText('to_favorite')).toBeInTheDocument(); + expect(queryByLabelText('unfavorite')).toBeNull(); + + await fireEvent.click(getByLabelText('to_favorite')); + + await waitFor(() => + expect(updateAsset).toHaveBeenCalledWith({ id: asset.id, updateAssetDto: { isFavorite: true } }), + ); + await waitFor(() => expect(getByLabelText('unfavorite')).toBeInTheDocument()); + }); +}); diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 2a75ca4e83ab1..3f7b048c8fa11 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -5,15 +5,17 @@ import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte'; import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte'; import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte'; + import { preloadManager } from '$lib/components/asset-viewer/PreloadManager.svelte'; + import OnEvents from '$lib/components/OnEvents.svelte'; import { AssetAction, ProjectionType } from '$lib/constants'; import { activityManager } from '$lib/managers/activity-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; - import { imageManager } from '$lib/managers/ImageManager.svelte'; import { getAssetActions } from '$lib/services/asset.service'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; @@ -36,6 +38,7 @@ } from '@immich/sdk'; import { CommandPaletteDefaultProvider } from '@immich/ui'; import { onDestroy, onMount, untrack } from 'svelte'; + import type { SwipeCustomEvent } from 'svelte-gestures'; import { t } from 'svelte-i18n'; import { fly } from 'svelte/transition'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; @@ -92,20 +95,19 @@ stopProgress: stopSlideshowProgress, slideshowNavigation, slideshowState, - slideshowTransition, slideshowRepeat, } = slideshowStore; const stackThumbnailSize = 60; const stackSelectedThumbnailSize = 65; - const asset = $derived(cursor.current); + let previewStackedAsset: AssetResponseDto | undefined = $state(); + let stack: StackResponseDto | null = $state(null); + + const asset = $derived(previewStackedAsset ?? cursor.current); const nextAsset = $derived(cursor.nextAsset); const previousAsset = $derived(cursor.previousAsset); let sharedLink = getSharedLink(); - let previewStackedAsset: AssetResponseDto | undefined = $state(); let fullscreenElement = $state(); - let unsubscribes: (() => void)[] = []; - let stack: StackResponseDto | null = $state(null); let playOriginalVideo = $state($alwaysLoadOriginalVideo); let slideshowStartAssetId = $state(); @@ -115,7 +117,7 @@ }; const refreshStack = async () => { - if (authManager.isSharedLink) { + if (authManager.isSharedLink || !withStacked) { return; } @@ -126,51 +128,56 @@ if (!stack?.assets.some(({ id }) => id === asset.id)) { stack = null; } - - untrack(() => { - imageManager.preload(stack?.assets[1]); - }); }; const handleFavorite = async () => { - if (album && album.isActivityEnabled) { - try { - await activityManager.toggleLike(); - } catch (error) { - handleError(error, $t('errors.unable_to_change_favorite')); - } + if (!album || !album.isActivityEnabled) { + return; + } + + try { + await activityManager.toggleLike(); + } catch (error) { + handleError(error, $t('errors.unable_to_change_favorite')); + } + }; + + const onAssetUpdate = (updatedAsset: AssetResponseDto) => { + if (asset.id === updatedAsset.id) { + cursor = { ...cursor, current: updatedAsset }; } }; onMount(() => { syncAssetViewerOpenClass(true); - unsubscribes.push( - slideshowState.subscribe((value) => { - if (value === SlideshowState.PlaySlideshow) { - slideshowHistory.reset(); - slideshowHistory.queue(toTimelineAsset(asset)); - handlePromiseError(handlePlaySlideshow()); - } else if (value === SlideshowState.StopSlideshow) { - handlePromiseError(handleStopSlideshow()); - } - }), - slideshowNavigation.subscribe((value) => { - if (value === SlideshowNavigation.Shuffle) { - slideshowHistory.reset(); - slideshowHistory.queue(toTimelineAsset(asset)); - } - }), - ); + const slideshowStateUnsubscribe = slideshowState.subscribe((value) => { + if (value === SlideshowState.PlaySlideshow) { + slideshowHistory.reset(); + slideshowHistory.queue(toTimelineAsset(asset)); + handlePromiseError(handlePlaySlideshow()); + } else if (value === SlideshowState.StopSlideshow) { + handlePromiseError(handleStopSlideshow()); + } + }); + + const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => { + if (value === SlideshowNavigation.Shuffle) { + slideshowHistory.reset(); + slideshowHistory.queue(toTimelineAsset(asset)); + } + }); + + return () => { + slideshowStateUnsubscribe(); + slideshowNavigationUnsubscribe(); + }; }); onDestroy(() => { - for (const unsubscribe of unsubscribes) { - unsubscribe(); - } - activityManager.reset(); assetViewerManager.closeEditor(); syncAssetViewerOpenClass(false); + preloadManager.destroy(); }); const closeViewer = () => { @@ -187,8 +194,7 @@ }; const tracker = new InvocationTracker(); - - const navigateAsset = (order?: 'previous' | 'next', e?: Event) => { + const navigateAsset = (order?: 'previous' | 'next') => { if (!order) { if ($slideshowState === SlideshowState.PlaySlideshow) { order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next'; @@ -197,16 +203,19 @@ } } - e?.stopPropagation(); - imageManager.cancel(asset); + preloadManager.cancelBeforeNavigation(order); + if (tracker.isActive()) { return; } void tracker.invoke(async () => { + const isShuffle = + $slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle; + let hasNext: boolean; - if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { + if (isShuffle) { hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); if (!hasNext) { const asset = await onRandom?.(); @@ -220,17 +229,22 @@ order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset); } - if ($slideshowState === SlideshowState.PlaySlideshow) { - if (hasNext) { - $restartSlideshowProgress = true; - } else if ($slideshowRepeat && slideshowStartAssetId) { - // Loop back to starting asset - await setAssetId(slideshowStartAssetId); - $restartSlideshowProgress = true; - } else { - await handleStopSlideshow(); - } + if ($slideshowState !== SlideshowState.PlaySlideshow) { + return; } + + if (hasNext) { + $restartSlideshowProgress = true; + return; + } + + if ($slideshowRepeat && slideshowStartAssetId) { + await setAssetId(slideshowStartAssetId); + $restartSlideshowProgress = true; + return; + } + + await handleStopSlideshow(); }, $t('error_while_navigating')); }; @@ -274,12 +288,14 @@ } }; - const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => { - previewStackedAsset = isMouseOver ? asset : undefined; + const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => { + previewStackedAsset = isMouseOver ? stackedAsset : undefined; }; + const handlePreAction = (action: Action) => { preAction?.(action); }; + const handleAction = async (action: Action) => { switch (action.type) { case AssetAction.DELETE: @@ -352,17 +368,31 @@ await ocrManager.getAssetOcr(asset.id); } }; + $effect(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions asset; untrack(() => handlePromiseError(refresh())); - imageManager.preload(cursor.nextAsset); - imageManager.preload(cursor.previousAsset); + }); + + let lastCursor = $state(); + + $effect(() => { + if (cursor.current.id === lastCursor?.current.id) { + return; + } + if (lastCursor) { + preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink); + } + if (!lastCursor) { + preloadManager.initializePreloads(cursor, sharedLink); + } + lastCursor = cursor; }); const viewerKind = $derived.by(() => { if (previewStackedAsset) { - return previewStackedAsset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer'; + return previewStackedAsset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer'; } if (asset.type === AssetTypeEnum.Video) { return 'VideoViewer'; @@ -396,16 +426,35 @@ ocrManager.hasOcrData, ); - const { Tag } = $derived(getAssetActions($t, asset)); + const { Tag, TagPeople } = $derived(getAssetActions($t, asset)); const showDetailPanel = $derived( asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !assetViewerManager.isShowEditor, ); + + const onSwipe = (event: SwipeCustomEvent) => { + if (assetViewerManager.zoom > 1) { + return; + } + + if (ocrManager.showOverlay) { + return; + } + + if (event.detail.direction === 'left') { + navigateAsset('next'); + } + + if (event.detail.direction === 'right') { + navigateAsset('previous'); + } + }; - + + @@ -448,23 +497,15 @@
{/if} - {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset} + {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
navigateAsset('previous')} />
{/if} -
- {#if viewerKind === 'StackPhotoViewer'} - navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - haveFadeTransition={false} - {sharedLink} - /> - {:else if viewerKind === 'StackVideoViewer'} +
+ {#if viewerKind === 'StackVideoViewer'} {:else if viewerKind === 'PhotoViewer'} - navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - {sharedLink} - haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition} - /> + {:else if viewerKind === 'VideoViewer'} - {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset} + {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
navigateAsset('next')} />
diff --git a/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.spec.ts b/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.spec.ts deleted file mode 100644 index d0d7f99ad36af..0000000000000 --- a/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock'; -import CropArea from '$lib/components/asset-viewer/editor/transform-tool/crop-area.svelte'; -import { transformManager } from '$lib/managers/edit/transform-manager.svelte'; -import { getAssetMediaUrl } from '$lib/utils'; -import { assetFactory } from '@test-data/factories/asset-factory'; -import { render } from '@testing-library/svelte'; -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; - -vi.mock('$lib/utils'); - -describe('CropArea', () => { - beforeAll(() => { - vi.stubGlobal('ResizeObserver', getResizeObserverMock()); - vi.mocked(getAssetMediaUrl).mockReturnValue('/mock-image.jpg'); - }); - - afterEach(() => { - transformManager.reset(); - }); - - it('clears cursor styles on reset', () => { - const asset = assetFactory.build(); - const { getByRole } = render(CropArea, { asset }); - const cropArea = getByRole('button', { name: 'Crop area' }); - - transformManager.region = { x: 100, y: 100, width: 200, height: 200 }; - transformManager.cropImageSize = { width: 1000, height: 1000 }; - transformManager.cropImageScale = 1; - transformManager.updateCursor(100, 150); - - expect(document.body.style.cursor).toBe('ew-resize'); - expect(cropArea.style.cursor).toBe('ew-resize'); - - transformManager.reset(); - - expect(document.body.style.cursor).toBe(''); - expect(cropArea.style.cursor).toBe(''); - }); - - it('sets cursor style at x: $x, y: $y to be $cursor', () => { - const data = [ - { x: 299, y: 84, cursor: '' }, - { x: 299, y: 85, cursor: 'nesw-resize' }, - { x: 299, y: 115, cursor: 'nesw-resize' }, - { x: 299, y: 116, cursor: 'ew-resize' }, - { x: 299, y: 284, cursor: 'ew-resize' }, - { x: 299, y: 285, cursor: 'nwse-resize' }, - { x: 299, y: 300, cursor: 'nwse-resize' }, - { x: 299, y: 301, cursor: '' }, - { x: 300, y: 84, cursor: '' }, - { x: 300, y: 85, cursor: 'nesw-resize' }, - { x: 300, y: 86, cursor: 'nesw-resize' }, - { x: 300, y: 114, cursor: 'nesw-resize' }, - { x: 300, y: 115, cursor: 'nesw-resize' }, - { x: 300, y: 116, cursor: 'ew-resize' }, - { x: 300, y: 284, cursor: 'ew-resize' }, - { x: 300, y: 285, cursor: 'nwse-resize' }, - { x: 300, y: 286, cursor: 'nwse-resize' }, - { x: 300, y: 300, cursor: 'nwse-resize' }, - { x: 300, y: 301, cursor: '' }, - { x: 301, y: 300, cursor: '' }, - { x: 301, y: 301, cursor: '' }, - ]; - - const element = document.createElement('div'); - - for (const { x, y, cursor } of data) { - const message = `x: ${x}, y: ${y} - ${cursor}`; - transformManager.reset(); - transformManager.region = { x: 100, y: 100, width: 200, height: 200 }; - transformManager.cropImageSize = { width: 600, height: 600 }; - transformManager.cropAreaEl = element; - transformManager.cropImageScale = 0.5; - transformManager.updateCursor(x, y); - expect(element.style.cursor, message).toBe(cursor); - expect(document.body.style.cursor, message).toBe(cursor); - } - }); -}); diff --git a/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte b/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte index 7a84612fe8629..011f30d445d64 100644 --- a/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte +++ b/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte @@ -1,9 +1,12 @@ -
- +
+ +
transformManager.handleMouseDownOn(e, ResizeBoundary.None)} + >
+ + {#each edges as edge (edge)} + {@const rotatedEdge = rotateBoundary(edges, edge, transformManager.normalizedRotation / 90)} + + {/each} + + {#each corners as corner (corner)} + {@const rotatedCorner = rotateBoundary(corners, corner, transformManager.normalizedRotation / 90)} + + {/each} +
+
diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index 39088b23de9c4..8b3d672bfe692 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -3,7 +3,7 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { getPeopleThumbnailUrl } from '$lib/utils'; - import { getContentMetrics, getNaturalSize } from '$lib/utils/container-utils'; + import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils'; import { handleError } from '$lib/utils/handle-error'; import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk'; import { Button, Input, modalManager, toastManager } from '@immich/ui'; @@ -74,6 +74,7 @@ canvas.add(faceRect); canvas.setActiveObject(faceRect); + setDefaultFaceRectanglePosition(faceRect); }; onMount(async () => { @@ -81,16 +82,31 @@ await getPeople(); }); - $effect(() => { - const metrics = getContentMetrics(htmlElement); - - const imageBoundingBox = { - top: metrics.offsetY, - left: metrics.offsetX, - width: metrics.contentWidth, - height: metrics.contentHeight, + const imageContentMetrics = $derived.by(() => { + const natural = getNaturalSize(htmlElement); + const container = { width: containerWidth, height: containerHeight }; + const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container); + return { + contentWidth, + contentHeight, + offsetX: (containerWidth - contentWidth) / 2, + offsetY: (containerHeight - contentHeight) / 2, }; + }); + + const setDefaultFaceRectanglePosition = (faceRect: Rect) => { + const { offsetX, offsetY } = imageContentMetrics; + + faceRect.set({ + top: offsetY + 200, + left: offsetX + 200, + }); + + faceRect.setCoords(); + positionFaceSelector(); + }; + $effect(() => { if (!canvas) { return; } @@ -104,15 +120,21 @@ return; } - faceRect.set({ - top: imageBoundingBox.top + 200, - left: imageBoundingBox.left + 200, - }); - - faceRect.setCoords(); - positionFaceSelector(); + if (!isFaceRectIntersectingCanvas(faceRect, canvas)) { + setDefaultFaceRectanglePosition(faceRect); + } }); + const isFaceRectIntersectingCanvas = (faceRect: Rect, canvas: Canvas) => { + const faceBox = faceRect.getBoundingRect(); + return !( + 0 > faceBox.left + faceBox.width || + 0 > faceBox.top + faceBox.height || + canvas.width < faceBox.left || + canvas.height < faceBox.top + ); + }; + const cancel = () => { isFaceEditMode.value = false; }; @@ -214,13 +236,13 @@ } const { left, top, width, height } = faceRect.getBoundingRect(); - const metrics = getContentMetrics(htmlElement); + const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics; const natural = getNaturalSize(htmlElement); - const scaleX = natural.width / metrics.contentWidth; - const scaleY = natural.height / metrics.contentHeight; - const imageX = (left - metrics.offsetX) * scaleX; - const imageY = (top - metrics.offsetY) * scaleY; + const scaleX = natural.width / contentWidth; + const scaleY = natural.height / contentHeight; + const imageX = (left - offsetX) * scaleX; + const imageY = (top - offsetY) * scaleY; return { imageWidth: natural.width, diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index e592024af87ab..55c765ce221f6 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -1,67 +1,56 @@
diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte index cf28dfa693ad9..a16bab2941966 100644 --- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte @@ -1,4 +1,5 @@ @@ -19,7 +20,7 @@ (isBroken = true)} - class="size-full rounded-xl object-cover aspect-square {className}" + class={cleanClass('size-full rounded-xl object-cover aspect-square', className)} data-testid="album-image" draggable="false" loading={preload ? 'eager' : 'lazy'} diff --git a/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte index 1e09c6bcfa9bc..319a5e7f9e11a 100644 --- a/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte +++ b/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte @@ -1,16 +1,18 @@ { + it('should return a string of class names', () => { + expect(cleanClass('class1', 'class2', 'class3')).toBe('class1 class2 class3'); + }); + + it('should filter out undefined, null, and false values', () => { + expect(cleanClass('class1', undefined, 'class2', null, 'class3', false)).toBe('class1 class2 class3'); + }); + + it('should unnest arrays', () => { + expect(cleanClass('class1', ['class2', 'class3'])).toBe('class1 class2 class3'); + }); +}); diff --git a/web/src/lib/index.ts b/web/src/lib/index.ts new file mode 100644 index 0000000000000..b4fc1956268c3 --- /dev/null +++ b/web/src/lib/index.ts @@ -0,0 +1,16 @@ +import { twMerge } from 'tailwind-merge'; + +export const cleanClass = (...classNames: unknown[]) => { + return twMerge( + classNames + .flatMap((className) => (Array.isArray(className) ? className : [className])) + .filter((className) => { + if (!className || typeof className === 'boolean') { + return false; + } + + return typeof className === 'string'; + }) + .join(' '), + ); +}; diff --git a/web/src/lib/managers/ImageManager.spec.ts b/web/src/lib/managers/ImageManager.spec.ts deleted file mode 100644 index 6147b3ac6f22a..0000000000000 --- a/web/src/lib/managers/ImageManager.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { imageManager } from '$lib/managers/ImageManager.svelte'; -import { getAssetMediaUrl } from '$lib/utils'; -import { cancelImageUrl } from '$lib/utils/sw-messaging'; -import { AssetMediaSize } from '@immich/sdk'; -import { assetFactory } from '@test-data/factories/asset-factory'; - -vi.mock('$lib/utils/sw-messaging', () => ({ - cancelImageUrl: vi.fn(), -})); - -vi.mock('$lib/utils', () => ({ - getAssetMediaUrl: vi.fn(), -})); - -describe('ImageManager', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('preload', () => { - it('creates an Image with the correct URL', () => { - vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media'); - const asset = assetFactory.build(); - - imageManager.preload(asset); - - expect(getAssetMediaUrl).toHaveBeenCalledWith({ - id: asset.id, - size: AssetMediaSize.Preview, - cacheKey: asset.thumbhash, - }); - }); - - it('does nothing for undefined asset', () => { - imageManager.preload(undefined); - expect(getAssetMediaUrl).not.toHaveBeenCalled(); - }); - - it('does nothing when getAssetMediaUrl returns falsy', () => { - vi.mocked(getAssetMediaUrl).mockReturnValue(''); - const asset = assetFactory.build(); - - imageManager.preload(asset); - - expect(getAssetMediaUrl).toHaveBeenCalled(); - }); - - it('uses the specified size', () => { - vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media'); - const asset = assetFactory.build(); - - imageManager.preload(asset, AssetMediaSize.Thumbnail); - - expect(getAssetMediaUrl).toHaveBeenCalledWith({ - id: asset.id, - size: AssetMediaSize.Thumbnail, - cacheKey: asset.thumbhash, - }); - }); - }); - - describe('cancel', () => { - it('calls cancelImageUrl with the correct URL', () => { - vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media'); - const asset = assetFactory.build(); - - imageManager.cancel(asset, AssetMediaSize.Preview); - - expect(cancelImageUrl).toHaveBeenCalledWith('/api/assets/123/media'); - }); - - it('does nothing for undefined asset', () => { - imageManager.cancel(undefined); - expect(getAssetMediaUrl).not.toHaveBeenCalled(); - expect(cancelImageUrl).not.toHaveBeenCalled(); - }); - - it('cancels all sizes when size is "all"', () => { - vi.mocked(getAssetMediaUrl).mockImplementation(({ size }) => `/api/assets/123/${size}`); - const asset = assetFactory.build(); - - imageManager.cancel(asset, 'all'); - - expect(getAssetMediaUrl).toHaveBeenCalledTimes(Object.values(AssetMediaSize).length); - for (const size of Object.values(AssetMediaSize)) { - expect(cancelImageUrl).toHaveBeenCalledWith(`/api/assets/123/${size}`); - } - }); - - it('does not call cancelImageUrl when URL is falsy', () => { - vi.mocked(getAssetMediaUrl).mockReturnValue(''); - const asset = assetFactory.build(); - - imageManager.cancel(asset, AssetMediaSize.Preview); - - expect(cancelImageUrl).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/web/src/lib/managers/ImageManager.svelte.ts b/web/src/lib/managers/ImageManager.svelte.ts deleted file mode 100644 index 004974d6773de..0000000000000 --- a/web/src/lib/managers/ImageManager.svelte.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { getAssetMediaUrl } from '$lib/utils'; -import { cancelImageUrl } from '$lib/utils/sw-messaging'; -import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk'; - -type AllAssetMediaSize = AssetMediaSize | 'all'; - -class ImageManager { - preload(asset: AssetResponseDto | undefined, size: AssetMediaSize = AssetMediaSize.Preview) { - if (!asset) { - return; - } - - const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash }); - if (!url) { - return; - } - - const img = new Image(); - img.src = url; - } - - cancel(asset: AssetResponseDto | undefined, size: AllAssetMediaSize = AssetMediaSize.Preview) { - if (!asset) { - return; - } - - const sizes = size === 'all' ? Object.values(AssetMediaSize) : [size]; - for (const size of sizes) { - const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash }); - if (url) { - cancelImageUrl(url); - } - } - } -} - -export const imageManager = new ImageManager(); diff --git a/web/src/lib/managers/asset-viewer-manager.svelte.ts b/web/src/lib/managers/asset-viewer-manager.svelte.ts index 0facbcdf47f61..0bab3aff80c97 100644 --- a/web/src/lib/managers/asset-viewer-manager.svelte.ts +++ b/web/src/lib/managers/asset-viewer-manager.svelte.ts @@ -1,3 +1,4 @@ +import type { ImageLoaderStatus } from '$lib/utils/adaptive-image-loader.svelte'; import { canCopyImageToClipboard } from '$lib/utils/asset-utils'; import { BaseEventManager } from '$lib/utils/base-event-manager.svelte'; import { PersistedLocalStorage } from '$lib/utils/persisted'; @@ -25,10 +26,24 @@ export class AssetViewerManager extends BaseEventManager { #animationFrameId: number | null = null; imgRef = $state(); + imageLoaderStatus = $state(); + #isImageLoading = $derived.by(() => { + const quality = this.imageLoaderStatus?.quality; + if (!quality) { + return false; + } + const previewOrOriginalReady = quality.preview === 'success' || quality.original === 'success'; + const loadingOriginal = this.zoom > 1 && quality.original !== 'success'; + return !previewOrOriginalReady || loadingOriginal; + }); isShowActivityPanel = $state(false); isPlayingMotionPhoto = $state(false); isShowEditor = $state(false); + get isImageLoading() { + return this.#isImageLoading; + } + get isShowDetailPanel() { return isShowDetailPanel.current; } diff --git a/web/src/lib/managers/edit/transform-manager.svelte.ts b/web/src/lib/managers/edit/transform-manager.svelte.ts index 77290d3e6da10..652cd0bee9716 100644 --- a/web/src/lib/managers/edit/transform-manager.svelte.ts +++ b/web/src/lib/managers/edit/transform-manager.svelte.ts @@ -1,9 +1,10 @@ -import { editManager, type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte'; +import { type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte'; import { getAssetMediaUrl } from '$lib/utils'; import { getDimensions } from '$lib/utils/asset-utils'; import { normalizeTransformEdits } from '$lib/utils/editor'; import { handleError } from '$lib/utils/handle-error'; import { AssetEditAction, AssetMediaSize, MirrorAxis, type AssetResponseDto, type CropParameters } from '@immich/sdk'; +import { clamp } from 'lodash-es'; import { tick } from 'svelte'; export type CropAspectRatio = @@ -37,17 +38,27 @@ type RegionConvertParams = { to: ImageDimensions; }; +export enum ResizeBoundary { + None = 'none', + TopLeft = 'top-left', + TopRight = 'top-right', + BottomLeft = 'bottom-left', + BottomRight = 'bottom-right', + Left = 'left', + Right = 'right', + Top = 'top', + Bottom = 'bottom', +} + class TransformManager implements EditToolManager { canReset: boolean = $derived.by(() => this.checkEdits()); hasChanges: boolean = $state(false); - darkenLevel = $state(0.65); isInteracting = $state(false); isDragging = $state(false); animationFrame = $state | null>(null); - canvasCursor = $state('default'); - dragOffset = $state({ x: 0, y: 0 }); - resizeSide = $state(''); + dragAnchor = $state({ x: 0, y: 0 }); + resizeSide = $state(ResizeBoundary.None); imgElement = $state(null); cropAreaEl = $state(null); overlayEl = $state(null); @@ -69,7 +80,6 @@ class TransformManager implements EditToolManager { const newAngle = this.imageRotation % 360; return newAngle < 0 ? newAngle + 360 : newAngle; }); - orientationChanged = $derived.by(() => this.normalizedRotation % 180 > 0); edits = $derived.by(() => this.getEdits()); @@ -81,9 +91,9 @@ class TransformManager implements EditToolManager { return; } - const newCrop = transformManager.recalculateCrop(aspectRatio); + const newCrop = this.recalculateCrop(aspectRatio); if (newCrop) { - transformManager.animateCropChange(this.cropAreaEl, this.region, newCrop); + this.animateCropChange(newCrop); this.region = newCrop; } } @@ -216,17 +226,11 @@ class TransformManager implements EditToolManager { } reset() { - this.darkenLevel = 0.65; this.isInteracting = false; this.animationFrame = null; - this.canvasCursor = 'default'; - this.dragOffset = { x: 0, y: 0 }; - this.resizeSide = ''; + this.dragAnchor = { x: 0, y: 0 }; + this.resizeSide = ResizeBoundary.None; this.imgElement = null; - if (this.cropAreaEl) { - this.cropAreaEl.style.cursor = ''; - } - document.body.style.cursor = ''; this.cropAreaEl = null; this.isDragging = false; this.overlayEl = null; @@ -295,12 +299,12 @@ class TransformManager implements EditToolManager { }; } - animateCropChange(element: HTMLElement, from: Region, to: Region, duration = 100) { - const cropFrame = element.querySelector('.crop-frame') as HTMLElement; - if (!cropFrame) { + animateCropChange(to: Region, duration = 100) { + if (!this.cropFrame) { return; } + const from = this.region; const startTime = performance.now(); const initialCrop = { ...from }; @@ -334,28 +338,6 @@ class TransformManager implements EditToolManager { return { newWidth, newHeight }; } - // Calculate constrained dimensions based on aspect ratio and limits - getConstrainedDimensions( - desiredWidth: number, - desiredHeight: number, - maxWidth: number, - maxHeight: number, - minSize = 50, - ) { - const { newWidth, newHeight } = this.adjustDimensions( - desiredWidth, - desiredHeight, - this.cropAspectRatio, - maxWidth, - maxHeight, - minSize, - ); - return { - width: Math.max(minSize, Math.min(newWidth, maxWidth)), - height: Math.max(minSize, Math.min(newHeight, maxHeight)), - }; - } - adjustDimensions( newWidth: number, newHeight: number, @@ -364,49 +346,45 @@ class TransformManager implements EditToolManager { yLimit: number, minSize: number, ) { + if (aspectRatio === 'free') { + return { + newWidth: clamp(newWidth, minSize, xLimit), + newHeight: clamp(newHeight, minSize, yLimit), + }; + } + let w = newWidth; let h = newHeight; - let aspectMultiplier: number; + const [ratioWidth, ratioHeight] = aspectRatio.split(':').map(Number); + const aspectMultiplier = ratioWidth && ratioHeight ? ratioWidth / ratioHeight : w / h; - if (aspectRatio === 'free') { - aspectMultiplier = newWidth / newHeight; - } else { - const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number); - aspectMultiplier = widthRatio && heightRatio ? widthRatio / heightRatio : newWidth / newHeight; - } - - if (aspectRatio !== 'free') { - h = w / aspectMultiplier; + h = w / aspectMultiplier; + // When dragging a corner, use the biggest region that fits 'inside' the mouse location. + if (h < newHeight) { + h = newHeight; + w = h * aspectMultiplier; } if (w > xLimit) { w = xLimit; - if (aspectRatio !== 'free') { - h = w / aspectMultiplier; - } + h = w / aspectMultiplier; } if (h > yLimit) { h = yLimit; - if (aspectRatio !== 'free') { - w = h * aspectMultiplier; - } + w = h * aspectMultiplier; } if (w < minSize) { w = minSize; - if (aspectRatio !== 'free') { - h = w / aspectMultiplier; - } + h = w / aspectMultiplier; } if (h < minSize) { h = minSize; - if (aspectRatio !== 'free') { - w = h * aspectMultiplier; - } + w = h * aspectMultiplier; } - if (aspectRatio !== 'free' && w / h !== aspectMultiplier) { + if (w / h !== aspectMultiplier) { if (w < minSize) { h = w / aspectMultiplier; } @@ -428,10 +406,6 @@ class TransformManager implements EditToolManager { this.cropFrame.style.width = `${crop.width}px`; this.cropFrame.style.height = `${crop.height}px`; - this.drawOverlay(crop); - } - - drawOverlay(crop: Region) { if (!this.overlayEl) { return; } @@ -465,7 +439,6 @@ class TransformManager implements EditToolManager { const cropFrameEl = this.cropFrame; cropFrameEl?.classList.add('transition'); this.region = this.normalizeCropArea(scale); - cropFrameEl?.classList.add('transition'); cropFrameEl?.addEventListener('transitionend', () => cropFrameEl?.classList.remove('transition'), { passive: true, }); @@ -540,7 +513,7 @@ class TransformManager implements EditToolManager { normalizeCropArea(scale: number) { const img = this.imgElement; if (!img) { - return { ...this.region }; + return this.region; } const scaleRatio = scale / this.cropImageScale; @@ -576,38 +549,17 @@ class TransformManager implements EditToolManager { this.draw(); } - handleMouseDown(e: MouseEvent) { - const canvas = this.cropAreaEl; - if (!canvas) { + handleMouseDownOn(e: MouseEvent, resizeBoundary: ResizeBoundary) { + if (e.button !== 0) { return; } - const { mouseX, mouseY } = this.getMousePosition(e); - - const { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - } = this.isOnCropBoundary(mouseX, mouseY); - - if ( - onTopLeftCorner || - onTopRightCorner || - onBottomLeftCorner || - onBottomRightCorner || - onLeftBoundary || - onRightBoundary || - onTopBoundary || - onBottomBoundary - ) { - this.setResizeSide(mouseX, mouseY); - } else if (this.isInCropArea(mouseX, mouseY)) { - this.startDragging(mouseX, mouseY); + this.isInteracting = true; + this.resizeSide = resizeBoundary; + if (resizeBoundary === ResizeBoundary.None) { + this.isDragging = true; + const { mouseX, mouseY } = this.getMousePosition(e); + this.dragAnchor = { x: mouseX - this.region.x, y: mouseY - this.region.y }; } document.body.style.userSelect = 'none'; @@ -615,20 +567,16 @@ class TransformManager implements EditToolManager { } handleMouseMove(e: MouseEvent) { - const canvas = this.cropAreaEl; - if (!canvas) { + if (!this.cropAreaEl) { return; } - const resizeSideValue = this.resizeSide; const { mouseX, mouseY } = this.getMousePosition(e); if (this.isDragging) { this.moveCrop(mouseX, mouseY); - } else if (resizeSideValue) { + } else if (this.resizeSide !== ResizeBoundary.None) { this.resizeCrop(mouseX, mouseY); - } else { - this.updateCursor(mouseX, mouseY); } } @@ -638,133 +586,44 @@ class TransformManager implements EditToolManager { this.isInteracting = false; this.isDragging = false; - this.resizeSide = ''; - this.fadeOverlay(true); // Darken the background + this.resizeSide = ResizeBoundary.None; } getMousePosition(e: MouseEvent) { - let offsetX = e.clientX; - let offsetY = e.clientY; - const clienRect = this.cropAreaEl?.getBoundingClientRect(); - const rotateDeg = this.normalizedRotation; - - if (rotateDeg == 90) { - offsetX = e.clientY - (clienRect?.top ?? 0); - offsetY = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0)); - } else if (rotateDeg == 180) { - offsetX = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0)); - offsetY = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0)); - } else if (rotateDeg == 270) { - offsetX = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0)); - offsetY = e.clientX - (clienRect?.left ?? 0); - } else if (rotateDeg == 0) { - offsetX -= clienRect?.left ?? 0; - offsetY -= clienRect?.top ?? 0; - } - return { mouseX: offsetX, mouseY: offsetY }; - } - - // Boundary detection helpers - private isInRange(value: number, target: number, sensitivity: number): boolean { - return value >= target - sensitivity && value <= target + sensitivity; - } - - private isWithinBounds(value: number, min: number, max: number): boolean { - return value >= min && value <= max; - } - - isOnCropBoundary(mouseX: number, mouseY: number) { - const { x, y, width, height } = this.region; - const sensitivity = 10; - const cornerSensitivity = 15; - const { width: imgWidth, height: imgHeight } = this.previewImageSize; - - const outOfBound = mouseX > imgWidth || mouseY > imgHeight || mouseX < 0 || mouseY < 0; - if (outOfBound) { - return { - onLeftBoundary: false, - onRightBoundary: false, - onTopBoundary: false, - onBottomBoundary: false, - onTopLeftCorner: false, - onTopRightCorner: false, - onBottomLeftCorner: false, - onBottomRightCorner: false, - }; + if (!this.cropAreaEl) { + throw new Error('Crop area is undefined'); } + const clientRect = this.cropAreaEl.getBoundingClientRect(); - const onLeftBoundary = this.isInRange(mouseX, x, sensitivity) && this.isWithinBounds(mouseY, y, y + height); - const onRightBoundary = - this.isInRange(mouseX, x + width, sensitivity) && this.isWithinBounds(mouseY, y, y + height); - const onTopBoundary = this.isInRange(mouseY, y, sensitivity) && this.isWithinBounds(mouseX, x, x + width); - const onBottomBoundary = - this.isInRange(mouseY, y + height, sensitivity) && this.isWithinBounds(mouseX, x, x + width); - - const onTopLeftCorner = - this.isInRange(mouseX, x, cornerSensitivity) && this.isInRange(mouseY, y, cornerSensitivity); - const onTopRightCorner = - this.isInRange(mouseX, x + width, cornerSensitivity) && this.isInRange(mouseY, y, cornerSensitivity); - const onBottomLeftCorner = - this.isInRange(mouseX, x, cornerSensitivity) && this.isInRange(mouseY, y + height, cornerSensitivity); - const onBottomRightCorner = - this.isInRange(mouseX, x + width, cornerSensitivity) && this.isInRange(mouseY, y + height, cornerSensitivity); - - return { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - }; - } - - isInCropArea(mouseX: number, mouseY: number) { - const { x, y, width, height } = this.region; - return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; - } - - setResizeSide(mouseX: number, mouseY: number) { - const { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - } = this.isOnCropBoundary(mouseX, mouseY); - - if (onTopLeftCorner) { - this.resizeSide = 'top-left'; - } else if (onTopRightCorner) { - this.resizeSide = 'top-right'; - } else if (onBottomLeftCorner) { - this.resizeSide = 'bottom-left'; - } else if (onBottomRightCorner) { - this.resizeSide = 'bottom-right'; - } else if (onLeftBoundary) { - this.resizeSide = 'left'; - } else if (onRightBoundary) { - this.resizeSide = 'right'; - } else if (onTopBoundary) { - this.resizeSide = 'top'; - } else if (onBottomBoundary) { - this.resizeSide = 'bottom'; + switch (this.normalizedRotation) { + case 90: { + return { + mouseX: e.clientY - clientRect.top, + mouseY: -e.clientX + clientRect.right, + }; + } + case 180: { + return { + mouseX: -e.clientX + clientRect.right, + mouseY: -e.clientY + clientRect.bottom, + }; + } + case 270: { + return { + mouseX: -e.clientY + clientRect.bottom, + mouseY: e.clientX - clientRect.left, + }; + } + // also case 0: + default: { + return { + mouseX: e.clientX - clientRect.left, + mouseY: e.clientY - clientRect.top, + }; + } } } - startDragging(mouseX: number, mouseY: number) { - this.isDragging = true; - const crop = this.region; - this.isInteracting = true; - this.dragOffset = { x: mouseX - crop.x, y: mouseY - crop.y }; - this.fadeOverlay(false); - } - moveCrop(mouseX: number, mouseY: number) { const cropArea = this.cropAreaEl; if (!cropArea) { @@ -772,102 +631,116 @@ class TransformManager implements EditToolManager { } this.hasChanges = true; - const newX = Math.max(0, Math.min(mouseX - this.dragOffset.x, cropArea.clientWidth - this.region.width)); - const newY = Math.max(0, Math.min(mouseY - this.dragOffset.y, cropArea.clientHeight - this.region.height)); - - this.region = { - ...this.region, - x: newX, - y: newY, - }; + this.region.x = clamp(mouseX - this.dragAnchor.x, 0, cropArea.clientWidth - this.region.width); + this.region.y = clamp(mouseY - this.dragAnchor.y, 0, cropArea.clientHeight - this.region.height); this.draw(); } resizeCrop(mouseX: number, mouseY: number) { const canvas = this.cropAreaEl; - const crop = this.region; - const resizeSideValue = this.resizeSide; - if (!canvas || !resizeSideValue) { + const currentCrop = this.region; + if (!canvas) { return; } - this.fadeOverlay(false); + this.isInteracting = true; this.hasChanges = true; - const { x, y, width, height } = crop; + const { x, y, width, height } = currentCrop; const minSize = 50; - let newRegion = { ...crop }; - - switch (resizeSideValue) { - case 'left': { - const desiredWidth = width + (x - mouseX); - if (desiredWidth >= minSize && mouseX >= 0) { - const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); - const finalWidth = Math.max(minSize, Math.min(w, canvas.clientWidth)); - const finalHeight = Math.max(minSize, Math.min(h, canvas.clientHeight)); - newRegion = { - x: Math.max(0, x + width - finalWidth), - y, - width: finalWidth, - height: finalHeight, - }; - } + let newRegion = { ...currentCrop }; + + let desiredWidth = width; + let desiredHeight = height; + + // Width + switch (this.resizeSide) { + case ResizeBoundary.Left: + case ResizeBoundary.TopLeft: + case ResizeBoundary.BottomLeft: { + desiredWidth = Math.max(minSize, width + (x - Math.max(mouseX, 0))); break; } - case 'right': { - const desiredWidth = mouseX - x; - if (desiredWidth >= minSize && mouseX <= canvas.clientWidth) { - const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); - newRegion = { - ...newRegion, - width: Math.max(minSize, Math.min(w, canvas.clientWidth - x)), - height: Math.max(minSize, Math.min(h, canvas.clientHeight)), - }; - } + case ResizeBoundary.Right: + case ResizeBoundary.TopRight: + case ResizeBoundary.BottomRight: { + desiredWidth = Math.max(minSize, Math.max(mouseX, 0) - x); break; } - case 'top': { - const desiredHeight = height + (y - mouseY); - if (desiredHeight >= minSize && mouseY >= 0) { - const { newWidth: w, newHeight: h } = this.adjustDimensions( - width, - desiredHeight, - this.cropAspectRatio, - canvas.clientWidth, - canvas.clientHeight, - minSize, - ); - newRegion = { - x, - y: Math.max(0, y + height - h), - width: w, - height: h, - }; - } + } + + // Height + switch (this.resizeSide) { + case ResizeBoundary.Top: + case ResizeBoundary.TopLeft: + case ResizeBoundary.TopRight: { + desiredHeight = Math.max(minSize, height + (y - Math.max(mouseY, 0))); break; } - case 'bottom': { - const desiredHeight = mouseY - y; - if (desiredHeight >= minSize && mouseY <= canvas.clientHeight) { - const { newWidth: w, newHeight: h } = this.adjustDimensions( - width, - desiredHeight, - this.cropAspectRatio, - canvas.clientWidth, - canvas.clientHeight - y, - minSize, - ); - newRegion = { - ...newRegion, - width: w, - height: h, - }; - } + case ResizeBoundary.Bottom: + case ResizeBoundary.BottomLeft: + case ResizeBoundary.BottomRight: { + desiredHeight = Math.max(minSize, Math.max(mouseY, 0) - y); + break; + } + } + + // Old + switch (this.resizeSide) { + case ResizeBoundary.Left: { + const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); + const finalWidth = clamp(w, minSize, canvas.clientWidth); + newRegion = { + x: Math.max(0, x + width - finalWidth), + y, + width: finalWidth, + height: clamp(h, minSize, canvas.clientHeight), + }; break; } - case 'top-left': { - const desiredWidth = width + (x - Math.max(mouseX, 0)); - const desiredHeight = height + (y - Math.max(mouseY, 0)); + case ResizeBoundary.Right: { + const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); + newRegion = { + ...newRegion, + width: clamp(w, minSize, canvas.clientWidth - x), + height: clamp(h, minSize, canvas.clientHeight), + }; + break; + } + case ResizeBoundary.Top: { + const { newWidth: w, newHeight: h } = this.adjustDimensions( + desiredWidth, + desiredHeight, + this.cropAspectRatio, + canvas.clientWidth, + canvas.clientHeight, + minSize, + ); + newRegion = { + x, + y: Math.max(0, y + height - h), + width: w, + height: h, + }; + break; + } + case ResizeBoundary.Bottom: { + const { newWidth: w, newHeight: h } = this.adjustDimensions( + desiredWidth, + desiredHeight, + this.cropAspectRatio, + canvas.clientWidth, + canvas.clientHeight - y, + minSize, + ); + newRegion = { + ...newRegion, + width: w, + height: h, + }; + break; + } + case ResizeBoundary.TopLeft: { const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredWidth, desiredHeight, @@ -884,9 +757,7 @@ class TransformManager implements EditToolManager { }; break; } - case 'top-right': { - const desiredWidth = Math.max(mouseX, 0) - x; - const desiredHeight = height + (y - Math.max(mouseY, 0)); + case ResizeBoundary.TopRight: { const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredWidth, desiredHeight, @@ -903,9 +774,7 @@ class TransformManager implements EditToolManager { }; break; } - case 'bottom-left': { - const desiredWidth = width + (x - Math.max(mouseX, 0)); - const desiredHeight = Math.max(mouseY, 0) - y; + case ResizeBoundary.BottomLeft: { const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredWidth, desiredHeight, @@ -922,9 +791,7 @@ class TransformManager implements EditToolManager { }; break; } - case 'bottom-right': { - const desiredWidth = Math.max(mouseX, 0) - x; - const desiredHeight = Math.max(mouseY, 0) - y; + case ResizeBoundary.BottomRight: { const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredWidth, desiredHeight, @@ -952,95 +819,6 @@ class TransformManager implements EditToolManager { this.draw(); } - updateCursor(mouseX: number, mouseY: number) { - if (!this.cropAreaEl) { - return; - } - - let { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - } = this.isOnCropBoundary(mouseX, mouseY); - - if (this.normalizedRotation == 90) { - [onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [ - onLeftBoundary, - onTopBoundary, - onRightBoundary, - onBottomBoundary, - ]; - - [onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [ - onBottomLeftCorner, - onTopLeftCorner, - onTopRightCorner, - onBottomRightCorner, - ]; - } else if (this.normalizedRotation == 180) { - [onTopBoundary, onBottomBoundary] = [onBottomBoundary, onTopBoundary]; - [onLeftBoundary, onRightBoundary] = [onRightBoundary, onLeftBoundary]; - - [onTopLeftCorner, onBottomRightCorner] = [onBottomRightCorner, onTopLeftCorner]; - [onTopRightCorner, onBottomLeftCorner] = [onBottomLeftCorner, onTopRightCorner]; - } else if (this.normalizedRotation == 270) { - [onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [ - onRightBoundary, - onBottomBoundary, - onLeftBoundary, - onTopBoundary, - ]; - - [onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [ - onTopRightCorner, - onBottomRightCorner, - onBottomLeftCorner, - onTopLeftCorner, - ]; - } - - let cursorName: string; - if (onTopLeftCorner || onBottomRightCorner) { - cursorName = 'nwse-resize'; - } else if (onTopRightCorner || onBottomLeftCorner) { - cursorName = 'nesw-resize'; - } else if (onLeftBoundary || onRightBoundary) { - cursorName = 'ew-resize'; - } else if (onTopBoundary || onBottomBoundary) { - cursorName = 'ns-resize'; - } else if (this.isInCropArea(mouseX, mouseY)) { - cursorName = 'move'; - } else { - cursorName = 'default'; - } - - if (this.canvasCursor != cursorName && this.cropAreaEl && !editManager.isShowingConfirmDialog) { - this.canvasCursor = cursorName; - document.body.style.cursor = cursorName; - this.cropAreaEl.style.cursor = cursorName; - } - } - - fadeOverlay(toDark: boolean) { - const overlay = this.overlayEl; - const cropFrame = document.querySelector('.crop-frame'); - - if (toDark) { - overlay?.classList.remove('light'); - cropFrame?.classList.remove('resizing'); - } else { - overlay?.classList.add('light'); - cropFrame?.classList.add('resizing'); - } - - this.isInteracting = !toDark; - } - resetCrop() { this.cropAspectRatio = 'free'; this.region = { diff --git a/web/src/lib/modals/AuthDisableLoginConfirmModal.svelte b/web/src/lib/modals/AuthDisableLoginConfirmModal.svelte index f12d6cd8a35ff..d9e215d945d54 100644 --- a/web/src/lib/modals/AuthDisableLoginConfirmModal.svelte +++ b/web/src/lib/modals/AuthDisableLoginConfirmModal.svelte @@ -1,6 +1,6 @@