mirror of
https://github.com/immich-app/immich.git
synced 2026-03-12 21:42:54 -07:00
Compare commits
21 Commits
8b23fb57ae
...
push-kulzw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ec63b428e | ||
|
|
6c531e0a5a | ||
|
|
471c27cd33 | ||
|
|
4773788a88 | ||
|
|
d49d995611 | ||
|
|
0ac3d6a83a | ||
|
|
9996ee12d0 | ||
|
|
0a79dd1228 | ||
|
|
e45308b949 | ||
|
|
c403e03a42 | ||
|
|
e7db3b220d | ||
|
|
28d5c169c0 | ||
|
|
0f2fe656db | ||
|
|
34ce68095d | ||
|
|
8764a1894b | ||
|
|
27f69b39b2 | ||
|
|
9fc6fbc373 | ||
|
|
9fc32b6f7a | ||
|
|
4571940a4e | ||
|
|
1ceb6d2e21 | ||
|
|
1a4c5d73ac |
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -131,7 +131,7 @@ jobs:
|
||||
- device: rocm
|
||||
suffixes: '-rocm'
|
||||
platforms: linux/amd64
|
||||
runner-mapping: '{"linux/amd64": "pokedex-giant"}'
|
||||
runner-mapping: '{"linux/amd64": "pokedex-large"}'
|
||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
149
.github/workflows/release.yml
vendored
149
.github/workflows/release.yml
vendored
@@ -1,149 +0,0 @@
|
||||
name: release.yml
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
paths:
|
||||
- CHANGELOG.md
|
||||
|
||||
jobs:
|
||||
# Maybe double check PR source branch?
|
||||
|
||||
merge_translations:
|
||||
uses: ./.github/workflows/merge-translations.yml
|
||||
permissions:
|
||||
pull-requests: write
|
||||
secrets:
|
||||
PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
|
||||
|
||||
build_mobile:
|
||||
uses: ./.github/workflows/build-mobile.yml
|
||||
needs: merge_translations
|
||||
permissions:
|
||||
contents: read
|
||||
secrets:
|
||||
KEY_JKS: ${{ secrets.KEY_JKS }}
|
||||
ALIAS: ${{ secrets.ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||
# iOS secrets
|
||||
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
|
||||
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
||||
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
||||
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl
|
||||
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
||||
with:
|
||||
ref: main
|
||||
environment: production
|
||||
|
||||
prepare_release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build_mobile
|
||||
permissions:
|
||||
actions: read # To download the app artifact
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
ref: main
|
||||
|
||||
- name: Extract changelog
|
||||
id: changelog
|
||||
run: |
|
||||
CHANGELOG_PATH=$RUNNER_TEMP/changelog.md
|
||||
sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH
|
||||
echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
|
||||
VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download APK
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: release-apk-signed
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Create draft release
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.result }}
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
body_path: ${{ steps.changelog.outputs.path }}
|
||||
draft: true
|
||||
files: |
|
||||
docker/docker-compose.yml
|
||||
docker/docker-compose.rootless.yml
|
||||
docker/example.env
|
||||
docker/hwaccel.ml.yml
|
||||
docker/hwaccel.transcoding.yml
|
||||
docker/prometheus.yml
|
||||
*.apk
|
||||
|
||||
- name: Rename Outline document
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
continue-on-error: true
|
||||
env:
|
||||
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
|
||||
VERSION: ${{ steps.changelog.outputs.version }}
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
const outlineKey = process.env.OUTLINE_API_KEY;
|
||||
const version = process.env.VERSION;
|
||||
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9';
|
||||
const baseUrl = 'https://outline.immich.cloud';
|
||||
|
||||
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${outlineKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ parentDocumentId })
|
||||
});
|
||||
|
||||
if (!listResponse.ok) {
|
||||
throw new Error(`Outline list failed: ${listResponse.statusText}`);
|
||||
}
|
||||
|
||||
const listData = await listResponse.json();
|
||||
const allDocuments = listData.data || [];
|
||||
const document = allDocuments.find(doc => doc.title === 'next');
|
||||
|
||||
if (document) {
|
||||
console.log(`Found document 'next', renaming to '${version}'...`);
|
||||
|
||||
const updateResponse = await fetch(`${baseUrl}/api/documents.update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${outlineKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: document.id,
|
||||
title: version
|
||||
})
|
||||
});
|
||||
|
||||
if (!updateResponse.ok) {
|
||||
throw new Error(`Failed to rename document: ${updateResponse.statusText}`);
|
||||
}
|
||||
} else {
|
||||
console.log('No document titled "next" found to rename');
|
||||
}
|
||||
@@ -1,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!);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1007,6 +1007,8 @@
|
||||
"editor_edits_applied_success": "Edits applied successfully",
|
||||
"editor_flip_horizontal": "Flip horizontal",
|
||||
"editor_flip_vertical": "Flip vertical",
|
||||
"editor_handle_corner": "{corner, select, top_left {Top-left} top_right {Top-right} bottom_left {Bottom-left} bottom_right {Bottom-right} other {A}} corner handle",
|
||||
"editor_handle_edge": "{edge, select, top {Top} bottom {Bottom} left {Left} right {Right} other {An}} edge handle",
|
||||
"editor_orientation": "Orientation",
|
||||
"editor_reset_all_changes": "Reset changes",
|
||||
"editor_rotate_left": "Rotate 90° counterclockwise",
|
||||
@@ -1072,7 +1074,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
}
|
||||
|
||||
@@ -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<Map<String, String>>(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<List<String>>(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<String, String>, serverUrls: List<String>) {
|
||||
fun setRequestHeaders(headerMap: Map<String, String>, serverUrls: List<String>, token: String?) {
|
||||
synchronized(this) {
|
||||
val builder = Headers.Builder()
|
||||
headerMap.forEach { (key, value) -> builder[key] = value }
|
||||
val newHeaders = builder.build()
|
||||
|
||||
val headersChanged = headers != newHeaders
|
||||
val 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<Cookie>()
|
||||
private var serverUrls = listOf<HttpUrl>()
|
||||
private var prefs: SharedPreferences? = null
|
||||
|
||||
|
||||
fun init(prefs: SharedPreferences) {
|
||||
this.prefs = prefs
|
||||
restore()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setServerUrls(urls: List<String>) {
|
||||
val parsed = urls.mapNotNull { it.toHttpUrlOrNull() }
|
||||
if (parsed.map { it.host } == serverUrls.map { it.host }) return
|
||||
serverUrls = parsed
|
||||
if (syncAuthCookies()) persist()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val changed = cookies.any { new ->
|
||||
store.none { it.name == new.name && it.domain == new.domain && it.path == new.path && it.value == new.value }
|
||||
}
|
||||
store.removeAll { existing ->
|
||||
cookies.any { it.name == existing.name && it.domain == existing.domain && it.path == existing.path }
|
||||
}
|
||||
store.addAll(cookies)
|
||||
val synced = serverUrls.any { it.host == url.host } && syncAuthCookies()
|
||||
if (changed || synced) persist()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
val now = System.currentTimeMillis()
|
||||
if (store.removeAll { it.expiresAt < now }) {
|
||||
syncAuthCookies()
|
||||
persist()
|
||||
}
|
||||
return store.filter { it.matches(url) }
|
||||
}
|
||||
|
||||
private fun syncAuthCookies(): Boolean {
|
||||
val serverHosts = serverUrls.map { it.host }.toSet()
|
||||
val now = System.currentTimeMillis()
|
||||
val sourceCookies = store
|
||||
.filter { it.name in AuthCookie.names && it.domain in serverHosts && it.expiresAt > now }
|
||||
.associateBy { it.name }
|
||||
|
||||
if (sourceCookies.isEmpty()) {
|
||||
return store.removeAll { it.name in AuthCookie.names && it.domain in serverHosts }
|
||||
}
|
||||
|
||||
var changed = false
|
||||
for (url in serverUrls) {
|
||||
for ((_, source) in sourceCookies) {
|
||||
if (store.any { it.name == source.name && it.domain == url.host && it.value == source.value }) continue
|
||||
store.removeAll { it.name == source.name && it.domain == url.host }
|
||||
store.add(rebuildCookie(source, url))
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun rebuildCookie(source: Cookie, url: HttpUrl): Cookie {
|
||||
return Cookie.Builder()
|
||||
.name(source.name).value(source.value)
|
||||
.domain(url.host).path("/")
|
||||
.expiresAt(source.expiresAt)
|
||||
.apply {
|
||||
if (url.isHttps) secure()
|
||||
if (source.httpOnly) httpOnly()
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
val p = prefs ?: return
|
||||
p.edit { putString(PREFS_COOKIES, Json.encodeToString(store.map { SerializedCookie.from(it) })) }
|
||||
}
|
||||
|
||||
private fun restore() {
|
||||
val p = prefs ?: return
|
||||
val jsonStr = p.getString(PREFS_COOKIES, null) ?: return
|
||||
try {
|
||||
store.addAll(Json.decodeFromString<List<SerializedCookie>>(jsonStr).map { it.toCookie() })
|
||||
} catch (_: Exception) {
|
||||
store.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class SerializedCookie(
|
||||
val name: String,
|
||||
val value: String,
|
||||
val domain: String,
|
||||
val path: String,
|
||||
val expiresAt: Long,
|
||||
val secure: Boolean,
|
||||
val httpOnly: Boolean,
|
||||
val hostOnly: Boolean,
|
||||
) {
|
||||
fun toCookie(): Cookie = Cookie.Builder()
|
||||
.name(name).value(value).path(path).expiresAt(expiresAt)
|
||||
.apply {
|
||||
if (hostOnly) hostOnlyDomain(domain) else domain(domain)
|
||||
if (secure) secure()
|
||||
if (httpOnly) httpOnly()
|
||||
}
|
||||
.build()
|
||||
|
||||
companion object {
|
||||
fun from(cookie: Cookie) = SerializedCookie(
|
||||
name = cookie.name, value = cookie.value, domain = cookie.domain,
|
||||
path = cookie.path, expiresAt = cookie.expiresAt, secure = cookie.secure,
|
||||
httpOnly = cookie.httpOnly, hostOnly = cookie.hostOnly,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ interface NetworkApi {
|
||||
fun removeCertificate(callback: (Result<Unit>) -> Unit)
|
||||
fun hasCertificate(): Boolean
|
||||
fun getClientPointer(): Long
|
||||
fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>)
|
||||
fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>, token: String?)
|
||||
|
||||
companion object {
|
||||
/** The codec used by NetworkApi. */
|
||||
@@ -287,8 +287,9 @@ interface NetworkApi {
|
||||
val args = message as List<Any?>
|
||||
val headersArg = args[0] as Map<String, String>
|
||||
val serverUrlsArg = args[1] as List<String>
|
||||
val tokenArg = args[2] as String?
|
||||
val wrapped: List<Any?> = try {
|
||||
api.setRequestHeaders(headersArg, serverUrlsArg)
|
||||
api.setRequestHeaders(headersArg, serverUrlsArg, tokenArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
NetworkPigeonUtils.wrapError(exception)
|
||||
|
||||
@@ -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>) -> Unit) {
|
||||
@@ -79,7 +79,7 @@ private class NetworkApiImpl() : NetworkApi {
|
||||
return HttpClientManager.getClientPointer()
|
||||
}
|
||||
|
||||
override fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>) {
|
||||
HttpClientManager.setRequestHeaders(headers, serverUrls)
|
||||
override fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>, token: String?) {
|
||||
HttpClientManager.setRequestHeaders(headers, serverUrls, token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -225,7 +225,7 @@ protocol NetworkApi {
|
||||
func removeCertificate(completion: @escaping (Result<Void, Error>) -> 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))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> = 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -26,8 +26,8 @@ class NetworkRepository {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> setHeaders(Map<String, String> headers, List<String> serverUrls) async {
|
||||
await networkApi.setRequestHeaders(headers, serverUrls);
|
||||
static Future<void> setHeaders(Map<String, String> headers, List<String> serverUrls, {String? token}) async {
|
||||
await networkApi.setRequestHeaders(headers, serverUrls, token);
|
||||
if (Platform.isIOS) {
|
||||
await init();
|
||||
}
|
||||
|
||||
@@ -148,10 +148,12 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
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()],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
4
mobile/lib/platform/network_api.g.dart
generated
4
mobile/lib/platform/network_api.g.dart
generated
@@ -281,7 +281,7 @@ class NetworkApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setRequestHeaders(Map<String, String> headers, List<String> serverUrls) async {
|
||||
Future<void> setRequestHeaders(Map<String, String> headers, List<String> serverUrls, String? token) async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
@@ -289,7 +289,7 @@ class NetworkApi {
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[headers, serverUrls]);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[headers, serverUrls, token]);
|
||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart' show InformationCollector;
|
||||
import 'package:flutter/painting.dart';
|
||||
|
||||
/// A [MultiFrameImageStreamCompleter] with support for listener tracking
|
||||
/// which makes resource cleanup possible when no longer needed.
|
||||
/// Codec is disposed through the MultiFrameImageStreamCompleter's internals onDispose method
|
||||
class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
|
||||
void Function()? _onLastListenerRemoved;
|
||||
int _listenerCount = 0;
|
||||
// True once any image or the codec has been provided.
|
||||
// Until then the image cache holds one listener, so "last real listener gone"
|
||||
// is _listenerCount == 1, not 0.
|
||||
bool didProvideImage = false;
|
||||
|
||||
AnimatedImageStreamCompleter._({
|
||||
required super.codec,
|
||||
required super.scale,
|
||||
super.informationCollector,
|
||||
void Function()? onLastListenerRemoved,
|
||||
}) : _onLastListenerRemoved = onLastListenerRemoved;
|
||||
|
||||
factory AnimatedImageStreamCompleter({
|
||||
required Stream<Object> stream,
|
||||
required double scale,
|
||||
ImageInfo? initialImage,
|
||||
InformationCollector? informationCollector,
|
||||
void Function()? onLastListenerRemoved,
|
||||
}) {
|
||||
final codecCompleter = Completer<ui.Codec>();
|
||||
final self = AnimatedImageStreamCompleter._(
|
||||
codec: codecCompleter.future,
|
||||
scale: scale,
|
||||
informationCollector: informationCollector,
|
||||
onLastListenerRemoved: onLastListenerRemoved,
|
||||
);
|
||||
|
||||
if (initialImage != null) {
|
||||
self.didProvideImage = true;
|
||||
self.setImage(initialImage);
|
||||
}
|
||||
|
||||
stream.listen(
|
||||
(item) {
|
||||
if (item is ImageInfo) {
|
||||
self.didProvideImage = true;
|
||||
self.setImage(item);
|
||||
} else if (item is ui.Codec) {
|
||||
if (!codecCompleter.isCompleted) {
|
||||
self.didProvideImage = true;
|
||||
codecCompleter.complete(item);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stack) {
|
||||
if (!codecCompleter.isCompleted) {
|
||||
codecCompleter.completeError(error, stack);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
// also complete if we are done but no error occurred, and we didn't call complete yet
|
||||
// could happen on cancellation
|
||||
if (!codecCompleter.isCompleted) {
|
||||
codecCompleter.completeError(StateError('Stream closed without providing a codec'));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
@override
|
||||
void addListener(ImageStreamListener listener) {
|
||||
super.addListener(listener);
|
||||
_listenerCount++;
|
||||
}
|
||||
|
||||
@override
|
||||
void removeListener(ImageStreamListener listener) {
|
||||
super.removeListener(listener);
|
||||
_listenerCount--;
|
||||
|
||||
final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage;
|
||||
final bool noListenersAfterCodec = _listenerCount == 0 && didProvideImage;
|
||||
|
||||
if (onlyCacheListenerLeft || noListenersAfterCodec) {
|
||||
final onLastListenerRemoved = _onLastListenerRemoved;
|
||||
if (onLastListenerRemoved != null) {
|
||||
_onLastListenerRemoved = null;
|
||||
onLastListenerRemoved();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
|
||||
final ImageProvider provider;
|
||||
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;
|
||||
|
||||
@@ -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<LocalFullImageProv
|
||||
final String id;
|
||||
final Size size;
|
||||
final AssetType assetType;
|
||||
final bool isAnimated;
|
||||
|
||||
LocalFullImageProvider({required this.id, required this.assetType, required this.size});
|
||||
LocalFullImageProvider({required this.id, required this.assetType, required this.size, required this.isAnimated});
|
||||
|
||||
@override
|
||||
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@@ -68,6 +68,21 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
|
||||
if (key.isAnimated) {
|
||||
return AnimatedImageStreamCompleter(
|
||||
stream: _animatedCodec(key, decode),
|
||||
scale: 1.0,
|
||||
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Id', key.id),
|
||||
DiagnosticsProperty<Size>('Size', key.size),
|
||||
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
|
||||
],
|
||||
onLastListenerRemoved: cancel,
|
||||
);
|
||||
}
|
||||
|
||||
return OneFramePlaceholderImageStreamCompleter(
|
||||
_codec(key, decode),
|
||||
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
|
||||
@@ -75,6 +90,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Id', key.id),
|
||||
DiagnosticsProperty<Size>('Size', key.size),
|
||||
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
|
||||
],
|
||||
onLastListenerRemoved: cancel,
|
||||
);
|
||||
@@ -110,15 +126,45 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
yield* loadRequest(request, decode);
|
||||
}
|
||||
|
||||
Stream<Object> _animatedCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||
yield* initialImageStream();
|
||||
|
||||
if (isCancelled) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
return;
|
||||
}
|
||||
|
||||
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||
final previewRequest = request = LocalImageRequest(
|
||||
localId: key.id,
|
||||
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
||||
assetType: key.assetType,
|
||||
);
|
||||
yield* loadRequest(previewRequest, decode);
|
||||
|
||||
if (isCancelled) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
return;
|
||||
}
|
||||
|
||||
// always try original for animated, since previews don't support animation
|
||||
final originalRequest = request = LocalImageRequest(localId: key.id, size: Size.zero, assetType: key.assetType);
|
||||
final codec = await loadCodecRequest(originalRequest);
|
||||
if (codec == null) {
|
||||
throw StateError('Failed to load animated codec for local asset ${key.id}');
|
||||
}
|
||||
yield codec;
|
||||
}
|
||||
|
||||
@override
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<RemoteFullImagePr
|
||||
final String assetId;
|
||||
final String thumbhash;
|
||||
final AssetType assetType;
|
||||
final bool isAnimated;
|
||||
|
||||
RemoteFullImageProvider({required this.assetId, required this.thumbhash, required this.assetType});
|
||||
RemoteFullImageProvider({
|
||||
required this.assetId,
|
||||
required this.thumbhash,
|
||||
required this.assetType,
|
||||
required this.isAnimated,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@@ -68,12 +75,27 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
|
||||
if (key.isAnimated) {
|
||||
return AnimatedImageStreamCompleter(
|
||||
stream: _animatedCodec(key, decode),
|
||||
scale: 1.0,
|
||||
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
|
||||
],
|
||||
onLastListenerRemoved: cancel,
|
||||
);
|
||||
}
|
||||
|
||||
return OneFramePlaceholderImageStreamCompleter(
|
||||
_codec(key, decode),
|
||||
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
|
||||
],
|
||||
onLastListenerRemoved: cancel,
|
||||
);
|
||||
@@ -106,16 +128,43 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
||||
yield* loadRequest(originalRequest, decode);
|
||||
}
|
||||
|
||||
Stream<Object> _animatedCodec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||
yield* initialImageStream();
|
||||
|
||||
if (isCancelled) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
return;
|
||||
}
|
||||
|
||||
final previewRequest = request = RemoteImageRequest(
|
||||
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
|
||||
);
|
||||
yield* loadRequest(previewRequest, decode, evictOnError: false);
|
||||
|
||||
if (isCancelled) {
|
||||
PaintingBinding.instance.imageCache.evict(this);
|
||||
return;
|
||||
}
|
||||
|
||||
// always try original for animated, since previews don't support animation
|
||||
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId));
|
||||
final codec = await loadCodecRequest(originalRequest);
|
||||
if (codec == null) {
|
||||
throw StateError('Failed to load animated codec for asset ${key.assetId}');
|
||||
}
|
||||
yield codec;
|
||||
}
|
||||
|
||||
@override
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
}
|
||||
|
||||
Future<bool> 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<AuthState> {
|
||||
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) {
|
||||
|
||||
@@ -38,10 +38,6 @@ class AuthRepository extends DatabaseRepository {
|
||||
});
|
||||
}
|
||||
|
||||
String getAccessToken() {
|
||||
return Store.get(StoreKey.accessToken);
|
||||
}
|
||||
|
||||
bool getEndpointSwitchingFeature() {
|
||||
return Store.tryGet(StoreKey.autoEndpointSwitching) ?? false;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class ApiService implements Authentication {
|
||||
class ApiService {
|
||||
late ApiClient _apiClient;
|
||||
|
||||
late UsersApi usersApi;
|
||||
@@ -45,7 +45,6 @@ class ApiService implements Authentication {
|
||||
setEndpoint(endpoint);
|
||||
}
|
||||
}
|
||||
String? _accessToken;
|
||||
final _log = Logger("ApiService");
|
||||
|
||||
Future<void> updateHeaders() async {
|
||||
@@ -54,11 +53,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);
|
||||
@@ -157,11 +153,6 @@ class ApiService implements Authentication {
|
||||
return "";
|
||||
}
|
||||
|
||||
Future<void> setAccessToken(String accessToken) async {
|
||||
_accessToken = accessToken;
|
||||
await Store.put(StoreKey.accessToken, accessToken);
|
||||
}
|
||||
|
||||
Future<void> setDeviceInfoHeader() async {
|
||||
DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
||||
|
||||
@@ -205,28 +196,12 @@ class ApiService implements Authentication {
|
||||
}
|
||||
|
||||
static Map<String, String> getRequestHeaders() {
|
||||
var accessToken = Store.get(StoreKey.accessToken, "");
|
||||
var customHeadersStr = Store.get(StoreKey.customHeaders, "");
|
||||
var header = <String, String>{};
|
||||
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<void> applyToParams(List<QueryParam> queryParams, Map<String, String> headerParams) {
|
||||
return Future.value();
|
||||
return (jsonDecode(customHeadersStr) as Map).cast<String, String>();
|
||||
}
|
||||
|
||||
ApiClient get apiClient => _apiClient;
|
||||
|
||||
@@ -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}");
|
||||
|
||||
|
||||
@@ -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<Asset> deleteCandidates,
|
||||
List<Asset> 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]);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
final hasVersion = Store.tryGet(StoreKey.version) != null;
|
||||
@@ -109,6 +111,16 @@ Future<void> 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);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A widget that animates implicitly between a play and a pause icon.
|
||||
class AnimatedPlayPause extends StatefulWidget {
|
||||
const AnimatedPlayPause({super.key, required this.playing, this.size, this.color});
|
||||
const AnimatedPlayPause({super.key, required this.playing, this.size, this.color, this.shadows});
|
||||
|
||||
final double? size;
|
||||
final bool playing;
|
||||
final Color? color;
|
||||
final List<Shadow>? shadows;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => AnimatedPlayPauseState();
|
||||
@@ -39,12 +42,32 @@ class AnimatedPlayPauseState extends State<AnimatedPlayPause> with SingleTickerP
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final icon = AnimatedIcon(
|
||||
color: widget.color,
|
||||
size: widget.size,
|
||||
icon: AnimatedIcons.play_pause,
|
||||
progress: animationController,
|
||||
);
|
||||
|
||||
return Center(
|
||||
child: AnimatedIcon(
|
||||
color: widget.color,
|
||||
size: widget.size,
|
||||
icon: AnimatedIcons.play_pause,
|
||||
progress: animationController,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
for (final shadow in widget.shadows ?? const <Shadow>[])
|
||||
Transform.translate(
|
||||
offset: shadow.offset,
|
||||
child: ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: shadow.blurRadius / 2, sigmaY: shadow.blurRadius / 2),
|
||||
child: AnimatedIcon(
|
||||
color: shadow.color,
|
||||
size: widget.size,
|
||||
icon: AnimatedIcons.play_pause,
|
||||
progress: animationController,
|
||||
),
|
||||
),
|
||||
),
|
||||
icon,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Shadow> _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),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,5 +43,5 @@ abstract class NetworkApi {
|
||||
|
||||
int getClientPointer();
|
||||
|
||||
void setRequestHeaders(Map<String, String> headers, List<String> serverUrls);
|
||||
void setRequestHeaders(Map<String, String> headers, List<String> serverUrls, String? token);
|
||||
}
|
||||
|
||||
1829
pnpm-lock.yaml
generated
1829
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -46,14 +46,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",
|
||||
@@ -66,7 +66,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",
|
||||
@@ -82,7 +82,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",
|
||||
|
||||
@@ -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<User>;
|
||||
role: AlbumUserRole;
|
||||
};
|
||||
|
||||
@@ -67,7 +68,7 @@ export type Activity = {
|
||||
updatedAt: Date;
|
||||
albumId: string;
|
||||
userId: string;
|
||||
user: User;
|
||||
user: ShallowDehydrateObject<User>;
|
||||
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<MapAsset>[];
|
||||
};
|
||||
|
||||
export type Asset = {
|
||||
@@ -159,9 +160,9 @@ export type StorageAsset = {
|
||||
export type Stack = {
|
||||
id: string;
|
||||
primaryAssetId: string;
|
||||
owner?: User;
|
||||
owner?: ShallowDehydrateObject<User>;
|
||||
ownerId: string;
|
||||
assets: MapAsset[];
|
||||
assets: ShallowDehydrateObject<MapAsset>[];
|
||||
assetCount?: number;
|
||||
};
|
||||
|
||||
@@ -177,11 +178,11 @@ export type AuthSharedLink = {
|
||||
|
||||
export type SharedLink = {
|
||||
id: string;
|
||||
album?: Album | null;
|
||||
album?: ShallowDehydrateObject<Album> | null;
|
||||
albumId: string | null;
|
||||
allowDownload: boolean;
|
||||
allowUpload: boolean;
|
||||
assets: MapAsset[];
|
||||
assets: ShallowDehydrateObject<MapAsset>[];
|
||||
createdAt: Date;
|
||||
description: string | null;
|
||||
expiresAt: Date | null;
|
||||
@@ -194,8 +195,8 @@ export type SharedLink = {
|
||||
};
|
||||
|
||||
export type Album = Selectable<AlbumTable> & {
|
||||
owner: User;
|
||||
assets: MapAsset[];
|
||||
owner: ShallowDehydrateObject<User>;
|
||||
assets: ShallowDehydrateObject<Selectable<AssetTable>>[];
|
||||
};
|
||||
|
||||
export type AuthSession = {
|
||||
@@ -205,9 +206,9 @@ export type AuthSession = {
|
||||
|
||||
export type Partner = {
|
||||
sharedById: string;
|
||||
sharedBy: User;
|
||||
sharedBy: ShallowDehydrateObject<User>;
|
||||
sharedWithId: string;
|
||||
sharedWith: User;
|
||||
sharedWith: ShallowDehydrateObject<User>;
|
||||
createdAt: Date;
|
||||
createId: string;
|
||||
updatedAt: Date;
|
||||
@@ -270,7 +271,7 @@ export type AssetFace = {
|
||||
imageWidth: number;
|
||||
personId: string | null;
|
||||
sourceType: SourceType;
|
||||
person?: Person | null;
|
||||
person?: ShallowDehydrateObject<Person> | null;
|
||||
updatedAt: Date;
|
||||
updateId: string;
|
||||
isVisible: boolean;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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<MapAsset>[];
|
||||
sharedLinks?: ShallowDehydrateObject<AuthSharedLink>[];
|
||||
albumName: string;
|
||||
description: string;
|
||||
albumThumbnailAssetId: string | null;
|
||||
@@ -200,12 +203,16 @@ export type MapAlbumDto = {
|
||||
updatedAt: Date;
|
||||
id: string;
|
||||
ownerId: string;
|
||||
owner: User;
|
||||
owner: ShallowDehydrateObject<User>;
|
||||
isActivityEnabled: boolean;
|
||||
order: AssetOrder;
|
||||
};
|
||||
|
||||
export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => {
|
||||
export const mapAlbum = (
|
||||
entity: MaybeDehydrated<MapAlbumDto>,
|
||||
withAssets: boolean,
|
||||
auth?: AuthDto,
|
||||
): AlbumResponseDto => {
|
||||
const albumUsers: AlbumUserResponseDto[] = [];
|
||||
|
||||
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<MapAlbumDto>) => mapAlbum(entity, true);
|
||||
export const mapAlbumWithoutAssets = (entity: MaybeDehydrated<MapAlbumDto>) => mapAlbum(entity, false);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
|
||||
import { 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);
|
||||
|
||||
@@ -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,13 @@ export type MapAsset = {
|
||||
deviceId: string;
|
||||
duplicateId: string | null;
|
||||
duration: string | null;
|
||||
edits?: AssetEditActionItem[];
|
||||
edits?: ShallowDehydrateObject<AssetEditActionItem>[];
|
||||
encodedVideoPath: string | null;
|
||||
exifInfo?: Selectable<Exif> | null;
|
||||
faces?: AssetFace[];
|
||||
exifInfo?: ShallowDehydrateObject<Selectable<Exif>> | null;
|
||||
faces?: ShallowDehydrateObject<AssetFace>[];
|
||||
fileCreatedAt: Date;
|
||||
fileModifiedAt: Date;
|
||||
files?: AssetFile[];
|
||||
files?: ShallowDehydrateObject<AssetFile>[];
|
||||
isExternal: boolean;
|
||||
isFavorite: boolean;
|
||||
isOffline: boolean;
|
||||
@@ -167,11 +168,11 @@ export type MapAsset = {
|
||||
localDateTime: Date;
|
||||
originalFileName: string;
|
||||
originalPath: string;
|
||||
owner?: User | null;
|
||||
owner?: ShallowDehydrateObject<User> | null;
|
||||
ownerId: string;
|
||||
stack?: Stack | null;
|
||||
stack?: (ShallowDehydrateObject<Stack> & { assets: Stack['assets'] }) | null;
|
||||
stackId: string | null;
|
||||
tags?: Tag[];
|
||||
tags?: ShallowDehydrateObject<Tag>[];
|
||||
thumbhash: Buffer<ArrayBufferLike> | null;
|
||||
type: AssetType;
|
||||
width: number | null;
|
||||
@@ -197,7 +198,7 @@ export type AssetMapOptions = {
|
||||
};
|
||||
|
||||
const peopleWithFaces = (
|
||||
faces?: AssetFace[],
|
||||
faces?: MaybeDehydrated<AssetFace>[],
|
||||
edits?: AssetEditActionItem[],
|
||||
assetDimensions?: ImageDimensions,
|
||||
): PersonWithFacesResponseDto[] => {
|
||||
@@ -213,7 +214,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 +238,7 @@ const mapStack = (entity: { stack?: Stack | null }) => {
|
||||
};
|
||||
};
|
||||
|
||||
export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): AssetResponseDto {
|
||||
export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOptions = {}): AssetResponseDto {
|
||||
const { stripMetadata = false, withStack = false } = options;
|
||||
|
||||
if (stripMetadata) {
|
||||
@@ -243,7 +247,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 +261,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 +272,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 +287,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,
|
||||
|
||||
@@ -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<Exif>): 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,
|
||||
|
||||
@@ -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') })
|
||||
@@ -222,21 +226,21 @@ export class PeopleResponseDto {
|
||||
hasNextPage?: boolean;
|
||||
}
|
||||
|
||||
export function mapPerson(person: Person): PersonResponseDto {
|
||||
export function mapPerson(person: MaybeDehydrated<Person>): 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),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapFacesWithoutPerson(
|
||||
face: Selectable<AssetFaceTable>,
|
||||
face: MaybeDehydrated<Selectable<AssetFaceTable>>,
|
||||
edits?: AssetEditActionItem[],
|
||||
assetDimensions?: ImageDimensions,
|
||||
): AssetFaceWithoutPersonResponseDto {
|
||||
|
||||
@@ -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<Tag>): 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<User | UserAdmin>): 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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<DB, 'album'>) => {
|
||||
.selectFrom('asset')
|
||||
.selectAll('asset')
|
||||
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.select((eb) => eb.table('asset_exif').$castTo<Exif>().as('exifInfo'))
|
||||
.select((eb) =>
|
||||
eb.table('asset_exif').$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>().as('exifInfo'),
|
||||
)
|
||||
.innerJoin('album_asset', 'album_asset.assetId', 'asset.id')
|
||||
.whereRef('album_asset.albumId', '=', 'album.id')
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
|
||||
@@ -9,7 +9,6 @@ import { DB } from 'src/schema';
|
||||
import {
|
||||
anyUuid,
|
||||
asUuid,
|
||||
toJson,
|
||||
withDefaultVisibility,
|
||||
withEdits,
|
||||
withExif,
|
||||
@@ -296,7 +295,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();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
NotNull,
|
||||
Selectable,
|
||||
SelectQueryBuilder,
|
||||
ShallowDehydrateObject,
|
||||
sql,
|
||||
Updateable,
|
||||
UpdateResult,
|
||||
@@ -554,7 +555,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<ShallowDehydrateObject<Selectable<AssetTable>>>('array_agg', [eb.table('stacked')])
|
||||
.as('assets'),
|
||||
)
|
||||
.whereRef('stacked.stackId', '=', 'stack.id')
|
||||
.whereRef('stacked.id', '!=', 'stack.primaryAssetId')
|
||||
.where('stacked.deletedAt', 'is', null)
|
||||
@@ -563,7 +568,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<Stack | null>().as('stack')),
|
||||
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')),
|
||||
),
|
||||
)
|
||||
.$if(!!files, (qb) => qb.select(withFiles))
|
||||
@@ -744,6 +749,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
|
||||
@@ -841,7 +847,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
|
||||
|
||||
@@ -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<ShallowDehydrateObject<Selectable<AssetExifTable>>>().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<MapAsset[]>().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 }>()
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
|
||||
|
||||
AndroidMake?: string;
|
||||
AndroidModel?: string;
|
||||
DeviceManufacturer?: string;
|
||||
DeviceModelName?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -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';
|
||||
@@ -433,7 +433,7 @@ export class SearchRepository {
|
||||
.select((eb) =>
|
||||
eb
|
||||
.fn('to_jsonb', [eb.table('asset_exif')])
|
||||
.$castTo<Selectable<AssetExifTable>>()
|
||||
.$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>()
|
||||
.as('exifInfo'),
|
||||
)
|
||||
.orderBy('asset_exif.city')
|
||||
|
||||
@@ -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 { 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<MapAsset[]>()
|
||||
.$castTo<
|
||||
(ShallowDehydrateObject<Selectable<AssetTable>> & {
|
||||
exifInfo: ShallowDehydrateObject<Selectable<AssetExifTable>>;
|
||||
})[]
|
||||
>()
|
||||
.as('assets'),
|
||||
)
|
||||
.groupBy(['shared_link.id', sql`"album".*`])
|
||||
.select((eb) => eb.fn.toJson('album').$castTo<Album | null>().as('album'))
|
||||
.select((eb) => eb.fn.toJson(eb.table('album')).$castTo<ShallowDehydrateObject<Album> | null>().as('album'))
|
||||
.where('shared_link.id', '=', id)
|
||||
.where('shared_link.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<MapAsset[]>()
|
||||
.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<Album | null>().as('album'))
|
||||
.select((eb) => eb.fn.toJson('album').$castTo<ShallowDehydrateObject<Album> | null>().as('album'))
|
||||
.where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)]))
|
||||
.$if(!!albumId, (eb) => eb.where('shared_link.albumId', '=', albumId!))
|
||||
.$if(!!id, (eb) => eb.where('shared_link.id', '=', id!))
|
||||
@@ -246,6 +249,7 @@ export class SharedLinkRepository {
|
||||
await this.db.deleteFrom('shared_link').where('shared_link.id', '=', id).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
private getSharedLinks(id: string) {
|
||||
return this.db
|
||||
.selectFrom('shared_link')
|
||||
@@ -269,7 +273,11 @@ export class SharedLinkRepository {
|
||||
.select((eb) =>
|
||||
eb.fn
|
||||
.coalesce(eb.fn.jsonAgg('assets').filterWhere('assets.id', 'is not', null), sql`'[]'`)
|
||||
.$castTo<MapAsset[]>()
|
||||
.$castTo<
|
||||
(ShallowDehydrateObject<Selectable<AssetTable>> & {
|
||||
exifInfo: ShallowDehydrateObject<Selectable<AssetExifTable>>;
|
||||
})[]
|
||||
>()
|
||||
.as('assets'),
|
||||
)
|
||||
.groupBy('shared_link.id')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ReactionType } from 'src/dtos/activity.dto';
|
||||
import { ActivityService } from 'src/services/activity.service';
|
||||
import { getForActivity } from 'test/mappers';
|
||||
import { factory, newUuid, newUuids } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@@ -78,7 +79,7 @@ describe(ActivityService.name, () => {
|
||||
const activity = factory.activity({ 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 } }), {
|
||||
albumId,
|
||||
@@ -101,7 +102,7 @@ describe(ActivityService.name, () => {
|
||||
const activity = factory.activity({ 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' }),
|
||||
@@ -113,7 +114,7 @@ describe(ActivityService.name, () => {
|
||||
const activity = factory.activity({ 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 });
|
||||
@@ -127,7 +128,7 @@ describe(ActivityService.name, () => {
|
||||
|
||||
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 });
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,13 +4,12 @@ import {
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Stats } from 'node:fs';
|
||||
import { AssetFile } from 'src/database';
|
||||
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
|
||||
import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { AssetMediaCreateDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||
import { AssetFileType, AssetStatus, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum';
|
||||
import { AssetFileType, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { UploadBody } from 'src/types';
|
||||
@@ -22,6 +21,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');
|
||||
@@ -152,13 +152,6 @@ const createDto = Object.freeze({
|
||||
duration: '0:00:00.000000',
|
||||
}) as AssetMediaCreateDto;
|
||||
|
||||
const replaceDto = Object.freeze({
|
||||
deviceAssetId: 'deviceAssetId',
|
||||
deviceId: 'deviceId',
|
||||
fileModifiedAt: new Date('2024-04-15T23:41:36.910Z'),
|
||||
fileCreatedAt: new Date('2024-04-15T23:41:36.910Z'),
|
||||
}) as AssetMediaReplaceDto;
|
||||
|
||||
const assetEntity = Object.freeze({
|
||||
id: 'id_1',
|
||||
ownerId: 'user_id_1',
|
||||
@@ -180,25 +173,6 @@ const assetEntity = Object.freeze({
|
||||
livePhotoVideoId: null,
|
||||
} as MapAsset);
|
||||
|
||||
const existingAsset = Object.freeze({
|
||||
...assetEntity,
|
||||
duration: null,
|
||||
type: AssetType.Image,
|
||||
checksum: Buffer.from('_getExistingAsset', 'utf8'),
|
||||
libraryId: 'libraryId',
|
||||
originalFileName: 'existing-filename.jpeg',
|
||||
}) as MapAsset;
|
||||
|
||||
const sidecarAsset = Object.freeze({
|
||||
...existingAsset,
|
||||
checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'),
|
||||
}) as MapAsset;
|
||||
|
||||
const copiedAsset = Object.freeze({
|
||||
id: 'copied-asset',
|
||||
originalPath: 'copied-path',
|
||||
}) as MapAsset;
|
||||
|
||||
describe(AssetMediaService.name, () => {
|
||||
let sut: AssetMediaService;
|
||||
let mocks: ServiceMocks;
|
||||
@@ -434,7 +408,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(
|
||||
@@ -451,7 +425,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(
|
||||
@@ -470,7 +444,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({
|
||||
@@ -776,177 +750,6 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceAsset', () => {
|
||||
it('should fail the auth check when update photo does not exist', async () => {
|
||||
await expect(sut.replaceAsset(authStub.user1, 'id', replaceDto, fileStub.photo)).rejects.toThrow(
|
||||
'Not found or no asset.update access',
|
||||
);
|
||||
|
||||
expect(mocks.asset.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail if asset cannot be fetched', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, fileStub.photo)).rejects.toThrow(
|
||||
'Asset not found',
|
||||
);
|
||||
|
||||
expect(mocks.asset.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update a photo with no sidecar to photo with no sidecar', async () => {
|
||||
const updatedFile = fileStub.photo;
|
||||
const updatedAsset = { ...existingAsset, ...updatedFile };
|
||||
mocks.asset.getById.mockResolvedValueOnce(existingAsset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(updatedAsset);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
||||
// this is the original file size
|
||||
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the clone call
|
||||
mocks.asset.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatus.REPLACED,
|
||||
id: 'copied-asset',
|
||||
});
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: existingAsset.id,
|
||||
originalFileName: 'photo1.jpeg',
|
||||
originalPath: 'fake_path/photo1.jpeg',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
originalFileName: 'existing-filename.jpeg',
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.deleteFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
assetId: existingAsset.id,
|
||||
type: AssetFileType.Sidecar,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
||||
deletedAt: expect.any(Date),
|
||||
status: AssetStatus.Trashed,
|
||||
});
|
||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(replaceDto.fileModifiedAt),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update a photo with sidecar to photo with sidecar', async () => {
|
||||
const updatedFile = fileStub.photo;
|
||||
const sidecarFile = fileStub.photoSidecar;
|
||||
const updatedAsset = { ...sidecarAsset, ...updatedFile };
|
||||
mocks.asset.getById.mockResolvedValueOnce(existingAsset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(updatedAsset);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
||||
// this is the original file size
|
||||
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the clone call
|
||||
mocks.asset.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(
|
||||
sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile, sidecarFile),
|
||||
).resolves.toEqual({
|
||||
status: AssetMediaStatus.REPLACED,
|
||||
id: 'copied-asset',
|
||||
});
|
||||
|
||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
||||
deletedAt: expect.any(Date),
|
||||
status: AssetStatus.Trashed,
|
||||
});
|
||||
expect(mocks.asset.upsertFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
assetId: existingAsset.id,
|
||||
path: sidecarFile.originalPath,
|
||||
type: AssetFileType.Sidecar,
|
||||
}),
|
||||
);
|
||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(replaceDto.fileModifiedAt),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update a photo with a sidecar to photo with no sidecar', async () => {
|
||||
const updatedFile = fileStub.photo;
|
||||
|
||||
const updatedAsset = { ...sidecarAsset, ...updatedFile };
|
||||
mocks.asset.getById.mockResolvedValueOnce(sidecarAsset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(updatedAsset);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
||||
// this is the original file size
|
||||
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the copy call
|
||||
mocks.asset.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatus.REPLACED,
|
||||
id: 'copied-asset',
|
||||
});
|
||||
|
||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
||||
deletedAt: expect.any(Date),
|
||||
status: AssetStatus.Trashed,
|
||||
});
|
||||
expect(mocks.asset.deleteFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
assetId: existingAsset.id,
|
||||
type: AssetFileType.Sidecar,
|
||||
}),
|
||||
);
|
||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(replaceDto.fileModifiedAt),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a photo with sidecar to duplicate photo ', async () => {
|
||||
const updatedFile = fileStub.photo;
|
||||
const error = new Error('unique key violation');
|
||||
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
|
||||
|
||||
mocks.asset.update.mockRejectedValue(error);
|
||||
mocks.asset.getById.mockResolvedValueOnce(sidecarAsset);
|
||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
||||
// this is the original file size
|
||||
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the clone call
|
||||
mocks.asset.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
id: sidecarAsset.id,
|
||||
});
|
||||
|
||||
expect(mocks.asset.create).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.updateAll).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.deleteFile).not.toHaveBeenCalled();
|
||||
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
data: { files: [updatedFile.originalPath, undefined] },
|
||||
});
|
||||
expect(mocks.user.updateUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkUploadCheck', () => {
|
||||
it('should accept hex and base64 checksums', async () => {
|
||||
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AssetService } from 'src/services/asset.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.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';
|
||||
|
||||
@@ -71,7 +72,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);
|
||||
|
||||
@@ -82,8 +83,8 @@ describe(AssetService.name, () => {
|
||||
const partner = factory.partner({ inTimeline: false });
|
||||
const auth = factory.auth({ user: { 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);
|
||||
|
||||
@@ -94,8 +95,8 @@ describe(AssetService.name, () => {
|
||||
const partner = factory.partner({ inTimeline: true });
|
||||
const auth = factory.auth({ user: { 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);
|
||||
|
||||
@@ -107,7 +108,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);
|
||||
|
||||
@@ -121,7 +122,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);
|
||||
|
||||
@@ -134,7 +135,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 } },
|
||||
@@ -152,7 +153,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);
|
||||
|
||||
@@ -162,7 +163,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);
|
||||
|
||||
@@ -204,8 +205,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 });
|
||||
|
||||
@@ -215,8 +216,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' });
|
||||
|
||||
@@ -229,8 +230,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 });
|
||||
|
||||
@@ -274,7 +275,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, {
|
||||
@@ -301,7 +302,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, {
|
||||
@@ -327,9 +328,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 });
|
||||
@@ -354,9 +355,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 });
|
||||
|
||||
@@ -569,7 +570,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 });
|
||||
|
||||
@@ -583,7 +584,7 @@ describe(AssetService.name, () => {
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(mocks.asset.remove).toHaveBeenCalledWith(asset);
|
||||
expect(mocks.asset.remove).toHaveBeenCalledWith(getForAssetDeletion(asset));
|
||||
});
|
||||
|
||||
it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => {
|
||||
@@ -591,11 +592,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 });
|
||||
|
||||
@@ -605,7 +602,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({
|
||||
@@ -622,7 +619,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 });
|
||||
|
||||
@@ -633,7 +630,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);
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
@@ -39,11 +40,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([
|
||||
|
||||
@@ -186,8 +186,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,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AlbumFactory } from 'test/factories/album.factory';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { getForAlbum, getForPartner } from 'test/mappers';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ShallowDehydrateObject } from 'kysely';
|
||||
import { OutputInfo } from 'sharp';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { Exif } from 'src/database';
|
||||
@@ -27,6 +28,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';
|
||||
|
||||
@@ -367,8 +369,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();
|
||||
@@ -377,17 +381,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);
|
||||
|
||||
@@ -398,7 +402,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 });
|
||||
|
||||
@@ -415,7 +419,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);
|
||||
|
||||
@@ -490,9 +494,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));
|
||||
@@ -532,9 +536,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));
|
||||
@@ -574,12 +578,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(
|
||||
@@ -600,9 +604,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(
|
||||
@@ -618,9 +622,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(
|
||||
@@ -638,10 +642,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(
|
||||
@@ -658,7 +662,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}`;
|
||||
@@ -708,7 +712,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`;
|
||||
@@ -760,7 +764,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 });
|
||||
|
||||
@@ -799,7 +803,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 });
|
||||
|
||||
@@ -834,12 +838,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 });
|
||||
|
||||
@@ -860,7 +864,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 });
|
||||
|
||||
@@ -879,7 +883,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 });
|
||||
|
||||
@@ -896,7 +900,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 });
|
||||
|
||||
@@ -910,7 +914,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 });
|
||||
|
||||
@@ -925,7 +929,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 });
|
||||
|
||||
@@ -941,7 +945,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 });
|
||||
|
||||
@@ -958,7 +962,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 });
|
||||
|
||||
@@ -977,7 +981,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 });
|
||||
|
||||
@@ -1018,7 +1022,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 });
|
||||
|
||||
@@ -1056,7 +1060,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 });
|
||||
|
||||
@@ -1104,7 +1108,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 });
|
||||
|
||||
@@ -1156,7 +1160,7 @@ describe(MediaService.name, () => {
|
||||
bitsPerSample: 14,
|
||||
})
|
||||
.build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -1187,7 +1191,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 });
|
||||
|
||||
@@ -1219,7 +1223,7 @@ describe(MediaService.name, () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -1264,7 +1268,7 @@ describe(MediaService.name, () => {
|
||||
bitsPerSample: 14,
|
||||
})
|
||||
.build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -1303,7 +1307,7 @@ describe(MediaService.name, () => {
|
||||
bitsPerSample: 14,
|
||||
})
|
||||
.build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -1338,7 +1342,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();
|
||||
@@ -1355,7 +1359,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([]);
|
||||
@@ -1377,7 +1381,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([]);
|
||||
|
||||
@@ -1405,7 +1409,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 });
|
||||
|
||||
@@ -1423,7 +1427,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([]);
|
||||
|
||||
@@ -1449,7 +1453,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' });
|
||||
@@ -1459,7 +1463,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([]);
|
||||
@@ -3603,15 +3607,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<Exif>)).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<Exif>)).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<Exif>)).toEqual(true);
|
||||
});
|
||||
|
||||
it('should return true for image with no colorspace or bit depth metadata', () => {
|
||||
@@ -3619,23 +3623,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<Exif>)).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<Exif>)).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<Exif>)).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<Exif>)).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<Exif>)).toEqual(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
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';
|
||||
@@ -258,7 +258,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 = {
|
||||
@@ -754,7 +754,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) {
|
||||
|
||||
@@ -3,6 +3,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';
|
||||
|
||||
@@ -33,7 +34,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([
|
||||
@@ -68,7 +69,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({
|
||||
@@ -85,7 +86,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 } }), {
|
||||
@@ -115,7 +116,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 } }), {
|
||||
@@ -135,7 +136,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(), {
|
||||
@@ -160,7 +161,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();
|
||||
|
||||
@@ -203,7 +204,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([
|
||||
@@ -218,7 +219,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([
|
||||
@@ -234,8 +235,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();
|
||||
|
||||
@@ -275,7 +276,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 },
|
||||
|
||||
@@ -19,6 +19,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';
|
||||
|
||||
@@ -176,7 +177,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 });
|
||||
@@ -198,7 +199,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,
|
||||
@@ -228,7 +229,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,
|
||||
@@ -257,7 +258,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 });
|
||||
@@ -277,7 +278,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,
|
||||
@@ -305,7 +306,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,
|
||||
@@ -337,7 +338,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({
|
||||
@@ -367,7 +368,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,
|
||||
@@ -383,7 +384,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);
|
||||
@@ -395,7 +396,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);
|
||||
@@ -417,7 +418,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);
|
||||
@@ -429,7 +430,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);
|
||||
@@ -441,7 +442,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);
|
||||
@@ -454,7 +455,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);
|
||||
@@ -474,7 +475,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);
|
||||
@@ -495,7 +496,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);
|
||||
@@ -522,7 +523,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);
|
||||
@@ -535,7 +536,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);
|
||||
@@ -551,7 +552,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);
|
||||
@@ -572,7 +573,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 });
|
||||
@@ -582,7 +583,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 });
|
||||
@@ -597,7 +598,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 }],
|
||||
@@ -608,7 +609,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({});
|
||||
|
||||
@@ -624,7 +625,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,
|
||||
@@ -686,7 +687,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, ''),
|
||||
@@ -733,7 +734,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,
|
||||
@@ -786,7 +787,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,
|
||||
@@ -808,7 +809,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,
|
||||
@@ -832,7 +833,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,
|
||||
@@ -859,7 +860,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,
|
||||
@@ -904,7 +905,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 });
|
||||
@@ -969,7 +970,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 });
|
||||
@@ -984,7 +985,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: {
|
||||
@@ -1007,7 +1008,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: {
|
||||
@@ -1029,7 +1030,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: {
|
||||
@@ -1052,7 +1053,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: {
|
||||
@@ -1075,7 +1076,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 });
|
||||
@@ -1086,7 +1087,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 });
|
||||
|
||||
@@ -1098,7 +1099,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 });
|
||||
@@ -1109,7 +1110,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,
|
||||
@@ -1127,7 +1128,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 });
|
||||
@@ -1150,7 +1151,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 });
|
||||
@@ -1164,7 +1165,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 });
|
||||
@@ -1173,7 +1174,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 });
|
||||
@@ -1182,7 +1183,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([]);
|
||||
@@ -1195,7 +1196,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([]);
|
||||
@@ -1210,7 +1211,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([]);
|
||||
@@ -1252,7 +1253,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 }]);
|
||||
@@ -1339,7 +1340,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([]);
|
||||
@@ -1383,7 +1384,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 });
|
||||
@@ -1397,7 +1398,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 });
|
||||
@@ -1411,7 +1412,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 });
|
||||
@@ -1425,7 +1426,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 });
|
||||
@@ -1439,7 +1440,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 });
|
||||
@@ -1453,7 +1454,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 });
|
||||
|
||||
@@ -1468,7 +1469,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 });
|
||||
@@ -1490,7 +1491,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' });
|
||||
|
||||
@@ -1518,7 +1519,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' });
|
||||
|
||||
@@ -1533,7 +1534,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' });
|
||||
|
||||
@@ -1568,9 +1569,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 });
|
||||
@@ -1595,7 +1601,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 });
|
||||
@@ -1609,7 +1615,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 });
|
||||
@@ -1623,7 +1629,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 });
|
||||
@@ -1637,7 +1643,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 });
|
||||
@@ -1754,17 +1760,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',
|
||||
@@ -1773,7 +1782,7 @@ describe(MetadataService.name, () => {
|
||||
'dateTimeOriginal',
|
||||
'timeZone',
|
||||
]);
|
||||
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(getForSidecarWrite(asset));
|
||||
await expect(
|
||||
sut.handleSidecarWrite({
|
||||
id: asset.id,
|
||||
@@ -1796,22 +1805,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']);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { constants } from 'node:fs/promises';
|
||||
import { 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,
|
||||
@@ -289,8 +289,10 @@ 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,
|
||||
@@ -447,8 +449,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,
|
||||
@@ -829,7 +830,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) {
|
||||
|
||||
@@ -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: '' });
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { PartnerDirection } from 'src/repositories/partner.repository';
|
||||
import { PartnerService } from 'src/services/partner.service';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { getDehydrated, getForPartner } from 'test/mappers';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@@ -18,26 +20,38 @@ 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 user1 = UserFactory.create();
|
||||
const user2 = UserFactory.create();
|
||||
const sharedWithUser2 = factory.partner({
|
||||
sharedBy: getDehydrated(user1),
|
||||
sharedWith: getDehydrated(user2),
|
||||
});
|
||||
const sharedWithUser1 = factory.partner({
|
||||
sharedBy: getDehydrated(user2),
|
||||
sharedWith: getDehydrated(user1),
|
||||
});
|
||||
const auth = factory.auth({ user: { 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 user1 = UserFactory.create();
|
||||
const user2 = UserFactory.create();
|
||||
const sharedWithUser2 = factory.partner({
|
||||
sharedBy: getDehydrated(user1),
|
||||
sharedWith: getDehydrated(user2),
|
||||
});
|
||||
const sharedWithUser1 = factory.partner({
|
||||
sharedBy: getDehydrated(user2),
|
||||
sharedWith: getDehydrated(user1),
|
||||
});
|
||||
const auth = factory.auth({ user: { 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 +59,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 user1 = UserFactory.create();
|
||||
const user2 = UserFactory.create();
|
||||
const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) });
|
||||
const auth = factory.auth({ user: { 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 +76,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 user1 = UserFactory.create();
|
||||
const user2 = UserFactory.create();
|
||||
const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) });
|
||||
const auth = factory.auth({ user: { 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 +91,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 user1 = UserFactory.create();
|
||||
const user2 = UserFactory.create();
|
||||
const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) });
|
||||
const auth = factory.auth({ user: { id: user1.id } });
|
||||
|
||||
mocks.partner.get.mockResolvedValue(partner);
|
||||
mocks.partner.get.mockResolvedValue(getForPartner(partner));
|
||||
|
||||
await sut.remove(auth, user2.id);
|
||||
|
||||
@@ -110,13 +124,13 @@ describe(PartnerService.name, () => {
|
||||
});
|
||||
|
||||
it('should update partner', async () => {
|
||||
const user1 = factory.user();
|
||||
const user2 = factory.user();
|
||||
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
||||
const user1 = UserFactory.create();
|
||||
const user2 = UserFactory.create();
|
||||
const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) });
|
||||
const auth = factory.auth({ user: { 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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -12,7 +12,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';
|
||||
|
||||
@@ -202,16 +202,16 @@ 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',
|
||||
thumbnailPath: person.thumbnailPath,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
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]));
|
||||
@@ -319,7 +319,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();
|
||||
@@ -353,15 +353,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,
|
||||
);
|
||||
@@ -390,7 +392,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({
|
||||
@@ -400,7 +402,7 @@ describe(PersonService.name, () => {
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
thumbnailPath: person.thumbnailPath,
|
||||
updatedAt: expect.any(Date),
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
|
||||
expect(mocks.job.queue).not.toHaveBeenCalledWith();
|
||||
@@ -412,7 +414,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(
|
||||
@@ -735,18 +737,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,
|
||||
@@ -764,12 +766,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 });
|
||||
@@ -788,9 +790,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 });
|
||||
|
||||
@@ -809,9 +811,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();
|
||||
|
||||
@@ -832,9 +834,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 });
|
||||
@@ -848,9 +850,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 });
|
||||
@@ -1237,7 +1239,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,
|
||||
@@ -1251,11 +1253,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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -491,7 +491,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
|
||||
@@ -519,7 +519,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) {
|
||||
|
||||
@@ -5,6 +5,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 { newTestService, ServiceMocks } from 'test/utils';
|
||||
import { beforeEach, vitest } from 'vitest';
|
||||
|
||||
@@ -74,7 +75,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);
|
||||
|
||||
|
||||
@@ -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 } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@@ -24,11 +26,13 @@ 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 });
|
||||
});
|
||||
});
|
||||
@@ -41,8 +45,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);
|
||||
});
|
||||
|
||||
@@ -54,7 +61,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);
|
||||
@@ -68,7 +81,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();
|
||||
@@ -90,9 +104,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,8 +140,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 });
|
||||
|
||||
@@ -145,8 +163,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,
|
||||
@@ -178,8 +199,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,
|
||||
@@ -221,8 +245,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 });
|
||||
|
||||
@@ -247,19 +272,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,
|
||||
@@ -268,11 +295,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(
|
||||
@@ -286,7 +315,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],
|
||||
});
|
||||
@@ -295,19 +324,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(
|
||||
@@ -338,11 +370,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',
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -88,11 +94,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,
|
||||
@@ -125,10 +134,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,
|
||||
@@ -141,12 +153,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 });
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -170,7 +170,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 });
|
||||
@@ -182,7 +184,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',
|
||||
@@ -211,7 +213,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}}';
|
||||
|
||||
@@ -219,7 +223,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);
|
||||
|
||||
@@ -259,7 +263,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}}';
|
||||
@@ -268,7 +274,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,
|
||||
@@ -764,7 +770,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 });
|
||||
@@ -775,7 +783,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',
|
||||
@@ -803,7 +811,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}}';
|
||||
|
||||
@@ -812,7 +822,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',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { SyncService } from 'src/services/sync.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { getForAsset, getForPartner } from 'test/mappers';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@@ -26,10 +27,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,
|
||||
@@ -44,7 +45,7 @@ describe(SyncService.name, () => {
|
||||
const partner = factory.partner();
|
||||
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] }),
|
||||
@@ -66,7 +67,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<typeof asset>({ length: 10_000 }).fill(asset));
|
||||
mocks.asset.getChangedDeltaSync.mockResolvedValue(
|
||||
Array.from<ReturnType<typeof getForAsset>>({ 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: [] });
|
||||
@@ -78,13 +81,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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -548,3 +549,5 @@ export interface UserMetadata extends Record<UserMetadataKey, Record<string, any
|
||||
[UserMetadataKey.License]: { licenseKey: string; activationKey: string; activatedAt: string };
|
||||
[UserMetadataKey.Onboarding]: { isOnboarded: boolean };
|
||||
}
|
||||
|
||||
export type MaybeDehydrated<T> = T | ShallowDehydrateObject<T>;
|
||||
|
||||
@@ -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 = <T extends object>(update: T, template: unkno
|
||||
return update;
|
||||
};
|
||||
|
||||
/** Modifies toJson return type to not set all properties as nullable */
|
||||
export function toJson<DB, TB extends keyof DB & string, T extends TB | Expression<unknown>>(
|
||||
eb: ExpressionBuilder<DB, TB>,
|
||||
table: T,
|
||||
) {
|
||||
return eb.fn.toJson<T>(table) as ExpressionWrapper<
|
||||
DB,
|
||||
TB,
|
||||
Simplify<
|
||||
T extends TB
|
||||
? Selectable<DB[T]> extends Nullable<infer N>
|
||||
? N | null
|
||||
: Selectable<DB[T]>
|
||||
: T extends Expression<infer O>
|
||||
? O extends Nullable<infer N>
|
||||
? 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<O>(qb: SelectQueryBuilder<DB, 'asset', O>)
|
||||
export function withExif<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
||||
return qb
|
||||
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.select((eb) => eb.fn.toJson(eb.table('asset_exif')).$castTo<Exif | null>().as('exifInfo'));
|
||||
.select((eb) =>
|
||||
eb.fn
|
||||
.toJson(eb.table('asset_exif'))
|
||||
.$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>> | null>()
|
||||
.as('exifInfo'),
|
||||
);
|
||||
}
|
||||
|
||||
export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
||||
return qb
|
||||
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.select((eb) => eb.fn.toJson(eb.table('asset_exif')).$castTo<Exif>().as('exifInfo'));
|
||||
.select((eb) => eb.fn.toJson(eb.table('asset_exif')).as('exifInfo'))
|
||||
.$narrowType<{ exifInfo: NotNull }>();
|
||||
}
|
||||
|
||||
export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
||||
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<DB, 'asset'>, withHidden?: boolean, withDeletedFace?: boolean) {
|
||||
@@ -164,7 +148,7 @@ export function withFacesAndPeople(
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.selectAll('asset_face')
|
||||
.select((eb) => eb.table('person').$castTo<Person>().as('person'))
|
||||
.select((eb) => eb.table('person').$castTo<ShallowDehydrateObject<Person>>().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)),
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
export const asDateString = (x: Date | string | null): string | null => {
|
||||
export const asDateString = <T extends Date | string | undefined | null>(x: T) => {
|
||||
return x instanceof Date ? x.toISOString() : (x as Exclude<T, Date>);
|
||||
};
|
||||
|
||||
export const asBirthDateString = (x: Date | string | null): string | null => {
|
||||
return x instanceof Date ? x.toISOString().split('T')[0] : x;
|
||||
};
|
||||
|
||||
|
||||
@@ -51,12 +51,14 @@ export class SharedLinkFactory {
|
||||
|
||||
album(dto: AlbumLike = {}, builder?: FactoryBuilder<AlbumFactory>) {
|
||||
this.#album = build(AlbumFactory.from(dto), builder);
|
||||
this.value.type = SharedLinkType.Album;
|
||||
return this;
|
||||
}
|
||||
|
||||
asset(dto: AssetLike = {}, builder?: FactoryBuilder<AssetFactory>) {
|
||||
const asset = build(AssetFactory.from(dto), builder);
|
||||
this.#assets.push(asset);
|
||||
this.value.type = SharedLinkType.Individual;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
84
server/test/fixtures/shared-link.stub.ts
vendored
84
server/test/fixtures/shared-link.stub.ts
vendored
@@ -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,
|
||||
|
||||
8
server/test/fixtures/tag.stub.ts
vendored
8
server/test/fixtures/tag.stub.ts
vendored
@@ -55,15 +55,15 @@ export const tagStub = {
|
||||
export const tagResponseStub = {
|
||||
tag1: Object.freeze<TagResponseDto>({
|
||||
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<TagResponseDto>({
|
||||
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',
|
||||
|
||||
@@ -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<AssetFactory['build']>) => {
|
||||
return {
|
||||
@@ -47,6 +55,171 @@ export const getForFacialRecognitionJob = (
|
||||
asset: Pick<Selectable<AssetTable>, '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 = <T extends Record<string, unknown>>(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<T>;
|
||||
};
|
||||
|
||||
export const getForAlbum = (album: ReturnType<AlbumFactory['build']>) => ({
|
||||
...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<ActivityTable> & { user: ReturnType<UserFactory['build']> }) => ({
|
||||
...activity,
|
||||
user: getDehydrated(activity.user),
|
||||
});
|
||||
|
||||
export const getForAsset = (asset: ReturnType<AssetFactory['build']>) => {
|
||||
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<PartnerTable> & Record<'sharedWith' | 'sharedBy', ReturnType<UserFactory['build']>>,
|
||||
) => ({
|
||||
...partner,
|
||||
sharedBy: getDehydrated(partner.sharedBy),
|
||||
sharedWith: getDehydrated(partner.sharedWith),
|
||||
});
|
||||
|
||||
export const getForMemory = (memory: ReturnType<MemoryFactory['build']>) => ({
|
||||
...memory,
|
||||
assets: memory.assets.map((asset) => getDehydrated(asset)),
|
||||
});
|
||||
|
||||
export const getForMetadataExtraction = (asset: ReturnType<AssetFactory['build']>) => ({
|
||||
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<AssetFactory['build']>) => ({
|
||||
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<AssetFaceFactory['build']>) => ({
|
||||
...face,
|
||||
person: face.person ? getDehydrated(face.person) : null,
|
||||
});
|
||||
|
||||
export const getForDetectedFaces = (asset: ReturnType<AssetFactory['build']>) => ({
|
||||
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<AssetFactory['build']>) => ({
|
||||
id: asset.id,
|
||||
originalPath: asset.originalPath,
|
||||
files: asset.files.map((file) => getDehydrated(file)),
|
||||
exifInfo: getDehydrated(asset.exifInfo),
|
||||
});
|
||||
|
||||
export const getForAssetDeletion = (asset: ReturnType<AssetFactory['build']>) => ({
|
||||
id: asset.id,
|
||||
visibility: asset.visibility,
|
||||
libraryId: asset.libraryId,
|
||||
ownerId: asset.ownerId,
|
||||
livePhotoVideoId: asset.livePhotoVideoId,
|
||||
encodedVideoPath: asset.encodedVideoPath,
|
||||
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<StackFactory['build']>) => ({
|
||||
...stack,
|
||||
assets: stack.assets.map((asset) => ({
|
||||
...getDehydrated(asset),
|
||||
exifInfo: getDehydrated(asset.exifInfo),
|
||||
})),
|
||||
});
|
||||
|
||||
export const getForDuplicate = (asset: ReturnType<AssetFactory['build']>) => ({
|
||||
...getDehydrated(asset),
|
||||
exifInfo: getDehydrated(asset.exifInfo),
|
||||
});
|
||||
|
||||
export const getForSharedLink = (sharedLink: ReturnType<SharedLinkFactory['build']>) => ({
|
||||
...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,
|
||||
});
|
||||
|
||||
@@ -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<DB>;
|
||||
@@ -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();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ShallowDehydrateObject } from 'kysely';
|
||||
import {
|
||||
Activity,
|
||||
Album,
|
||||
@@ -113,8 +114,12 @@ const partnerFactory = ({
|
||||
sharedWith: sharedWithProvided,
|
||||
...partner
|
||||
}: Partial<Partner> = {}) => {
|
||||
const sharedBy = UserFactory.create(sharedByProvided ?? {});
|
||||
const sharedWith = UserFactory.create(sharedWithProvided ?? {});
|
||||
const hydrateUser = (user: Partial<ShallowDehydrateObject<User>>) => ({
|
||||
...user,
|
||||
profileChangedAt: user.profileChangedAt ? new Date(user.profileChangedAt) : undefined,
|
||||
});
|
||||
const sharedBy = UserFactory.create(sharedByProvided ? hydrateUser(sharedByProvided) : {});
|
||||
const sharedWith = UserFactory.create(sharedWithProvided ? hydrateUser(sharedWithProvided) : {});
|
||||
|
||||
return {
|
||||
sharedById: sharedBy.id,
|
||||
@@ -214,7 +219,7 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
|
||||
};
|
||||
};
|
||||
|
||||
const activityFactory = (activity: Partial<Activity> = {}) => {
|
||||
const activityFactory = (activity: Omit<Partial<Activity>, 'user'> = {}) => {
|
||||
const userId = activity.userId || newUuid();
|
||||
return {
|
||||
id: newUuid(),
|
||||
|
||||
@@ -98,7 +98,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",
|
||||
|
||||
25
web/src/lib/actions/image-loader.svelte.ts
Normal file
25
web/src/lib/actions/image-loader.svelte.ts
Normal file
@@ -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;
|
||||
@@ -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) }),
|
||||
|
||||
228
web/src/lib/components/AdaptiveImage.svelte
Normal file
228
web/src/lib/components/AdaptiveImage.svelte
Normal file
@@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
||||
import ImageLayer from '$lib/components/ImageLayer.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { getAssetUrls } from '$lib/utils';
|
||||
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
|
||||
import { scaleToCover, scaleToFit } from '$lib/utils/container-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { untrack, type Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
asset: AssetResponseDto;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
objectFit?: 'contain' | 'cover';
|
||||
container: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
onUrlChange?: (url: string) => void;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
ref?: HTMLDivElement;
|
||||
imgRef?: HTMLImageElement;
|
||||
backdrop?: Snippet;
|
||||
overlays?: Snippet;
|
||||
};
|
||||
|
||||
let {
|
||||
ref = $bindable(),
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
imgRef = $bindable(),
|
||||
asset,
|
||||
sharedLink,
|
||||
objectFit = 'contain',
|
||||
container,
|
||||
onUrlChange,
|
||||
onImageReady,
|
||||
onError,
|
||||
backdrop,
|
||||
overlays,
|
||||
}: Props = $props();
|
||||
|
||||
const afterThumbnail = (loader: AdaptiveImageLoader) => {
|
||||
if (assetViewerManager.zoom > 1) {
|
||||
loader.trigger('original');
|
||||
} else {
|
||||
loader.trigger('preview');
|
||||
}
|
||||
};
|
||||
|
||||
const buildQualityList = () => {
|
||||
const assetUrls = getAssetUrls(asset, sharedLink);
|
||||
const qualityList: QualityList = [
|
||||
{
|
||||
quality: 'thumbnail',
|
||||
url: assetUrls.thumbnail,
|
||||
onAfterLoad: afterThumbnail,
|
||||
onAfterError: afterThumbnail,
|
||||
},
|
||||
{
|
||||
quality: 'preview',
|
||||
url: assetUrls.preview,
|
||||
onAfterError: (loader) => loader.trigger('original'),
|
||||
},
|
||||
{ quality: 'original', url: assetUrls.original },
|
||||
];
|
||||
return qualityList;
|
||||
};
|
||||
|
||||
const loaderKey = $derived(`${asset.id}:${asset.thumbhash}:${sharedLink?.id}`);
|
||||
|
||||
const adaptiveImageLoader = $derived.by(() => {
|
||||
void loaderKey;
|
||||
|
||||
return untrack(
|
||||
() =>
|
||||
new AdaptiveImageLoader(buildQualityList(), {
|
||||
onImageReady,
|
||||
onError,
|
||||
onUrlChange,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
$effect.pre(() => {
|
||||
const loader = adaptiveImageLoader;
|
||||
untrack(() => assetViewerManager.resetZoomState());
|
||||
return () => loader.destroy();
|
||||
});
|
||||
|
||||
const imageDimensions = $derived.by(() => {
|
||||
const { width, height } = asset;
|
||||
if (width && width > 0 && height && height > 0) {
|
||||
return { width, height };
|
||||
}
|
||||
return { width: 1, height: 1 };
|
||||
});
|
||||
|
||||
const { width, height, left, top } = $derived.by(() => {
|
||||
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
||||
const { width, height } = scaleFn(imageDimensions, container);
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
left: (container.width - width) / 2 + 'px',
|
||||
top: (container.height - height) / 2 + 'px',
|
||||
};
|
||||
});
|
||||
|
||||
const { status } = $derived(adaptiveImageLoader);
|
||||
const alt = $derived(status.urls.preview ? $getAltText(toTimelineAsset(asset)) : '');
|
||||
|
||||
const show = $derived.by(() => {
|
||||
const { quality, started, hasError, urls } = status;
|
||||
return {
|
||||
alphaBackground: !hasError && started,
|
||||
spinner: !asset.thumbhash && !started,
|
||||
brokenAsset: hasError,
|
||||
thumbhash: quality.thumbnail !== 'success' && quality.preview !== 'success' && quality.original !== 'success',
|
||||
thumbnail: quality.thumbnail !== 'error' && quality.preview !== 'success' && quality.original !== 'success',
|
||||
preview: quality.preview !== 'error' && quality.original !== 'success',
|
||||
original: quality.original !== 'error' && urls.original !== undefined,
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
assetViewerManager.imageLoaderStatus = status;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (assetViewerManager.zoom > 1 && status.quality.original !== 'success') {
|
||||
untrack(() => void adaptiveImageLoader.trigger('original'));
|
||||
}
|
||||
});
|
||||
|
||||
let thumbnailElement = $state<HTMLImageElement>();
|
||||
let previewElement = $state<HTMLImageElement>();
|
||||
let originalElement = $state<HTMLImageElement>();
|
||||
|
||||
$effect(() => {
|
||||
const quality = status.quality;
|
||||
imgRef =
|
||||
(quality.original === 'success' ? originalElement : undefined) ??
|
||||
(quality.preview === 'success' ? previewElement : undefined) ??
|
||||
(quality.thumbnail === 'success' ? thumbnailElement : undefined);
|
||||
});
|
||||
|
||||
const zoomTransform = $derived.by(() => {
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
if (currentZoom === 1 && currentPositionX === 0 && currentPositionY === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return `translate(${currentPositionX}px, ${currentPositionY}px) scale(${currentZoom})`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}>
|
||||
{@render backdrop?.()}
|
||||
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
style:transform={zoomTransform}
|
||||
style:transform-origin={zoomTransform ? '0 0' : undefined}
|
||||
>
|
||||
<div class="absolute" style:left style:top style:width style:height>
|
||||
{#if show.alphaBackground}
|
||||
<AlphaBackground />
|
||||
{/if}
|
||||
|
||||
{#if show.thumbhash}
|
||||
{#if asset.thumbhash}
|
||||
<!-- Thumbhash / spinner layer -->
|
||||
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
|
||||
{:else if show.spinner}
|
||||
<DelayedLoadingSpinner />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if show.thumbnail}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{width}
|
||||
{height}
|
||||
quality="thumbnail"
|
||||
src={status.urls.thumbnail}
|
||||
alt=""
|
||||
role="presentation"
|
||||
bind:ref={thumbnailElement}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if show.brokenAsset}
|
||||
<BrokenAsset class="text-xl h-full w-full absolute" />
|
||||
{/if}
|
||||
|
||||
{#if show.preview}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
{overlays}
|
||||
quality="preview"
|
||||
src={status.urls.preview}
|
||||
bind:ref={previewElement}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if show.original}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
{overlays}
|
||||
quality="original"
|
||||
src={status.urls.original}
|
||||
bind:ref={originalElement}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
11
web/src/lib/components/AlphaBackground.svelte
Normal file
11
web/src/lib/components/AlphaBackground.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
class?: ClassValue;
|
||||
}
|
||||
|
||||
let { class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="absolute h-full w-full bg-gray-300 dark:bg-gray-700 {className}"></div>
|
||||
20
web/src/lib/components/DelayedLoadingSpinner.svelte
Normal file
20
web/src/lib/components/DelayedLoadingSpinner.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
</script>
|
||||
|
||||
<div class="delayed-spinner absolute flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.delayed-spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { isFirefox } from '$lib/utils/asset-utils';
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import type { HTMLImgAttributes } from 'svelte/elements';
|
||||
@@ -14,6 +15,7 @@
|
||||
let { src, onStart, onLoad, onError, ref = $bindable(), ...rest }: Props = $props();
|
||||
|
||||
let capturedSource: string | undefined = $state();
|
||||
let loaded = $state(false);
|
||||
let destroyed = false;
|
||||
|
||||
$effect(() => {
|
||||
@@ -32,11 +34,25 @@
|
||||
}
|
||||
});
|
||||
|
||||
const completeLoad = () => {
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
loaded = true;
|
||||
onLoad?.();
|
||||
};
|
||||
|
||||
const handleLoad = () => {
|
||||
if (destroyed || !src) {
|
||||
return;
|
||||
}
|
||||
onLoad?.();
|
||||
|
||||
if (isFirefox && ref) {
|
||||
ref.decode().then(completeLoad, completeLoad);
|
||||
return;
|
||||
}
|
||||
|
||||
completeLoad();
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
@@ -49,6 +65,13 @@
|
||||
|
||||
{#if capturedSource}
|
||||
{#key capturedSource}
|
||||
<img bind:this={ref} src={capturedSource} {...rest} onload={handleLoad} onerror={handleError} />
|
||||
<img
|
||||
bind:this={ref}
|
||||
src={capturedSource}
|
||||
{...rest}
|
||||
style:visibility={isFirefox && !loaded ? 'hidden' : undefined}
|
||||
onload={handleLoad}
|
||||
onerror={handleError}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
47
web/src/lib/components/ImageLayer.svelte
Normal file
47
web/src/lib/components/ImageLayer.svelte
Normal file
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import Image from '$lib/components/Image.svelte';
|
||||
import type { AdaptiveImageLoader, ImageQuality } from '$lib/utils/adaptive-image-loader.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
adaptiveImageLoader: AdaptiveImageLoader;
|
||||
quality: ImageQuality;
|
||||
src: string | undefined;
|
||||
alt?: string;
|
||||
role?: string;
|
||||
ref?: HTMLImageElement;
|
||||
width: string;
|
||||
height: string;
|
||||
overlays?: Snippet;
|
||||
};
|
||||
|
||||
let {
|
||||
adaptiveImageLoader,
|
||||
quality,
|
||||
src,
|
||||
alt = '',
|
||||
role,
|
||||
ref = $bindable(),
|
||||
width,
|
||||
height,
|
||||
overlays,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#key adaptiveImageLoader}
|
||||
<div class="absolute top-0" style:width style:height>
|
||||
<Image
|
||||
{src}
|
||||
onStart={() => 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?.()}
|
||||
</div>
|
||||
{/key}
|
||||
46
web/src/lib/components/LoadingDots.svelte
Normal file
46
web/src/lib/components/LoadingDots.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
class?: ClassValue;
|
||||
}
|
||||
|
||||
let { class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="delayed inline-flex items-center gap-1 {className}">
|
||||
{#each [0, 1, 2] as i (i)}
|
||||
<span class="dot block size-1.5 rounded-full bg-white shadow-[0_0_3px_rgba(0,0,0,0.6)]" style:--delay="{i * 0.25}s"
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.delayed {
|
||||
visibility: hidden;
|
||||
animation: delayed-visibility 0s linear 0.4s forwards;
|
||||
}
|
||||
|
||||
@keyframes delayed-visibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
animation: dot-stream 1.6s var(--delay, 0s) ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dot-stream {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
104
web/src/lib/components/asset-viewer/PreloadManager.svelte.ts
Normal file
104
web/src/lib/components/asset-viewer/PreloadManager.svelte.ts
Normal file
@@ -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();
|
||||
@@ -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 @@
|
||||
<ActionButton action={Close} />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
|
||||
<div class="flex items-center gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
|
||||
{#if assetViewerManager.isImageLoading}
|
||||
<Tooltip text={$t('loading')}>
|
||||
{#snippet child({ props })}
|
||||
<div {...props} role="status" aria-label={$t('loading')}>
|
||||
<LoadingDots class="me-1" />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
{/if}
|
||||
<ActionButton action={Cast} />
|
||||
<ActionButton action={Actions.Share} />
|
||||
<ActionButton action={Actions.Offline} />
|
||||
|
||||
76
web/src/lib/components/asset-viewer/asset-viewer.spec.ts
Normal file
76
web/src/lib/components/asset-viewer/asset-viewer.spec.ts
Normal file
@@ -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<typeof import('@immich/sdk')>('@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());
|
||||
});
|
||||
});
|
||||
@@ -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<Element>();
|
||||
let unsubscribes: (() => void)[] = [];
|
||||
let stack: StackResponseDto | null = $state(null);
|
||||
|
||||
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
|
||||
let slideshowStartAssetId = $state<string>();
|
||||
@@ -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<AssetCursor>();
|
||||
|
||||
$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');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
|
||||
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag, TagPeople]} />
|
||||
<OnEvents {onAssetUpdate} />
|
||||
|
||||
<svelte:document bind:fullscreenElement />
|
||||
|
||||
@@ -448,23 +497,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset}
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
|
||||
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
|
||||
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Asset Viewer -->
|
||||
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||
{#if viewerKind === 'StackPhotoViewer'}
|
||||
<PhotoViewer
|
||||
cursor={{ ...cursor, current: previewStackedAsset! }}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
haveFadeTransition={false}
|
||||
{sharedLink}
|
||||
/>
|
||||
{:else if viewerKind === 'StackVideoViewer'}
|
||||
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||
{#if viewerKind === 'StackVideoViewer'}
|
||||
<VideoViewer
|
||||
asset={previewStackedAsset!}
|
||||
cacheKey={previewStackedAsset!.thumbhash}
|
||||
@@ -494,13 +535,7 @@
|
||||
{:else if viewerKind === 'CropArea'}
|
||||
<CropArea {asset} />
|
||||
{:else if viewerKind === 'PhotoViewer'}
|
||||
<PhotoViewer
|
||||
{cursor}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
{sharedLink}
|
||||
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
|
||||
/>
|
||||
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
|
||||
{:else if viewerKind === 'VideoViewer'}
|
||||
<VideoViewer
|
||||
{asset}
|
||||
@@ -535,7 +570,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset}
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
|
||||
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
|
||||
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user