diff --git a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl index afa00e60677c1..6419c16dad324 100644 --- a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.41.0" - constraints = "4.41.0" + version = "4.43.0" + constraints = "4.43.0" hashes = [ - "h1:0mc+YrjQrcctGrGYDmzlcqcgSv9MYB74rvMaZylIKC8=", - "h1:0zUx4vk4jOORQqn6xHBF7dO6N6bielFHdJ0mgF4Obn8=", - "h1:AsIZW3uLFNOZO7kL/K7/Y/S0IYxUV9Hz85NNk/3TTsA=", - "h1:FSgYM4+LHMbX/a4Y1kx7FPPWmXqS3/MQYzvjMJHHHWM=", - "h1:Tx6Nh3BWP1x9L3KK/Eyi+ET0T26g3+jf1jyiuqpNIis=", - "h1:VRI9wu8P43xxfpeTndRwsisLnqncfnmEYMOEH5zH4pQ=", - "h1:YxQqmiES/Yanq/VfGqBEqg+VIO7FGhO88aKoWFHyGIg=", - "h1:ZWHiaesjgDLKWlfdNj0oKyj/DWdxcfsO6NINu39zfpY=", - "h1:a2aCgDDBz3ccrr8YstIMl7VFnKo1xZAp+rOv59PPJ7U=", - "h1:aRyv8tB6wBAF9lKsLEdiHyCqnK5LfZq0FqMXCcUB4UU=", - "h1:lXpuO7zv2uD2GzPE1ARxznreRAh+QHTc2lAJ7iOoFgY=", - "h1:sA1xq0QNQ4fH8SHXouYNq50xirVD18SamKQwPsBQrrY=", - "h1:v7sHvKq7oqMYPn47ULHFyIQsKD9o+6Xg/uHbxQUixEw=", - "h1:wo/x4atWyXuWGlfR6h5nH0YwBAmBwTRY27HtWP8ycLo=", - "zh:339d26e06dc6fb299ea8aad9476a60fd65bb1d40631ae8eeb81cddf2dd2bebc8", - "zh:3dec2ad96ac2c283fd34ce65781b55c4edbb4d5c5cb53da8e31537176c0ed562", - "zh:5f63a5f8080319a2fff09d4d49944829fa708723436520787cfb60725ced80cf", - "zh:67162c28ccea71cb8141ed15c0637e35621354ebe14878e0b75a8f160fc5505d", - "zh:6ac1e07f5347b6395aca690ed22101bb25e957d25f986f760ff673a7adfd5ef6", - "zh:70282a723c7b52fcabde2baad41c864ed3a8d69f0c4d27a6b6933cac434cffc6", + "h1:2kDVLD36BOVgBzI9p0WIQ+xjFfMmjaItA0l8SyZWEPo=", + "h1:2sGJDAwFEgO8+3y+2suYO+yrjNOzSsihad0hbM3+jPg=", + "h1:A1WPQFcdD+7FrFBFrKcx4CiSr75xSmsO93C0e5NBAeQ=", + "h1:BuXs/1ohmF4fWyOErY6vNbm7DaEIfbLSepSiZ2ol9I8=", + "h1:QPh+X19oyo808sqdeJaVqahZcQgcG1jCi3DA5zpjz6U=", + "h1:RI7c7dhSJoIkfou5b8ITRpM5MqsQD3FULj1h/rI4rJk=", + "h1:gdI5JTCPjewdGq1bhGAs+V5qCcmJ73N2gtMfuFybJp4=", + "h1:h4lnJpCIYZ7dsN9IO2mmwNdWNiQYEPoAEUjLF2sZ5kc=", + "h1:jTaExrX/eR7vGT5wayGqH8ZtXS2zyk0WmD3zbAKFIQU=", + "h1:l5NKJUOQJ1mHl1eekeXaxUZ+g+8Yv4aGcIN9vuK6GL4=", + "h1:sNbvm66/2vc8B/khyioOO8eNaU8nb89x693AN7fQheU=", + "h1:tXS4g1yE420AU4mvZ7RrYI+yYTutkRID3l+W0gBH4BM=", + "h1:vA+kES7uqmKA9K0U45IXR94jaTQZCHZLCHqMUeGxKMI=", + "h1:zV131k79+ob9p4jrLDgztDNvZvt8fvrrzpn0nPikBw8=", + "zh:006d111d6eafe6eeb5df2f91bd0ca320f979bd71f8cd8c475f10b2bd94acba55", + "zh:031fbb5cac23a841dc18e270cbfcd3ce9f4ba504edbd3c78931f7ed9827220a8", + "zh:07a72fe8b55afee99529bf4169ab6abfac5eabcd10968c29101925bcd358b09f", + "zh:0d14727d011c2d9df4c3058f527d2409223449ab48b46cbc86922eb553ef77c1", + "zh:155ce1333672d26cd18a5866b0761489d91682beffee58e45c3a1b68e8491d3d", + "zh:35a2a1939a965335b29ebdbfd759d93a97c0f589d9cd218f537dee6f600e3fb9", + "zh:52912fe421e7d911431f77788db2ea13836efd65a2e82385adb52c6a84d4ee90", + "zh:57374318d9194ea1db08884b0541a9055823d5970ad48f9a57547ac231163007", + "zh:5fb942b9e2553c058fe09fe12fb39dd175cd6715bb41c059c1a70df2bfc64dc1", + "zh:63cabd2bda201b09b35a3279d1f813ab71394b9b90fc5cf8962a5eba207803bc", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:924cd23abc326c6b3914e2cd9c94c7832c2552e1e9ae258fb9fd9aedaa5f7ce7", - "zh:a4b75e4c239879296259e7d54f1befbc7fdc16da2d62d1294e9f73add4cae61e", - "zh:a6ceb08feb63b00c7141783b31e45a154c76fd8cdebbdf371074805f0053572d", - "zh:afae1843f9ba85f2f6d94108c65cf43a457e83531a632d44d863e935160cb2ba", - "zh:bd6628ce60c778960a5755f7010b7e2cc5c6ff0341a21c175341b28058ec843d", - "zh:cd30866a1ff99d72b5fa1699db582fa4f25562e6ab21dcc6870324f3056108e0", - "zh:df5924cca691a8220aaaebb5cb55c3d6c32ff0a881f198695eff28155eb12b54", - "zh:e78d0696c941aba58df1cb36b8a0d25cd5f3963f01d9338fdbda74db58afdd49", + "zh:978ee67d3d53970a5c474ab40b00adee97f4153b16804a2b6b7ee205ae69d18a", + "zh:bbafdbef631b5c80570087817b42b16b1a76d556d692853a71c47fb48663cf00", + "zh:be91b3f2a697cbbb41f65aad2600972d0ede1e962a7d8a00bb3177cb77d86666", + "zh:efe168ad4aaa6156ce5a31d4e50e9d54d38ee5a5888412f9e690c0de5d619683", ] } diff --git a/deployment/modules/cloudflare/docs-release/config.tf b/deployment/modules/cloudflare/docs-release/config.tf index 18d8ff1eb4665..74ea6d5816f8e 100644 --- a/deployment/modules/cloudflare/docs-release/config.tf +++ b/deployment/modules/cloudflare/docs-release/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.41.0" + version = "4.43.0" } } } diff --git a/deployment/modules/cloudflare/docs/.terraform.lock.hcl b/deployment/modules/cloudflare/docs/.terraform.lock.hcl index afa00e60677c1..6419c16dad324 100644 --- a/deployment/modules/cloudflare/docs/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.41.0" - constraints = "4.41.0" + version = "4.43.0" + constraints = "4.43.0" hashes = [ - "h1:0mc+YrjQrcctGrGYDmzlcqcgSv9MYB74rvMaZylIKC8=", - "h1:0zUx4vk4jOORQqn6xHBF7dO6N6bielFHdJ0mgF4Obn8=", - "h1:AsIZW3uLFNOZO7kL/K7/Y/S0IYxUV9Hz85NNk/3TTsA=", - "h1:FSgYM4+LHMbX/a4Y1kx7FPPWmXqS3/MQYzvjMJHHHWM=", - "h1:Tx6Nh3BWP1x9L3KK/Eyi+ET0T26g3+jf1jyiuqpNIis=", - "h1:VRI9wu8P43xxfpeTndRwsisLnqncfnmEYMOEH5zH4pQ=", - "h1:YxQqmiES/Yanq/VfGqBEqg+VIO7FGhO88aKoWFHyGIg=", - "h1:ZWHiaesjgDLKWlfdNj0oKyj/DWdxcfsO6NINu39zfpY=", - "h1:a2aCgDDBz3ccrr8YstIMl7VFnKo1xZAp+rOv59PPJ7U=", - "h1:aRyv8tB6wBAF9lKsLEdiHyCqnK5LfZq0FqMXCcUB4UU=", - "h1:lXpuO7zv2uD2GzPE1ARxznreRAh+QHTc2lAJ7iOoFgY=", - "h1:sA1xq0QNQ4fH8SHXouYNq50xirVD18SamKQwPsBQrrY=", - "h1:v7sHvKq7oqMYPn47ULHFyIQsKD9o+6Xg/uHbxQUixEw=", - "h1:wo/x4atWyXuWGlfR6h5nH0YwBAmBwTRY27HtWP8ycLo=", - "zh:339d26e06dc6fb299ea8aad9476a60fd65bb1d40631ae8eeb81cddf2dd2bebc8", - "zh:3dec2ad96ac2c283fd34ce65781b55c4edbb4d5c5cb53da8e31537176c0ed562", - "zh:5f63a5f8080319a2fff09d4d49944829fa708723436520787cfb60725ced80cf", - "zh:67162c28ccea71cb8141ed15c0637e35621354ebe14878e0b75a8f160fc5505d", - "zh:6ac1e07f5347b6395aca690ed22101bb25e957d25f986f760ff673a7adfd5ef6", - "zh:70282a723c7b52fcabde2baad41c864ed3a8d69f0c4d27a6b6933cac434cffc6", + "h1:2kDVLD36BOVgBzI9p0WIQ+xjFfMmjaItA0l8SyZWEPo=", + "h1:2sGJDAwFEgO8+3y+2suYO+yrjNOzSsihad0hbM3+jPg=", + "h1:A1WPQFcdD+7FrFBFrKcx4CiSr75xSmsO93C0e5NBAeQ=", + "h1:BuXs/1ohmF4fWyOErY6vNbm7DaEIfbLSepSiZ2ol9I8=", + "h1:QPh+X19oyo808sqdeJaVqahZcQgcG1jCi3DA5zpjz6U=", + "h1:RI7c7dhSJoIkfou5b8ITRpM5MqsQD3FULj1h/rI4rJk=", + "h1:gdI5JTCPjewdGq1bhGAs+V5qCcmJ73N2gtMfuFybJp4=", + "h1:h4lnJpCIYZ7dsN9IO2mmwNdWNiQYEPoAEUjLF2sZ5kc=", + "h1:jTaExrX/eR7vGT5wayGqH8ZtXS2zyk0WmD3zbAKFIQU=", + "h1:l5NKJUOQJ1mHl1eekeXaxUZ+g+8Yv4aGcIN9vuK6GL4=", + "h1:sNbvm66/2vc8B/khyioOO8eNaU8nb89x693AN7fQheU=", + "h1:tXS4g1yE420AU4mvZ7RrYI+yYTutkRID3l+W0gBH4BM=", + "h1:vA+kES7uqmKA9K0U45IXR94jaTQZCHZLCHqMUeGxKMI=", + "h1:zV131k79+ob9p4jrLDgztDNvZvt8fvrrzpn0nPikBw8=", + "zh:006d111d6eafe6eeb5df2f91bd0ca320f979bd71f8cd8c475f10b2bd94acba55", + "zh:031fbb5cac23a841dc18e270cbfcd3ce9f4ba504edbd3c78931f7ed9827220a8", + "zh:07a72fe8b55afee99529bf4169ab6abfac5eabcd10968c29101925bcd358b09f", + "zh:0d14727d011c2d9df4c3058f527d2409223449ab48b46cbc86922eb553ef77c1", + "zh:155ce1333672d26cd18a5866b0761489d91682beffee58e45c3a1b68e8491d3d", + "zh:35a2a1939a965335b29ebdbfd759d93a97c0f589d9cd218f537dee6f600e3fb9", + "zh:52912fe421e7d911431f77788db2ea13836efd65a2e82385adb52c6a84d4ee90", + "zh:57374318d9194ea1db08884b0541a9055823d5970ad48f9a57547ac231163007", + "zh:5fb942b9e2553c058fe09fe12fb39dd175cd6715bb41c059c1a70df2bfc64dc1", + "zh:63cabd2bda201b09b35a3279d1f813ab71394b9b90fc5cf8962a5eba207803bc", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:924cd23abc326c6b3914e2cd9c94c7832c2552e1e9ae258fb9fd9aedaa5f7ce7", - "zh:a4b75e4c239879296259e7d54f1befbc7fdc16da2d62d1294e9f73add4cae61e", - "zh:a6ceb08feb63b00c7141783b31e45a154c76fd8cdebbdf371074805f0053572d", - "zh:afae1843f9ba85f2f6d94108c65cf43a457e83531a632d44d863e935160cb2ba", - "zh:bd6628ce60c778960a5755f7010b7e2cc5c6ff0341a21c175341b28058ec843d", - "zh:cd30866a1ff99d72b5fa1699db582fa4f25562e6ab21dcc6870324f3056108e0", - "zh:df5924cca691a8220aaaebb5cb55c3d6c32ff0a881f198695eff28155eb12b54", - "zh:e78d0696c941aba58df1cb36b8a0d25cd5f3963f01d9338fdbda74db58afdd49", + "zh:978ee67d3d53970a5c474ab40b00adee97f4153b16804a2b6b7ee205ae69d18a", + "zh:bbafdbef631b5c80570087817b42b16b1a76d556d692853a71c47fb48663cf00", + "zh:be91b3f2a697cbbb41f65aad2600972d0ede1e962a7d8a00bb3177cb77d86666", + "zh:efe168ad4aaa6156ce5a31d4e50e9d54d38ee5a5888412f9e690c0de5d619683", ] } diff --git a/deployment/modules/cloudflare/docs/config.tf b/deployment/modules/cloudflare/docs/config.tf index 18d8ff1eb4665..74ea6d5816f8e 100644 --- a/deployment/modules/cloudflare/docs/config.tf +++ b/deployment/modules/cloudflare/docs/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.41.0" + version = "4.43.0" } } } diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index abbba8c6b39b5..b789d8653f168 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -20,6 +20,7 @@ The default configuration looks like this: "acceptedVideoCodecs": ["h264"], "targetAudioCodec": "aac", "acceptedAudioCodecs": ["aac", "mp3", "libopus"], + "acceptedContainers": ["mov", "ogg", "webm"], "targetResolution": "720", "maxBitrate": "0", "bframes": -1, @@ -32,7 +33,8 @@ The default configuration looks like this: "preferredHwDevice": "auto", "transcode": "required", "tonemap": "hable", - "accel": "disabled" + "accel": "disabled", + "accelDecode": false }, "job": { "backgroundTask": { @@ -60,10 +62,13 @@ The default configuration looks like this: "concurrency": 5 }, "thumbnailGeneration": { - "concurrency": 5 + "concurrency": 3 }, "videoConversion": { "concurrency": 1 + }, + "notifications": { + "concurrency": 5 } }, "logging": { @@ -78,40 +83,46 @@ The default configuration looks like this: "modelName": "ViT-B-32__openai" }, "duplicateDetection": { - "enabled": false, - "maxDistance": 0.03 + "enabled": true, + "maxDistance": 0.01 }, "facialRecognition": { "enabled": true, "modelName": "buffalo_l", "minScore": 0.7, - "maxDistance": 0.6, + "maxDistance": 0.5, "minFaces": 3 } }, "map": { "enabled": true, - "lightStyle": "", - "darkStyle": "" + "lightStyle": "https://tiles.immich.cloud/v1/style/light.json", + "darkStyle": "https://tiles.immich.cloud/v1/style/dark.json" }, "reverseGeocoding": { "enabled": true }, + "metadata": { + "faces": { + "import": false + } + }, "oauth": { - "enabled": false, - "issuerUrl": "", + "autoLaunch": false, + "autoRegister": true, + "buttonText": "Login with OAuth", "clientId": "", "clientSecret": "", + "defaultStorageQuota": 0, + "enabled": false, + "issuerUrl": "", + "mobileOverrideEnabled": false, + "mobileRedirectUri": "", "scope": "openid email profile", "signingAlgorithm": "RS256", + "profileSigningAlgorithm": "none", "storageLabelClaim": "preferred_username", - "storageQuotaClaim": "immich_quota", - "defaultStorageQuota": 0, - "buttonText": "Login with OAuth", - "autoRegister": true, - "autoLaunch": false, - "mobileOverrideEnabled": false, - "mobileRedirectUri": "" + "storageQuotaClaim": "immich_quota" }, "passwordLogin": { "enabled": true @@ -122,11 +133,16 @@ The default configuration looks like this: "template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}" }, "image": { - "thumbnailFormat": "webp", - "thumbnailSize": 250, - "previewFormat": "jpeg", - "previewSize": 1440, - "quality": 80, + "thumbnail": { + "format": "webp", + "size": 250, + "quality": 80 + }, + "preview": { + "format": "jpeg", + "size": 1440, + "quality": 80 + }, "colorspace": "p3", "extractEmbedded": false }, @@ -140,23 +156,35 @@ The default configuration looks like this: "theme": { "customCss": "" }, - "user": { - "deleteDelay": 7 - }, "library": { "scan": { "enabled": true, "cronExpression": "0 0 * * *" }, "watch": { - "enabled": false, - "usePolling": false, - "interval": 10000 + "enabled": false } }, "server": { "externalDomain": "", "loginPageMessage": "" + }, + "notifications": { + "smtp": { + "enabled": false, + "from": "", + "replyTo": "", + "transport": { + "ignoreCert": false, + "host": "", + "port": 587, + "username": "", + "password": "" + } + } + }, + "user": { + "deleteDelay": 7 } } ``` diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index e0281085cf1f0..4dd02ec69fc3d 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -76,7 +76,6 @@ describe('/asset', () => { let user2Assets: AssetMediaResponseDto[]; let locationAsset: AssetMediaResponseDto; let ratingAsset: AssetMediaResponseDto; - let facesAsset: AssetMediaResponseDto; const setupTests = async () => { await utils.resetDatabase(); @@ -236,7 +235,7 @@ describe('/asset', () => { await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) }); // asset faces - facesAsset = await utils.createAsset(admin.accessToken, { + const facesAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'portrait.jpg', bytes: await readFile(facesAssetFilepath), diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 6a7d7a6b4df89..80514f1603b0d 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -64,19 +64,19 @@ custom_lint: allowed: # required / wanted - lib/entities/*.entity.dart - - lib/repositories/{album,asset,backup,exif_info,user}.repository.dart - # acceptable exceptions for the time being + - lib/repositories/{album,asset,backup,database,etag,exif_info,user}.repository.dart + # acceptable exceptions for the time being (until Isar is fully replaced) - integration_test/test_utils/general_helper.dart - lib/main.dart - - lib/routing/router.dart - - lib/utils/{db,migration,renderlist_generator}.dart - - test/**.dart - # refactor to make the providers and services testable - lib/pages/common/album_asset_selection.page.dart - - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart - - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,backup/manual_upload,search/all_motion_photos,search/recently_added_asset}.provider.dart - - lib/services/{asset,background,backup,immich_logger,sync}.service.dart + - lib/routing/router.dart + - lib/services/immich_logger.service.dart # not really a service... more a util + - lib/utils/{db,migration,renderlist_generator}.dart - lib/widgets/asset_grid/asset_grid_data_structure.dart + - test/**.dart + # refactor the remaining providers + - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart + - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,search/all_motion_photos,search/recently_added_asset}.provider.dart - import_rule_openapi: message: openapi must only be used through ApiRepositories diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 324c9069fdf46..bb4f3efd267c6 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -588,5 +588,16 @@ "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", - "viewer_unstack": "Un-Stack" -} \ No newline at end of file + "viewer_unstack": "Un-Stack", + "downloading_media": "Downloading media", + "download_finished": "Download finished", + "download_filename": "file: {}", + "downloading": "Downloading...", + "download_complete": "Download complete", + "download_failed": "Download failed", + "download_canceled": "Download canceled", + "download_paused": "Download paused", + "download_enqueue": "Download enqueued", + "download_notfound": "Download not found", + "download_waiting_to_retry": "Waiting to retry" +} diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 3b361c4e1902f..6a9d34ab83bfe 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - background_downloader (0.0.1): + - Flutter - connectivity_plus (0.0.1): - Flutter - ReachabilitySwift @@ -99,6 +101,7 @@ PODS: - Flutter DEPENDENCIES: + - background_downloader (from `.symlinks/plugins/background_downloader/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) @@ -137,6 +140,8 @@ SPEC REPOS: - Toast EXTERNAL SOURCES: + background_downloader: + :path: ".symlinks/plugins/background_downloader/ios" connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/ios" device_info_plus: @@ -189,6 +194,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: + background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c diff --git a/mobile/lib/extensions/collection_extensions.dart b/mobile/lib/extensions/collection_extensions.dart index f71b0aacd3a7d..d27c9e9500dd2 100644 --- a/mobile/lib/extensions/collection_extensions.dart +++ b/mobile/lib/extensions/collection_extensions.dart @@ -70,19 +70,6 @@ extension AssetListExtension on Iterable { } return this; } - - /// Filters out offline assets and returns those that are still accessible by the Immich server - /// TODO: isOffline is removed from Immich, so this method is not useful anymore - Iterable nonOfflineOnly({ - void Function()? errorCallback, - }) { - final bool onlyLive = every((e) => false); - if (!onlyLive) { - if (errorCallback != null) errorCallback(); - return where((a) => false); - } - return this; - } } extension SortedByProperty on Iterable { diff --git a/mobile/lib/interfaces/album.interface.dart b/mobile/lib/interfaces/album.interface.dart index c2ba650b6f407..ba188f127009a 100644 --- a/mobile/lib/interfaces/album.interface.dart +++ b/mobile/lib/interfaces/album.interface.dart @@ -1,21 +1,43 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IAlbumRepository { - Future count({bool? local}); +abstract interface class IAlbumRepository implements IDatabaseRepository { Future create(Album album); - Future getById(int id); + + Future get(int id); + Future getByName( String name, { bool? shared, bool? remote, }); + + Future> getAll({ + bool? shared, + bool? remote, + int? ownerId, + AlbumSort? sortBy, + }); + Future update(Album album); + Future delete(int albumId); - Future> getAll({bool? shared}); + + Future deleteAllLocal(); + + Future count({bool? local}); + + Future addUsers(Album album, List users); + Future removeUsers(Album album, List users); + Future addAssets(Album album, List assets); + Future removeAssets(Album album, List assets); + Future recalculateMetadata(Album album); } + +enum AlbumSort { remoteId, localId } diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart index 0d2dcfa1b5b35..5aec594eb1148 100644 --- a/mobile/lib/interfaces/asset.interface.dart +++ b/mobile/lib/interfaces/asset.interface.dart @@ -1,27 +1,62 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/device_asset.entity.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IAssetRepository { +abstract interface class IAssetRepository implements IDatabaseRepository { Future getByRemoteId(String id); - Future> getAllByRemoteId(Iterable ids); - Future> getByAlbum(Album album, {User? notOwnedBy}); - Future deleteById(List ids); + + Future getByOwnerIdChecksum(int ownerId, String checksum); + + Future> getAllByRemoteId( + Iterable ids, { + AssetState? state, + }); + + Future> getAllByOwnerIdChecksum( + List ids, + List checksums, + ); + Future> getAll({ required int ownerId, - bool? remote, - int limit = 100, + AssetState? state, + AssetSort? sortBy, + int? limit, }); + + Future> getAllLocal(); + + Future> getByAlbum( + Album album, { + Iterable notOwnedBy = const [], + int? ownerId, + AssetState? state, + AssetSort? sortBy, + }); + + Future update(Asset asset); + Future> updateAll(List assets); + Future deleteAllByRemoteId(List ids, {AssetState? state}); + + Future deleteById(List ids); + Future> getMatches({ required List assets, required int ownerId, - bool? remote, + AssetState? state, int limit = 100, }); Future> getDeviceAssetsById(List ids); + Future upsertDeviceAssets(List deviceAssets); + + Future upsertDuplicatedAssets(Iterable duplicatedAssets); + + Future> getAllDuplicatedAssetIds(); } + +enum AssetSort { checksum, ownerIdChecksum } diff --git a/mobile/lib/interfaces/backup.interface.dart b/mobile/lib/interfaces/backup.interface.dart index e343a9d39019f..c32199a58f476 100644 --- a/mobile/lib/interfaces/backup.interface.dart +++ b/mobile/lib/interfaces/backup.interface.dart @@ -1,5 +1,16 @@ import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class IBackupRepository implements IDatabaseRepository { + Future> getAll({BackupAlbumSort? sort}); -abstract interface class IBackupRepository { Future> getIdsBySelection(BackupSelection backup); + + Future> getAllBySelection(BackupSelection backup); + + Future updateAll(List backupAlbums); + + Future deleteAll(List ids); } + +enum BackupAlbumSort { id } diff --git a/mobile/lib/interfaces/database.interface.dart b/mobile/lib/interfaces/database.interface.dart new file mode 100644 index 0000000000000..5645d15c47beb --- /dev/null +++ b/mobile/lib/interfaces/database.interface.dart @@ -0,0 +1,3 @@ +abstract interface class IDatabaseRepository { + Future transaction(Future Function() callback); +} diff --git a/mobile/lib/interfaces/download.interface.dart b/mobile/lib/interfaces/download.interface.dart new file mode 100644 index 0000000000000..dc4f0f57f8c44 --- /dev/null +++ b/mobile/lib/interfaces/download.interface.dart @@ -0,0 +1,14 @@ +import 'package:background_downloader/background_downloader.dart'; + +abstract interface class IDownloadRepository { + void Function(TaskStatusUpdate)? onImageDownloadStatus; + void Function(TaskStatusUpdate)? onVideoDownloadStatus; + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + void Function(TaskProgressUpdate)? onTaskProgress; + + Future> getLiveVideoTasks(); + Future download(DownloadTask task); + Future cancel(String id); + Future deleteAllTrackingRecords(); + Future deleteRecordsWithIds(List id); +} diff --git a/mobile/lib/interfaces/etag.interface.dart b/mobile/lib/interfaces/etag.interface.dart new file mode 100644 index 0000000000000..e567235d1bb2d --- /dev/null +++ b/mobile/lib/interfaces/etag.interface.dart @@ -0,0 +1,14 @@ +import 'package:immich_mobile/entities/etag.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class IETagRepository implements IDatabaseRepository { + Future get(int id); + + Future getById(String id); + + Future> getAllIds(); + + Future upsertAll(List etags); + + Future deleteByIds(List ids); +} diff --git a/mobile/lib/interfaces/exif_info.interface.dart b/mobile/lib/interfaces/exif_info.interface.dart index fa8ca08f9d55f..86608c26d0cf8 100644 --- a/mobile/lib/interfaces/exif_info.interface.dart +++ b/mobile/lib/interfaces/exif_info.interface.dart @@ -1,9 +1,12 @@ import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IExifInfoRepository { +abstract interface class IExifInfoRepository implements IDatabaseRepository { Future get(int id); Future update(ExifInfo exifInfo); + Future> updateAll(List exifInfos); + Future delete(int id); } diff --git a/mobile/lib/interfaces/user.interface.dart b/mobile/lib/interfaces/user.interface.dart index 828a7b2398a50..e6175a7dc9906 100644 --- a/mobile/lib/interfaces/user.interface.dart +++ b/mobile/lib/interfaces/user.interface.dart @@ -1,8 +1,23 @@ import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; -abstract interface class IUserRepository { - Future> getByIds(List ids); +abstract interface class IUserRepository implements IDatabaseRepository { Future get(String id); - Future> getAll({bool self = true}); + + Future> getByIds(List ids); + + Future> getAll({bool self = true, UserSort? sortBy}); + + /// Returns all users whose assets can be accessed (self+partners) + Future> getAllAccessible(); + + Future> upsertAll(List users); + Future update(User user); + + Future deleteById(List ids); + + Future me(); } + +enum UserSort { id } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index dc1df746cb964..40eda30204e01 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:background_downloader/background_downloader.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; @@ -9,6 +10,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/utils/download.dart'; import 'package:timezone/data/latest.dart'; import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/services/background.service.dart'; @@ -72,7 +74,6 @@ Future initApp() async { var log = Logger("ImmichErrorLogger"); FlutterError.onError = (details) { - debugPrint("FlutterError - Catch all: $details"); FlutterError.presentError(details); log.severe( 'FlutterError - Catch all', @@ -82,11 +83,29 @@ Future initApp() async { }; PlatformDispatcher.instance.onError = (error, stack) { + debugPrint("FlutterError - Catch all: $error"); log.severe('PlatformDispatcher - Catch all', error, stack); return true; }; initializeTimeZones(); + + FileDownloader().configureNotification( + running: TaskNotification( + 'downloading_media'.tr(), + 'file: {filename}', + ), + complete: TaskNotification( + 'download_finished'.tr(), + 'file: {filename}', + ), + progressBar: true, + ); + + FileDownloader().trackTasksInGroup( + downloadGroupLivePhoto, + markDownloadedComplete: false, + ); } Future loadDb() async { @@ -188,8 +207,8 @@ class ImmichAppState extends ConsumerState @override Widget build(BuildContext context) { - var router = ref.watch(appRouterProvider); - var immichTheme = ref.watch(immichThemeProvider); + final router = ref.watch(appRouterProvider); + final immichTheme = ref.watch(immichThemeProvider); return MaterialApp( localizationsDelegates: context.localizationDelegates, diff --git a/mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart b/mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart deleted file mode 100644 index 0a354781f81c3..0000000000000 --- a/mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:convert'; - -enum DownloadAssetStatus { idle, loading, success, error } - -class AssetViewerPageState { - // enum - final DownloadAssetStatus downloadAssetStatus; - - AssetViewerPageState({ - required this.downloadAssetStatus, - }); - - AssetViewerPageState copyWith({ - DownloadAssetStatus? downloadAssetStatus, - }) { - return AssetViewerPageState( - downloadAssetStatus: downloadAssetStatus ?? this.downloadAssetStatus, - ); - } - - Map toMap() { - final result = {}; - - result.addAll({'downloadAssetStatus': downloadAssetStatus.index}); - - return result; - } - - factory AssetViewerPageState.fromMap(Map map) { - return AssetViewerPageState( - downloadAssetStatus: - DownloadAssetStatus.values[map['downloadAssetStatus'] ?? 0], - ); - } - - String toJson() => json.encode(toMap()); - - factory AssetViewerPageState.fromJson(String source) => - AssetViewerPageState.fromMap(json.decode(source)); - - @override - String toString() => - 'ImageViewerPageState(downloadAssetStatus: $downloadAssetStatus)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is AssetViewerPageState && - other.downloadAssetStatus == downloadAssetStatus; - } - - @override - int get hashCode => downloadAssetStatus.hashCode; -} diff --git a/mobile/lib/models/download/download_state.model.dart b/mobile/lib/models/download/download_state.model.dart new file mode 100644 index 0000000000000..edd2fa183ec5d --- /dev/null +++ b/mobile/lib/models/download/download_state.model.dart @@ -0,0 +1,109 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:collection/collection.dart'; + +class DownloadInfo { + final String fileName; + final double progress; + // enum + final TaskStatus status; + + DownloadInfo({ + required this.fileName, + required this.progress, + required this.status, + }); + + DownloadInfo copyWith({ + String? fileName, + double? progress, + TaskStatus? status, + }) { + return DownloadInfo( + fileName: fileName ?? this.fileName, + progress: progress ?? this.progress, + status: status ?? this.status, + ); + } + + Map toMap() { + return { + 'fileName': fileName, + 'progress': progress, + 'status': status.index, + }; + } + + factory DownloadInfo.fromMap(Map map) { + return DownloadInfo( + fileName: map['fileName'] as String, + progress: map['progress'] as double, + status: TaskStatus.values[map['status'] as int], + ); + } + + String toJson() => json.encode(toMap()); + + factory DownloadInfo.fromJson(String source) => + DownloadInfo.fromMap(json.decode(source) as Map); + + @override + String toString() => + 'DownloadInfo(fileName: $fileName, progress: $progress, status: $status)'; + + @override + bool operator ==(covariant DownloadInfo other) { + if (identical(this, other)) return true; + + return other.fileName == fileName && + other.progress == progress && + other.status == status; + } + + @override + int get hashCode => fileName.hashCode ^ progress.hashCode ^ status.hashCode; +} + +class DownloadState { + // enum + final TaskStatus downloadStatus; + final Map taskProgress; + final bool showProgress; + DownloadState({ + required this.downloadStatus, + required this.taskProgress, + required this.showProgress, + }); + + DownloadState copyWith({ + TaskStatus? downloadStatus, + Map? taskProgress, + bool? showProgress, + }) { + return DownloadState( + downloadStatus: downloadStatus ?? this.downloadStatus, + taskProgress: taskProgress ?? this.taskProgress, + showProgress: showProgress ?? this.showProgress, + ); + } + + @override + String toString() => + 'DownloadState(downloadStatus: $downloadStatus, taskProgress: $taskProgress, showProgress: $showProgress)'; + + @override + bool operator ==(covariant DownloadState other) { + if (identical(this, other)) return true; + final mapEquals = const DeepCollectionEquality().equals; + + return other.downloadStatus == downloadStatus && + mapEquals(other.taskProgress, taskProgress) && + other.showProgress == showProgress; + } + + @override + int get hashCode => + downloadStatus.hashCode ^ taskProgress.hashCode ^ showProgress.hashCode; +} diff --git a/mobile/lib/models/download/livephotos_medatada.model.dart b/mobile/lib/models/download/livephotos_medatada.model.dart new file mode 100644 index 0000000000000..9c0c7ae4e95c7 --- /dev/null +++ b/mobile/lib/models/download/livephotos_medatada.model.dart @@ -0,0 +1,60 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +enum LivePhotosPart { + video, + image, +} + +class LivePhotosMetadata { + // enum + LivePhotosPart part; + + String id; + LivePhotosMetadata({ + required this.part, + required this.id, + }); + + LivePhotosMetadata copyWith({ + LivePhotosPart? part, + String? id, + }) { + return LivePhotosMetadata( + part: part ?? this.part, + id: id ?? this.id, + ); + } + + Map toMap() { + return { + 'part': part.index, + 'id': id, + }; + } + + factory LivePhotosMetadata.fromMap(Map map) { + return LivePhotosMetadata( + part: LivePhotosPart.values[map['part'] as int], + id: map['id'] as String, + ); + } + + String toJson() => json.encode(toMap()); + + factory LivePhotosMetadata.fromJson(String source) => + LivePhotosMetadata.fromMap(json.decode(source) as Map); + + @override + String toString() => 'LivePhotosMetadata(part: $part, id: $id)'; + + @override + bool operator ==(covariant LivePhotosMetadata other) { + if (identical(this, other)) return true; + + return other.part == part && other.id == id; + } + + @override + int get hashCode => part.hashCode ^ id.hashCode; +} diff --git a/mobile/lib/pages/common/download_panel.dart b/mobile/lib/pages/common/download_panel.dart new file mode 100644 index 0000000000000..95cefd742af0a --- /dev/null +++ b/mobile/lib/pages/common/download_panel.dart @@ -0,0 +1,150 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; + +class DownloadPanel extends ConsumerWidget { + const DownloadPanel({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final showProgress = ref.watch( + downloadStateProvider.select((state) => state.showProgress), + ); + + final tasks = ref + .watch( + downloadStateProvider.select((state) => state.taskProgress), + ) + .entries + .toList(); + + onCancelDownload(String id) { + ref.watch(downloadStateProvider.notifier).cancelDownload(id); + } + + return Positioned( + bottom: 140, + left: 16, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: showProgress + ? ConstrainedBox( + constraints: + BoxConstraints.loose(Size(context.width - 32, 300)), + child: ListView.builder( + shrinkWrap: true, + itemCount: tasks.length, + itemBuilder: (context, index) { + final task = tasks[index]; + return DownloadTaskTile( + progress: task.value.progress, + fileName: task.value.fileName, + status: task.value.status, + onCancelDownload: () => onCancelDownload(task.key), + ); + }, + ), + ) + : const SizedBox.shrink(key: ValueKey('no_progress')), + ), + ); + } +} + +class DownloadTaskTile extends StatelessWidget { + final double progress; + final String fileName; + final TaskStatus status; + final VoidCallback onCancelDownload; + + const DownloadTaskTile({ + super.key, + required this.progress, + required this.fileName, + required this.status, + required this.onCancelDownload, + }); + + @override + Widget build(BuildContext context) { + final progressPercent = (progress * 100).round(); + + getStatusText() { + switch (status) { + case TaskStatus.running: + return 'downloading'.tr(); + case TaskStatus.complete: + return 'download_complete'.tr(); + case TaskStatus.failed: + return 'download_failed'.tr(); + case TaskStatus.canceled: + return 'download_canceled'.tr(); + case TaskStatus.paused: + return 'download_paused'.tr(); + case TaskStatus.enqueued: + return 'download_enqueue'.tr(); + case TaskStatus.notFound: + return 'download_notfound'.tr(); + case TaskStatus.waitingToRetry: + return 'download_waiting_to_retry'.tr(); + } + } + + return SizedBox( + key: const ValueKey('download_progress'), + width: MediaQuery.of(context).size.width - 32, + child: Card( + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: ListTile( + minVerticalPadding: 18, + leading: const Icon(Icons.video_file_outlined), + title: Text( + getStatusText(), + style: context.textTheme.labelLarge, + ), + trailing: IconButton( + icon: Icon(Icons.close, color: context.colorScheme.onError), + onPressed: onCancelDownload, + style: ElevatedButton.styleFrom( + backgroundColor: context.colorScheme.error.withAlpha(200), + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + style: context.textTheme.labelMedium, + ), + Row( + children: [ + Expanded( + child: LinearProgressIndicator( + minHeight: 8.0, + value: progress, + borderRadius: + const BorderRadius.all(Radius.circular(10.0)), + ), + ), + const SizedBox(width: 8), + Text( + '$progressPercent%', + style: context.textTheme.labelSmall, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 1434d1cca5f59..57c75ca84df84 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -11,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/pages/common/download_panel.dart'; import 'package:immich_mobile/pages/common/video_viewer.page.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; @@ -421,6 +422,7 @@ class GalleryViewerPage extends HookConsumerWidget { ], ), ), + const DownloadPanel(), ], ), ), diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart index a2c3987aa8165..c7e75df79b27f 100644 --- a/mobile/lib/providers/asset.provider.dart +++ b/mobile/lib/providers/asset.provider.dart @@ -275,28 +275,14 @@ class AssetNotifier extends StateNotifier { return isSuccess ? remote.toList() : []; } - Future toggleFavorite(List assets, [bool? status]) async { + Future toggleFavorite(List assets, [bool? status]) { status ??= !assets.every((a) => a.isFavorite); - final newAssets = await _assetService.changeFavoriteStatus(assets, status); - for (Asset? newAsset in newAssets) { - if (newAsset == null) { - log.severe("Change favorite status failed for asset"); - continue; - } - } + return _assetService.changeFavoriteStatus(assets, status); } - Future toggleArchive(List assets, [bool? status]) async { + Future toggleArchive(List assets, [bool? status]) { status ??= !assets.every((a) => a.isArchived); - final newAssets = await _assetService.changeArchiveStatus(assets, status); - int i = 0; - for (Asset oldAsset in assets) { - final newAsset = newAssets[i++]; - if (newAsset == null) { - log.severe("Change archive status failed for asset ${oldAsset.id}"); - continue; - } - } + return _assetService.changeArchiveStatus(assets, status); } } diff --git a/mobile/lib/providers/asset_viewer/download.provider.dart b/mobile/lib/providers/asset_viewer/download.provider.dart new file mode 100644 index 0000000000000..d4aa2823b5b2f --- /dev/null +++ b/mobile/lib/providers/asset_viewer/download.provider.dart @@ -0,0 +1,191 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/download/download_state.model.dart'; +import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; +import 'package:immich_mobile/services/download.service.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/services/share.service.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/widgets/common/share_dialog.dart'; + +class DownloadStateNotifier extends StateNotifier { + final DownloadService _downloadService; + final ShareService _shareService; + + DownloadStateNotifier( + this._downloadService, + this._shareService, + ) : super( + DownloadState( + downloadStatus: TaskStatus.complete, + showProgress: false, + taskProgress: {}, + ), + ) { + _downloadService.onImageDownloadStatus = _downloadImageCallback; + _downloadService.onVideoDownloadStatus = _downloadVideoCallback; + _downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback; + _downloadService.onTaskProgress = _taskProgressCallback; + } + + void _updateDownloadStatus(String taskId, TaskStatus status) { + if (status == TaskStatus.canceled) { + return; + } + + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..addAll({ + taskId: DownloadInfo( + progress: state.taskProgress[taskId]?.progress ?? 0, + fileName: state.taskProgress[taskId]?.fileName ?? '', + status: status, + ), + }), + ); + } + + // Download live photo callback + void _downloadLivePhotoCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + if (update.task.metaData.isEmpty) { + return; + } + final livePhotosId = + LivePhotosMetadata.fromJson(update.task.metaData).id; + _downloadService.saveLivePhotos(update.task, livePhotosId); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + // Download image callback + void _downloadImageCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + _downloadService.saveImage(update.task); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + // Download video callback + void _downloadVideoCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + _downloadService.saveVideo(update.task); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + void _taskProgressCallback(TaskProgressUpdate update) { + // Ignore if the task is cancled or completed + if (update.progress == -2 || update.progress == -1) { + return; + } + + state = state.copyWith( + showProgress: true, + taskProgress: {} + ..addAll(state.taskProgress) + ..addAll({ + update.task.taskId: DownloadInfo( + progress: update.progress, + fileName: update.task.filename, + status: TaskStatus.running, + ), + }), + ); + } + + void _onDownloadComplete(String id) { + Future.delayed(const Duration(seconds: 2), () { + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..remove(id), + ); + + if (state.taskProgress.isEmpty) { + state = state.copyWith( + showProgress: false, + ); + } + }); + } + + void downloadAsset(Asset asset, BuildContext context) async { + await _downloadService.download(asset); + } + + void cancelDownload(String id) async { + final isCanceled = await _downloadService.cancelDownload(id); + + if (isCanceled) { + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..remove(id), + ); + } + + if (state.taskProgress.isEmpty) { + state = state.copyWith( + showProgress: false, + ); + } + } + + void shareAsset(Asset asset, BuildContext context) async { + showDialog( + context: context, + builder: (BuildContext buildContext) { + _shareService.shareAsset(asset, context).then( + (bool status) { + if (!status) { + ImmichToast.show( + context: context, + msg: 'image_viewer_page_state_provider_share_error'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } + buildContext.pop(); + }, + ); + return const ShareDialog(); + }, + barrierDismissible: false, + ); + } +} + +final downloadStateProvider = + StateNotifierProvider( + ((ref) => DownloadStateNotifier( + ref.watch(downloadServiceProvider), + ref.watch(shareServiceProvider), + )), +); diff --git a/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart b/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart deleted file mode 100644 index 631011f200bbd..0000000000000 --- a/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'dart:io'; - -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:fluttertoast/fluttertoast.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/services/album.service.dart'; -import 'package:immich_mobile/models/asset_viewer/asset_viewer_page_state.model.dart'; -import 'package:immich_mobile/services/image_viewer.service.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/services/share.service.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:immich_mobile/widgets/common/share_dialog.dart'; - -class ImageViewerStateNotifier extends StateNotifier { - final ImageViewerService _imageViewerService; - final ShareService _shareService; - final AlbumService _albumService; - - ImageViewerStateNotifier( - this._imageViewerService, - this._shareService, - this._albumService, - ) : super( - AssetViewerPageState( - downloadAssetStatus: DownloadAssetStatus.idle, - ), - ); - - void downloadAsset(Asset asset, BuildContext context) async { - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading); - - ImmichToast.show( - context: context, - msg: 'download_started'.tr(), - toastType: ToastType.info, - gravity: ToastGravity.BOTTOM, - ); - - bool isSuccess = await _imageViewerService.downloadAsset(asset); - - if (isSuccess) { - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.success); - - ImmichToast.show( - context: context, - msg: Platform.isAndroid - ? 'download_sucess_android'.tr() - : 'download_sucess'.tr(), - toastType: ToastType.success, - gravity: ToastGravity.BOTTOM, - ); - _albumService.refreshDeviceAlbums(); - } else { - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.error); - ImmichToast.show( - context: context, - msg: 'download_error'.tr(), - toastType: ToastType.error, - gravity: ToastGravity.BOTTOM, - ); - } - - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle); - } - - void shareAsset(Asset asset, BuildContext context) async { - showDialog( - context: context, - builder: (BuildContext buildContext) { - _shareService.shareAsset(asset, context).then( - (bool status) { - if (!status) { - ImmichToast.show( - context: context, - msg: 'image_viewer_page_state_provider_share_error'.tr(), - toastType: ToastType.error, - gravity: ToastGravity.BOTTOM, - ); - } - buildContext.pop(); - }, - ); - return const ShareDialog(); - }, - barrierDismissible: false, - ); - } -} - -final imageViewerStateProvider = - StateNotifierProvider( - ((ref) => ImageViewerStateNotifier( - ref.watch(imageViewerServiceProvider), - ref.watch(shareServiceProvider), - ref.watch(albumServiceProvider), - )), -); diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 0885f35f77998..dc6d2f7cc89cb 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -7,6 +7,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; @@ -17,6 +18,7 @@ import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; @@ -45,6 +47,7 @@ class BackupNotifier extends StateNotifier { this._db, this._albumMediaRepository, this._fileMediaRepository, + this._backupRepository, this.ref, ) : super( BackUpState( @@ -95,6 +98,7 @@ class BackupNotifier extends StateNotifier { final Isar _db; final IAlbumMediaRepository _albumMediaRepository; final IFileMediaRepository _fileMediaRepository; + final IBackupRepository _backupRepository; final Ref ref; /// @@ -255,9 +259,9 @@ class BackupNotifier extends StateNotifier { state = state.copyWith(availableAlbums: availableAlbums); final List excludedBackupAlbums = - await _backupService.excludedAlbumsQuery().findAll(); + await _backupRepository.getAllBySelection(BackupSelection.exclude); final List selectedBackupAlbums = - await _backupService.selectedAlbumsQuery().findAll(); + await _backupRepository.getAllBySelection(BackupSelection.select); final Set selectedAlbums = {}; for (final BackupAlbum ba in selectedBackupAlbums) { @@ -767,6 +771,7 @@ final backupProvider = ref.watch(dbProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(fileMediaRepositoryProvider), + ref.watch(backupRepositoryProvider), ref, ); }); diff --git a/mobile/lib/providers/backup/manual_upload.provider.dart b/mobile/lib/providers/backup/manual_upload.provider.dart index 0cf159bfddb1c..192126f0859c7 100644 --- a/mobile/lib/providers/backup/manual_upload.provider.dart +++ b/mobile/lib/providers/backup/manual_upload.provider.dart @@ -6,8 +6,10 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/widgets.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; @@ -25,7 +27,6 @@ import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; @@ -36,6 +37,7 @@ final manualUploadProvider = ref.watch(localNotificationService), ref.watch(backupProvider.notifier), ref.watch(backupServiceProvider), + ref.watch(backupRepositoryProvider), ref, ); }); @@ -45,12 +47,14 @@ class ManualUploadNotifier extends StateNotifier { final LocalNotificationService _localNotificationService; final BackupNotifier _backupProvider; final BackupService _backupService; + final BackupRepository _backupRepository; final Ref ref; ManualUploadNotifier( this._localNotificationService, this._backupProvider, this._backupService, + this._backupRepository, this.ref, ) : super( ManualUploadState( @@ -206,9 +210,9 @@ class ManualUploadNotifier extends StateNotifier { } final selectedBackupAlbums = - _backupService.selectedAlbumsQuery().findAllSync(); + await _backupRepository.getAllBySelection(BackupSelection.select); final excludedBackupAlbums = - _backupService.excludedAlbumsQuery().findAllSync(); + await _backupRepository.getAllBySelection(BackupSelection.exclude); // Get candidates from selected albums and excluded albums Set candidates = diff --git a/mobile/lib/repositories/activity_api.repository.dart b/mobile/lib/repositories/activity_api.repository.dart index 0b1b4d99f36df..8da3759709327 100644 --- a/mobile/lib/repositories/activity_api.repository.dart +++ b/mobile/lib/repositories/activity_api.repository.dart @@ -3,14 +3,14 @@ import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/activity_api.interface.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final activityApiRepositoryProvider = Provider( (ref) => ActivityApiRepository(ref.watch(apiServiceProvider).activitiesApi), ); -class ActivityApiRepository extends BaseApiRepository +class ActivityApiRepository extends ApiRepository implements IActivityApiRepository { final ActivitiesApi _api; diff --git a/mobile/lib/repositories/album.repository.dart b/mobile/lib/repositories/album.repository.dart index 08c939aa6ca87..35f5cae32722c 100644 --- a/mobile/lib/repositories/album.repository.dart +++ b/mobile/lib/repositories/album.repository.dart @@ -4,32 +4,36 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; final albumRepositoryProvider = Provider((ref) => AlbumRepository(ref.watch(dbProvider))); -class AlbumRepository implements IAlbumRepository { - final Isar _db; - - AlbumRepository( - this._db, - ); +class AlbumRepository extends DatabaseRepository implements IAlbumRepository { + AlbumRepository(super.db); @override Future count({bool? local}) { - if (local == true) return _db.albums.where().localIdIsNotNull().count(); - if (local == false) return _db.albums.where().remoteIdIsNotNull().count(); - return _db.albums.count(); + final baseQuery = db.albums.where(); + final QueryBuilder query; + switch (local) { + case null: + query = baseQuery.noOp(); + case true: + query = baseQuery.localIdIsNotNull(); + case false: + query = baseQuery.remoteIdIsNotNull(); + } + return query.count(); } @override - Future create(Album album) => - _db.writeTxn(() => _db.albums.store(album)); + Future create(Album album) => txn(() => db.albums.store(album)); @override Future getByName(String name, {bool? shared, bool? remote}) { - var query = _db.albums.filter().nameEqualTo(name); + var query = db.albums.filter().nameEqualTo(name); if (shared != null) { query = query.sharedEqualTo(shared); } @@ -42,37 +46,61 @@ class AlbumRepository implements IAlbumRepository { } @override - Future update(Album album) => - _db.writeTxn(() => _db.albums.store(album)); + Future update(Album album) => txn(() => db.albums.store(album)); @override - Future delete(int albumId) => - _db.writeTxn(() => _db.albums.delete(albumId)); + Future delete(int albumId) => txn(() => db.albums.delete(albumId)); @override - Future> getAll({bool? shared}) { - final baseQuery = _db.albums.filter(); - QueryBuilder? query; - if (shared != null) { - query = baseQuery.sharedEqualTo(true); + Future> getAll({ + bool? shared, + bool? remote, + int? ownerId, + AlbumSort? sortBy, + }) { + final baseQuery = db.albums.where(); + final QueryBuilder afterWhere; + if (remote == null) { + afterWhere = baseQuery.noOp(); + } else if (remote) { + afterWhere = baseQuery.remoteIdIsNotNull(); + } else { + afterWhere = baseQuery.localIdIsNotNull(); } - return query?.findAll() ?? _db.albums.where().findAll(); + QueryBuilder filterQuery = + afterWhere.filter().noOp(); + if (shared != null) { + filterQuery = filterQuery.sharedEqualTo(true); + } + if (ownerId != null) { + filterQuery = filterQuery.owner((q) => q.isarIdEqualTo(ownerId)); + } + final QueryBuilder query; + switch (sortBy) { + case null: + query = filterQuery.noOp(); + case AlbumSort.remoteId: + query = filterQuery.sortByRemoteId(); + case AlbumSort.localId: + query = filterQuery.sortByLocalId(); + } + return query.findAll(); } @override - Future getById(int id) => _db.albums.get(id); + Future get(int id) => db.albums.get(id); @override Future removeUsers(Album album, List users) => - _db.writeTxn(() => album.sharedUsers.update(unlink: users)); + txn(() => album.sharedUsers.update(unlink: users)); @override Future addAssets(Album album, List assets) => - _db.writeTxn(() => album.assets.update(link: assets)); + txn(() => album.assets.update(link: assets)); @override Future removeAssets(Album album, List assets) => - _db.writeTxn(() => album.assets.update(unlink: assets)); + txn(() => album.assets.update(unlink: assets)); @override Future recalculateMetadata(Album album) async { @@ -82,4 +110,12 @@ class AlbumRepository implements IAlbumRepository { await album.assets.filter().updatedAtProperty().max(); return album; } + + @override + Future addUsers(Album album, List users) => + txn(() => album.sharedUsers.update(link: users)); + + @override + Future deleteAllLocal() => + txn(() => db.albums.where().localIdIsNotNull().deleteAll()); } diff --git a/mobile/lib/repositories/album_api.repository.dart b/mobile/lib/repositories/album_api.repository.dart index 0e27e44684a67..5d0b56dc7882a 100644 --- a/mobile/lib/repositories/album_api.repository.dart +++ b/mobile/lib/repositories/album_api.repository.dart @@ -4,15 +4,14 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final albumApiRepositoryProvider = Provider( (ref) => AlbumApiRepository(ref.watch(apiServiceProvider).albumsApi), ); -class AlbumApiRepository extends BaseApiRepository - implements IAlbumApiRepository { +class AlbumApiRepository extends ApiRepository implements IAlbumApiRepository { final AlbumsApi _api; AlbumApiRepository(this._api); @@ -26,7 +25,7 @@ class AlbumApiRepository extends BaseApiRepository @override Future> getAll({bool? shared}) async { final dtos = await checkNull(_api.getAllAlbums(shared: shared)); - return dtos.map(_toAlbum).toList().cast(); + return dtos.map(_toAlbum).toList(); } @override diff --git a/mobile/lib/repositories/base_api.repository.dart b/mobile/lib/repositories/api.repository.dart similarity index 71% rename from mobile/lib/repositories/base_api.repository.dart rename to mobile/lib/repositories/api.repository.dart index 418cba84f886c..b454c77f9b7da 100644 --- a/mobile/lib/repositories/base_api.repository.dart +++ b/mobile/lib/repositories/api.repository.dart @@ -1,8 +1,6 @@ -import 'package:flutter/foundation.dart'; import 'package:immich_mobile/constants/errors.dart'; -abstract class BaseApiRepository { - @protected +abstract class ApiRepository { Future checkNull(Future future) async { final response = await future; if (response == null) throw NoResponseDtoError(); diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart index 087344302a417..eaaafd3045a39 100644 --- a/mobile/lib/repositories/asset.repository.dart +++ b/mobile/lib/repositories/asset.repository.dart @@ -5,78 +5,145 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/device_asset.entity.dart'; +import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; +import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; final assetRepositoryProvider = Provider((ref) => AssetRepository(ref.watch(dbProvider))); -class AssetRepository implements IAssetRepository { - final Isar _db; - - AssetRepository( - this._db, - ); +class AssetRepository extends DatabaseRepository implements IAssetRepository { + AssetRepository(super.db); @override - Future> getByAlbum(Album album, {User? notOwnedBy}) { + Future> getByAlbum( + Album album, { + Iterable notOwnedBy = const [], + int? ownerId, + AssetState? state, + AssetSort? sortBy, + }) { var query = album.assets.filter(); - if (notOwnedBy != null) { - query = query.not().ownerIdEqualTo(notOwnedBy.isarId); + if (notOwnedBy.length == 1) { + query = query.not().ownerIdEqualTo(notOwnedBy.first); + } else if (notOwnedBy.isNotEmpty) { + query = + query.not().anyOf(notOwnedBy, (q, int id) => q.ownerIdEqualTo(id)); } - return query.findAll(); + if (ownerId != null) { + query = query.ownerIdEqualTo(ownerId); + } + + switch (state) { + case null: + break; + case AssetState.local: + query = query.remoteIdIsNull(); + case AssetState.remote: + query = query.localIdIsNull(); + case AssetState.merged: + query = query.localIdIsNotNull().remoteIdIsNotNull(); + } + + final QueryBuilder sortedQuery; + + switch (sortBy) { + case null: + sortedQuery = query.noOp(); + case AssetSort.checksum: + sortedQuery = query.sortByChecksum(); + case AssetSort.ownerIdChecksum: + sortedQuery = query.sortByOwnerId().thenByChecksum(); + } + + return sortedQuery.findAll(); } @override - Future deleteById(List ids) => - _db.writeTxn(() => _db.assets.deleteAll(ids)); + Future deleteById(List ids) => txn(() async { + await db.assets.deleteAll(ids); + await db.exifInfos.deleteAll(ids); + }); @override - Future getByRemoteId(String id) => _db.assets.getByRemoteId(id); + Future getByRemoteId(String id) => db.assets.getByRemoteId(id); @override - Future> getAllByRemoteId(Iterable ids) => - _db.assets.getAllByRemoteId(ids); + Future> getAllByRemoteId( + Iterable ids, { + AssetState? state, + }) => + _getAllByRemoteIdImpl(ids, state).findAll(); + + QueryBuilder _getAllByRemoteIdImpl( + Iterable ids, + AssetState? state, + ) { + final query = db.assets.remote(ids).filter(); + switch (state) { + case null: + return query.noOp(); + case AssetState.local: + return query.remoteIdIsNull(); + case AssetState.remote: + return query.localIdIsNull(); + case AssetState.merged: + return query.localIdIsNotEmpty().remoteIdIsNotNull(); + } + } @override Future> getAll({ required int ownerId, - bool? remote, - int limit = 100, + AssetState? state, + AssetSort? sortBy, + int? limit, }) { - if (remote == null) { - return _db.assets - .where() - .ownerIdEqualToAnyChecksum(ownerId) - .limit(limit) - .findAll(); - } - final QueryBuilder query; - if (remote) { - query = _db.assets - .where() - .localIdIsNull() - .filter() - .remoteIdIsNotNull() - .ownerIdEqualTo(ownerId); - } else { - query = _db.assets - .where() - .remoteIdIsNull() - .filter() - .localIdIsNotNull() - .ownerIdEqualTo(ownerId); + final baseQuery = db.assets.where(); + final QueryBuilder filteredQuery; + switch (state) { + case null: + filteredQuery = baseQuery.ownerIdEqualToAnyChecksum(ownerId).noOp(); + case AssetState.local: + filteredQuery = baseQuery + .remoteIdIsNull() + .filter() + .localIdIsNotNull() + .ownerIdEqualTo(ownerId); + case AssetState.remote: + filteredQuery = baseQuery + .localIdIsNull() + .filter() + .remoteIdIsNotNull() + .ownerIdEqualTo(ownerId); + case AssetState.merged: + filteredQuery = baseQuery + .ownerIdEqualToAnyChecksum(ownerId) + .filter() + .remoteIdIsNotNull() + .localIdIsNotNull(); } - return query.limit(limit).findAll(); + final QueryBuilder query; + switch (sortBy) { + case null: + query = filteredQuery.noOp(); + case AssetSort.checksum: + query = filteredQuery.sortByChecksum(); + case AssetSort.ownerIdChecksum: + query = filteredQuery.sortByOwnerId().thenByChecksum(); + } + + return limit == null ? query.findAll() : query.limit(limit).findAll(); } @override Future> updateAll(List assets) async { - await _db.writeTxn(() => _db.assets.putAll(assets)); + await txn(() => db.assets.putAll(assets)); return assets; } @@ -84,16 +151,20 @@ class AssetRepository implements IAssetRepository { Future> getMatches({ required List assets, required int ownerId, - bool? remote, + AssetState? state, int limit = 100, }) { + final baseQuery = db.assets.where(); final QueryBuilder query; - if (remote == null) { - query = _db.assets.filter().remoteIdIsNotNull().or().localIdIsNotNull(); - } else if (remote) { - query = _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(); - } else { - query = _db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(); + switch (state) { + case null: + query = baseQuery.noOp(); + case AssetState.local: + query = baseQuery.remoteIdIsNull().filter().localIdIsNotNull(); + case AssetState.remote: + query = baseQuery.localIdIsNull().filter().remoteIdIsNotNull(); + case AssetState.merged: + query = baseQuery.localIdIsNotNull().filter().remoteIdIsNotNull(); } return _getMatchesImpl(query, ownerId, assets, limit); } @@ -101,16 +172,50 @@ class AssetRepository implements IAssetRepository { @override Future> getDeviceAssetsById(List ids) => Platform.isAndroid - ? _db.androidDeviceAssets.getAll(ids.cast()) - : _db.iOSDeviceAssets.getAllById(ids.cast()); + ? db.androidDeviceAssets.getAll(ids.cast()) + : db.iOSDeviceAssets.getAllById(ids.cast()); @override - Future upsertDeviceAssets(List deviceAssets) => - _db.writeTxn( + Future upsertDeviceAssets(List deviceAssets) => txn( () => Platform.isAndroid - ? _db.androidDeviceAssets.putAll(deviceAssets.cast()) - : _db.iOSDeviceAssets.putAll(deviceAssets.cast()), + ? db.androidDeviceAssets.putAll(deviceAssets.cast()) + : db.iOSDeviceAssets.putAll(deviceAssets.cast()), ); + + @override + Future update(Asset asset) async { + await txn(() => asset.put(db)); + return asset; + } + + @override + Future upsertDuplicatedAssets(Iterable duplicatedAssets) => txn( + () => db.duplicatedAssets + .putAll(duplicatedAssets.map(DuplicatedAsset.new).toList()), + ); + + @override + Future> getAllDuplicatedAssetIds() => + db.duplicatedAssets.where().idProperty().findAll(); + + @override + Future getByOwnerIdChecksum(int ownerId, String checksum) => + db.assets.getByOwnerIdChecksum(ownerId, checksum); + + @override + Future> getAllByOwnerIdChecksum( + List ids, + List checksums, + ) => + db.assets.getAllByOwnerIdChecksum(ids, checksums); + + @override + Future> getAllLocal() => + db.assets.where().localIdIsNotNull().findAll(); + + @override + Future deleteAllByRemoteId(List ids, {AssetState? state}) => + txn(() => _getAllByRemoteIdImpl(ids, state).deleteAll()); } Future> _getMatchesImpl( diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index eb796f6c6b5d2..54d57c4dfccf6 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/interfaces/asset_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final assetApiRepositoryProvider = Provider( @@ -12,8 +12,7 @@ final assetApiRepositoryProvider = Provider( ), ); -class AssetApiRepository extends BaseApiRepository - implements IAssetApiRepository { +class AssetApiRepository extends ApiRepository implements IAssetApiRepository { final AssetsApi _api; final SearchApi _searchApi; diff --git a/mobile/lib/repositories/backup.repository.dart b/mobile/lib/repositories/backup.repository.dart index c9d93f787769b..61997ff23ae22 100644 --- a/mobile/lib/repositories/backup.repository.dart +++ b/mobile/lib/repositories/backup.repository.dart @@ -2,19 +2,41 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; final backupRepositoryProvider = Provider((ref) => BackupRepository(ref.watch(dbProvider))); -class BackupRepository implements IBackupRepository { - final Isar _db; +class BackupRepository extends DatabaseRepository implements IBackupRepository { + BackupRepository(super.db); - BackupRepository( - this._db, - ); + @override + Future> getAll({BackupAlbumSort? sort}) { + final baseQuery = db.backupAlbums.where(); + final QueryBuilder query; + switch (sort) { + case null: + query = baseQuery.noOp(); + case BackupAlbumSort.id: + query = baseQuery.sortById(); + } + return query.findAll(); + } @override Future> getIdsBySelection(BackupSelection backup) => - _db.backupAlbums.filter().selectionEqualTo(backup).idProperty().findAll(); + db.backupAlbums.filter().selectionEqualTo(backup).idProperty().findAll(); + + @override + Future> getAllBySelection(BackupSelection backup) => + db.backupAlbums.filter().selectionEqualTo(backup).findAll(); + + @override + Future deleteAll(List ids) => + txn(() => db.backupAlbums.deleteAll(ids)); + + @override + Future updateAll(List backupAlbums) => + txn(() => db.backupAlbums.putAll(backupAlbums)); } diff --git a/mobile/lib/repositories/database.repository.dart b/mobile/lib/repositories/database.repository.dart new file mode 100644 index 0000000000000..f9ee1426bbee7 --- /dev/null +++ b/mobile/lib/repositories/database.repository.dart @@ -0,0 +1,28 @@ +import 'dart:async'; + +import 'package:immich_mobile/interfaces/database.interface.dart'; +import 'package:isar/isar.dart'; + +/// copied from Isar; needed to check if an async transaction is already active +const Symbol _zoneTxn = #zoneTxn; + +abstract class DatabaseRepository implements IDatabaseRepository { + final Isar db; + DatabaseRepository(this.db); + + bool get inTxn => Zone.current[_zoneTxn] != null; + + Future txn(Future Function() callback) => + inTxn ? callback() : transaction(callback); + + @override + Future transaction(Future Function() callback) => + db.writeTxn(callback); +} + +extension Asd on QueryBuilder { + QueryBuilder noOp() { + // ignore: invalid_use_of_protected_member + return QueryBuilder.apply(this, (query) => query); + } +} diff --git a/mobile/lib/repositories/download.repository.dart b/mobile/lib/repositories/download.repository.dart new file mode 100644 index 0000000000000..5b42f66b02148 --- /dev/null +++ b/mobile/lib/repositories/download.repository.dart @@ -0,0 +1,68 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/download.interface.dart'; +import 'package:immich_mobile/utils/download.dart'; + +final downloadRepositoryProvider = Provider((ref) => DownloadRepository()); + +class DownloadRepository implements IDownloadRepository { + @override + void Function(TaskStatusUpdate)? onImageDownloadStatus; + + @override + void Function(TaskStatusUpdate)? onVideoDownloadStatus; + + @override + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + + @override + void Function(TaskProgressUpdate)? onTaskProgress; + + DownloadRepository() { + FileDownloader().registerCallbacks( + group: downloadGroupImage, + taskStatusCallback: (update) => onImageDownloadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + + FileDownloader().registerCallbacks( + group: downloadGroupVideo, + taskStatusCallback: (update) => onVideoDownloadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + + FileDownloader().registerCallbacks( + group: downloadGroupLivePhoto, + taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + } + + @override + Future download(DownloadTask task) { + return FileDownloader().enqueue(task); + } + + @override + Future deleteAllTrackingRecords() { + return FileDownloader().database.deleteAllRecords(); + } + + @override + Future cancel(String id) { + return FileDownloader().cancelTaskWithId(id); + } + + @override + Future> getLiveVideoTasks() { + return FileDownloader().database.allRecordsWithStatus( + TaskStatus.complete, + group: downloadGroupLivePhoto, + ); + } + + @override + Future deleteRecordsWithIds(List ids) { + return FileDownloader().database.deleteRecordsWithIds(ids); + } +} diff --git a/mobile/lib/repositories/etag.repository.dart b/mobile/lib/repositories/etag.repository.dart new file mode 100644 index 0000000000000..9921b69f5ec0a --- /dev/null +++ b/mobile/lib/repositories/etag.repository.dart @@ -0,0 +1,29 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/etag.entity.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; +import 'package:isar/isar.dart'; + +final etagRepositoryProvider = + Provider((ref) => ETagRepository(ref.watch(dbProvider))); + +class ETagRepository extends DatabaseRepository implements IETagRepository { + ETagRepository(super.db); + + @override + Future> getAllIds() => db.eTags.where().idProperty().findAll(); + + @override + Future get(int id) => db.eTags.get(id); + + @override + Future upsertAll(List etags) => txn(() => db.eTags.putAll(etags)); + + @override + Future deleteByIds(List ids) => + txn(() => db.eTags.deleteAllById(ids)); + + @override + Future getById(String id) => db.eTags.getById(id); +} diff --git a/mobile/lib/repositories/exif_info.repository.dart b/mobile/lib/repositories/exif_info.repository.dart index a165e98bdbfe3..3ddb50104bea2 100644 --- a/mobile/lib/repositories/exif_info.repository.dart +++ b/mobile/lib/repositories/exif_info.repository.dart @@ -2,27 +2,30 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:isar/isar.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; final exifInfoRepositoryProvider = Provider((ref) => ExifInfoRepository(ref.watch(dbProvider))); -class ExifInfoRepository implements IExifInfoRepository { - final Isar _db; - - ExifInfoRepository( - this._db, - ); +class ExifInfoRepository extends DatabaseRepository + implements IExifInfoRepository { + ExifInfoRepository(super.db); @override - Future delete(int id) => _db.exifInfos.delete(id); + Future delete(int id) => txn(() => db.exifInfos.delete(id)); @override - Future get(int id) => _db.exifInfos.get(id); + Future get(int id) => db.exifInfos.get(id); @override Future update(ExifInfo exifInfo) async { - await _db.writeTxn(() => _db.exifInfos.put(exifInfo)); + await txn(() => db.exifInfos.put(exifInfo)); return exifInfo; } + + @override + Future> updateAll(List exifInfos) async { + await txn(() => db.exifInfos.putAll(exifInfos)); + return exifInfos; + } } diff --git a/mobile/lib/repositories/partner_api.repository.dart b/mobile/lib/repositories/partner_api.repository.dart index 3419a2bc77244..0b3d164ca3523 100644 --- a/mobile/lib/repositories/partner_api.repository.dart +++ b/mobile/lib/repositories/partner_api.repository.dart @@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final partnerApiRepositoryProvider = Provider( @@ -11,7 +11,7 @@ final partnerApiRepositoryProvider = Provider( ), ); -class PartnerApiRepository extends BaseApiRepository +class PartnerApiRepository extends ApiRepository implements IPartnerApiRepository { final PartnersApi _api; diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart index 8071c33dc2cea..d324a03edbef8 100644 --- a/mobile/lib/repositories/person_api.repository.dart +++ b/mobile/lib/repositories/person_api.repository.dart @@ -1,14 +1,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final personApiRepositoryProvider = Provider( (ref) => PersonApiRepository(ref.watch(apiServiceProvider).peopleApi), ); -class PersonApiRepository extends BaseApiRepository +class PersonApiRepository extends ApiRepository implements IPersonApiRepository { final PeopleApi _api; diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart index 796b1f421b863..fb4df84fe7c4a 100644 --- a/mobile/lib/repositories/user.repository.dart +++ b/mobile/lib/repositories/user.repository.dart @@ -3,37 +3,61 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; final userRepositoryProvider = Provider((ref) => UserRepository(ref.watch(dbProvider))); -class UserRepository implements IUserRepository { - final Isar _db; - - UserRepository( - this._db, - ); +class UserRepository extends DatabaseRepository implements IUserRepository { + UserRepository(super.db); @override Future> getByIds(List ids) async => - (await _db.users.getAllById(ids)).cast(); + (await db.users.getAllById(ids)).nonNulls.toList(); @override - Future get(String id) => _db.users.getById(id); + Future get(String id) => db.users.getById(id); @override - Future> getAll({bool self = true}) { - if (self) { - return _db.users.where().findAll(); - } + Future> getAll({bool self = true, UserSort? sortBy}) { + final baseQuery = db.users.where(); final int userId = Store.get(StoreKey.currentUser).isarId; - return _db.users.where().isarIdNotEqualTo(userId).findAll(); + final QueryBuilder afterWhere = + self ? baseQuery.noOp() : baseQuery.isarIdNotEqualTo(userId); + final QueryBuilder query; + switch (sortBy) { + case null: + query = afterWhere.noOp(); + case UserSort.id: + query = afterWhere.sortById(); + } + return query.findAll(); } @override Future update(User user) async { - await _db.writeTxn(() => _db.users.put(user)); + await txn(() => db.users.put(user)); return user; } + + @override + Future me() => Future.value(Store.get(StoreKey.currentUser)); + + @override + Future deleteById(List ids) => txn(() => db.users.deleteAll(ids)); + + @override + Future> upsertAll(List users) async { + await txn(() => db.users.putAll(users)); + return users; + } + + @override + Future> getAllAccessible() => db.users + .filter() + .isPartnerSharedWithEqualTo(true) + .or() + .isarIdEqualTo(Store.get(StoreKey.currentUser).isarId) + .findAll(); } diff --git a/mobile/lib/repositories/user_api.repository.dart b/mobile/lib/repositories/user_api.repository.dart index ffc50ae4c3e6c..9641c4e0e610e 100644 --- a/mobile/lib/repositories/user_api.repository.dart +++ b/mobile/lib/repositories/user_api.repository.dart @@ -5,7 +5,7 @@ import 'package:http/http.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/user_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:openapi/api.dart'; final userApiRepositoryProvider = Provider( @@ -14,8 +14,7 @@ final userApiRepositoryProvider = Provider( ), ); -class UserApiRepository extends BaseApiRepository - implements IUserApiRepository { +class UserApiRepository extends ApiRepository implements IUserApiRepository { final UsersApi _api; UserApiRepository(this._api); diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index dd021e698e094..091049edb59f1 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -243,14 +243,15 @@ class AlbumService { int albumId, { List add = const [], List remove = const [], - }) async { - final album = await _albumRepository.getById(albumId); - if (album == null) return; - await _albumRepository.addAssets(album, add); - await _albumRepository.removeAssets(album, remove); - await _albumRepository.recalculateMetadata(album); - await _albumRepository.update(album); - } + }) => + _albumRepository.transaction(() async { + final album = await _albumRepository.get(albumId); + if (album == null) return; + await _albumRepository.addAssets(album, add); + await _albumRepository.removeAssets(album, remove); + await _albumRepository.recalculateMetadata(album); + await _albumRepository.update(album); + }); Future addAdditionalUserToAlbum( List sharedUserIds, @@ -285,20 +286,20 @@ class AlbumService { Future deleteAlbum(Album album) async { try { - final user = Store.get(StoreKey.currentUser); - if (album.owner.value?.isarId == user.isarId) { + final userId = Store.get(StoreKey.currentUser).isarId; + if (album.owner.value?.isarId == userId) { await _albumApiRepository.delete(album.remoteId!); } if (album.shared) { final foreignAssets = - await _assetRepository.getByAlbum(album, notOwnedBy: user); + await _assetRepository.getByAlbum(album, notOwnedBy: [userId]); await _albumRepository.delete(album.id); final List albums = await _albumRepository.getAll(shared: true); final List existing = []; for (Album album in albums) { existing.addAll( - await _assetRepository.getByAlbum(album, notOwnedBy: user), + await _assetRepository.getByAlbum(album, notOwnedBy: [userId]), ); } final List idsToRemove = @@ -357,7 +358,7 @@ class AlbumService { album.sharedUsers.remove(user); await _albumRepository.removeUsers(album, [user]); - final a = await _albumRepository.getById(album.id); + final a = await _albumRepository.get(album.id); // trigger watcher await _albumRepository.update(a!); diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 262040026e6a2..b2cad4dc828eb 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -1,27 +1,30 @@ -// ignore_for_file: null_argument_to_non_null_type - import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/etag.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_api.interface.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; +import 'package:immich_mobile/repositories/etag.repository.dart'; import 'package:immich_mobile/repositories/exif_info.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:openapi/api.dart'; @@ -29,48 +32,54 @@ import 'package:openapi/api.dart'; final assetServiceProvider = Provider( (ref) => AssetService( ref.watch(assetApiRepositoryProvider), + ref.watch(assetRepositoryProvider), ref.watch(exifInfoRepositoryProvider), + ref.watch(userRepositoryProvider), + ref.watch(etagRepositoryProvider), + ref.watch(backupRepositoryProvider), ref.watch(apiServiceProvider), ref.watch(syncServiceProvider), ref.watch(userServiceProvider), ref.watch(backupServiceProvider), ref.watch(albumServiceProvider), - ref.watch(dbProvider), ), ); class AssetService { final IAssetApiRepository _assetApiRepository; + final IAssetRepository _assetRepository; final IExifInfoRepository _exifInfoRepository; + final IUserRepository _userRepository; + final IETagRepository _etagRepository; + final IBackupRepository _backupRepository; final ApiService _apiService; final SyncService _syncService; final UserService _userService; final BackupService _backupService; final AlbumService _albumService; final log = Logger('AssetService'); - final Isar _db; AssetService( this._assetApiRepository, + this._assetRepository, this._exifInfoRepository, + this._userRepository, + this._etagRepository, + this._backupRepository, this._apiService, this._syncService, this._userService, this._backupService, this._albumService, - this._db, ); /// Checks the server for updated assets and updates the local database if /// required. Returns `true` if there were any changes. Future refreshRemoteAssets() async { - final syncedUserIds = await _db.eTags.where().idProperty().findAll(); + final syncedUserIds = await _etagRepository.getAllIds(); final List syncedUsers = syncedUserIds.isEmpty ? [] - : await _db.users - .where() - .anyOf(syncedUserIds, (q, id) => q.idEqualTo(id)) - .findAll(); + : await _userRepository.getByIds(syncedUserIds); final Stopwatch sw = Stopwatch()..start(); final bool changes = await _syncService.syncRemoteAssetsToDb( users: syncedUsers, @@ -175,7 +184,7 @@ class AssetService { /// Loads the exif information from the database. If there is none, loads /// the exif info from the server (remote assets only) Future loadExif(Asset a) async { - a.exifInfo ??= await _db.exifInfos.get(a.id); + a.exifInfo ??= await _exifInfoRepository.get(a.id); // fileSize is always filled on the server but not set on client if (a.exifInfo?.fileSize == null) { if (a.isRemote) { @@ -185,7 +194,7 @@ class AssetService { a.exifInfo = newExif; if (newExif != a.exifInfo) { if (a.isInDb) { - _db.writeTxn(() => a.put(_db)); + _assetRepository.transaction(() => _assetRepository.update(a)); } else { debugPrint("[loadExif] parameter Asset is not from DB!"); } @@ -214,7 +223,7 @@ class AssetService { ); } - Future> changeFavoriteStatus( + Future> changeFavoriteStatus( List assets, bool isFavorite, ) async { @@ -230,11 +239,11 @@ class AssetService { return assets; } catch (error, stack) { log.severe("Error while changing favorite status", error, stack); - return Future.value(null); + return []; } } - Future> changeArchiveStatus( + Future> changeArchiveStatus( List assets, bool isArchived, ) async { @@ -250,11 +259,11 @@ class AssetService { return assets; } catch (error, stack) { log.severe("Error while changing archive status", error, stack); - return Future.value(null); + return []; } } - Future> changeDateTime( + Future?> changeDateTime( List assets, String updatedDt, ) async { @@ -278,7 +287,7 @@ class AssetService { } } - Future> changeLocation( + Future?> changeLocation( List assets, LatLng location, ) async { @@ -307,10 +316,10 @@ class AssetService { Future syncUploadedAssetToAlbums() async { try { - final [selectedAlbums, excludedAlbums] = await Future.wait([ - _backupService.selectedAlbumsQuery().findAll(), - _backupService.excludedAlbumsQuery().findAll(), - ]); + final selectedAlbums = + await _backupRepository.getAllBySelection(BackupSelection.select); + final excludedAlbums = + await _backupRepository.getAllBySelection(BackupSelection.exclude); final candidates = await _backupService.buildUploadCandidates( selectedAlbums, @@ -319,12 +328,11 @@ class AssetService { ); await refreshRemoteAssets(); - final remoteAssets = await _db.assets - .where() - .localIdIsNotNull() - .filter() - .remoteIdIsNotNull() - .findAll(); + final owner = await _userRepository.me(); + final remoteAssets = await _assetRepository.getAll( + ownerId: owner.isarId, + state: AssetState.merged, + ); /// Map Map> assetToAlbums = {}; diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 86dfd0c5998c5..3959e2a6edc2b 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -9,6 +9,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/main.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; @@ -18,6 +19,8 @@ import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/etag.repository.dart'; +import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart'; @@ -38,7 +41,6 @@ import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; -import 'package:isar/isar.dart'; import 'package:path_provider_ios/path_provider_ios.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; @@ -357,7 +359,7 @@ class BackgroundService { } Future _onAssetsChanged() async { - final Isar db = await loadDb(); + final db = await loadDb(); HttpOverrides.global = HttpSSLCertOverride(); ApiService apiService = ApiService(); @@ -366,7 +368,9 @@ class BackgroundService { AppSettingsService settingsService = AppSettingsService(); AlbumRepository albumRepository = AlbumRepository(db); AssetRepository assetRepository = AssetRepository(db); - BackupRepository backupAlbumRepository = BackupRepository(db); + BackupRepository backupRepository = BackupRepository(db); + ExifInfoRepository exifInfoRepository = ExifInfoRepository(db); + ETagRepository eTagRepository = ETagRepository(db); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); FileMediaRepository fileMediaRepository = FileMediaRepository(); AssetMediaRepository assetMediaRepository = AssetMediaRepository(); @@ -382,11 +386,15 @@ class BackgroundService { EntityService entityService = EntityService(assetRepository, userRepository); SyncService syncSerive = SyncService( - db, hashService, entityService, albumMediaRepository, albumApiRepository, + albumRepository, + assetRepository, + exifInfoRepository, + userRepository, + eTagRepository, ); UserService userService = UserService( partnerApiRepository, @@ -400,22 +408,24 @@ class BackgroundService { entityService, albumRepository, assetRepository, - backupAlbumRepository, + backupRepository, albumMediaRepository, albumApiRepository, ); BackupService backupService = BackupService( apiService, - db, settingService, albumService, albumMediaRepository, fileMediaRepository, + assetRepository, assetMediaRepository, ); - final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync(); - final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync(); + final selectedAlbums = + await backupRepository.getAllBySelection(BackupSelection.select); + final excludedAlbums = + await backupRepository.getAllBySelection(BackupSelection.exclude); if (selectedAlbums.isEmpty) { return true; } @@ -433,28 +443,28 @@ class BackgroundService { await Store.delete(StoreKey.backupFailedSince); final backupAlbums = [...selectedAlbums, ...excludedAlbums]; backupAlbums.sortBy((e) => e.id); - db.writeTxnSync(() { - final dbAlbums = db.backupAlbums.where().sortById().findAllSync(); - final List toDelete = []; - final List toUpsert = []; - // stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state - diffSortedListsSync( - dbAlbums, - backupAlbums, - compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), - both: (BackupAlbum a, BackupAlbum b) { - a.lastBackup = a.lastBackup.isAfter(b.lastBackup) - ? a.lastBackup - : b.lastBackup; - toUpsert.add(a); - return true; - }, - onlyFirst: (BackupAlbum a) => toUpsert.add(a), - onlySecond: (BackupAlbum b) => toDelete.add(b.isarId), - ); - db.backupAlbums.deleteAllSync(toDelete); - db.backupAlbums.putAllSync(toUpsert); - }); + + final dbAlbums = + await backupRepository.getAll(sort: BackupAlbumSort.id); + final List toDelete = []; + final List toUpsert = []; + // stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state + diffSortedListsSync( + dbAlbums, + backupAlbums, + compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), + both: (BackupAlbum a, BackupAlbum b) { + a.lastBackup = a.lastBackup.isAfter(b.lastBackup) + ? a.lastBackup + : b.lastBackup; + toUpsert.add(a); + return true; + }, + onlyFirst: (BackupAlbum a) => toUpsert.add(a), + onlySecond: (BackupAlbum b) => toDelete.add(b.isarId), + ); + await backupRepository.deleteAll(toDelete); + await backupRepository.updateAll(toUpsert); } else if (Store.tryGet(StoreKey.backupFailedSince) == null) { Store.put(StoreKey.backupFailedSince, DateTime.now()); return false; diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 683339f271ed2..a0b6bf16c2304 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -9,9 +9,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; @@ -20,14 +20,13 @@ import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:path/path.dart' as p; @@ -37,11 +36,11 @@ import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; final backupServiceProvider = Provider( (ref) => BackupService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), ref.watch(appSettingsServiceProvider), ref.watch(albumServiceProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(fileMediaRepositoryProvider), + ref.watch(assetRepositoryProvider), ref.watch(assetMediaRepositoryProvider), ), ); @@ -49,21 +48,21 @@ final backupServiceProvider = Provider( class BackupService { final httpClient = http.Client(); final ApiService _apiService; - final Isar _db; final Logger _log = Logger("BackupService"); final AppSettingsService _appSetting; final AlbumService _albumService; final IAlbumMediaRepository _albumMediaRepository; final IFileMediaRepository _fileMediaRepository; + final IAssetRepository _assetRepository; final IAssetMediaRepository _assetMediaRepository; BackupService( this._apiService, - this._db, this._appSetting, this._albumService, this._albumMediaRepository, this._fileMediaRepository, + this._assetRepository, this._assetMediaRepository, ); @@ -78,24 +77,17 @@ class BackupService { } } - Future _saveDuplicatedAssetIds(List deviceAssetIds) { - final duplicates = deviceAssetIds.map((id) => DuplicatedAsset(id)).toList(); - return _db.writeTxn(() => _db.duplicatedAssets.putAll(duplicates)); - } + Future _saveDuplicatedAssetIds(List deviceAssetIds) => + _assetRepository.transaction( + () => _assetRepository.upsertDuplicatedAssets(deviceAssetIds), + ); /// Get duplicated asset id from database Future> getDuplicatedAssetIds() async { - final duplicates = await _db.duplicatedAssets.where().findAll(); - return duplicates.map((e) => e.id).toSet(); + final duplicates = await _assetRepository.getAllDuplicatedAssetIds(); + return duplicates.toSet(); } - QueryBuilder - selectedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.select); - QueryBuilder - excludedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude); - /// Returns all assets newer than the last successful backup per album /// if `useTimeFilter` is set to true, all assets will be returned Future> buildUploadCandidates( diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index da9d8da1649e4..82cfb8347a975 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -34,19 +34,19 @@ class BackupVerificationService { final owner = Store.get(StoreKey.currentUser).isarId; final List onlyLocal = await _assetRepository.getAll( ownerId: owner, - remote: false, + state: AssetState.local, limit: limit, ); final List remoteMatches = await _assetRepository.getMatches( assets: onlyLocal, ownerId: owner, - remote: true, + state: AssetState.remote, limit: limit, ); final List localMatches = await _assetRepository.getMatches( assets: remoteMatches, ownerId: owner, - remote: false, + state: AssetState.local, limit: limit, ); diff --git a/mobile/lib/services/download.service.dart b/mobile/lib/services/download.service.dart new file mode 100644 index 0000000000000..996cbe61f192f --- /dev/null +++ b/mobile/lib/services/download.service.dart @@ -0,0 +1,193 @@ +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/download.interface.dart'; +import 'package:immich_mobile/interfaces/file_media.interface.dart'; +import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; +import 'package:immich_mobile/repositories/download.repository.dart'; +import 'package:immich_mobile/repositories/file_media.repository.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/download.dart'; + +final downloadServiceProvider = Provider( + (ref) => DownloadService( + ref.watch(fileMediaRepositoryProvider), + ref.watch(downloadRepositoryProvider), + ), +); + +class DownloadService { + final IDownloadRepository _downloadRepository; + final IFileMediaRepository _fileMediaRepository; + void Function(TaskStatusUpdate)? onImageDownloadStatus; + void Function(TaskStatusUpdate)? onVideoDownloadStatus; + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + void Function(TaskProgressUpdate)? onTaskProgress; + + DownloadService( + this._fileMediaRepository, + this._downloadRepository, + ) { + _downloadRepository.onImageDownloadStatus = _onImageDownloadCallback; + _downloadRepository.onVideoDownloadStatus = _onVideoDownloadCallback; + _downloadRepository.onLivePhotoDownloadStatus = + _onLivePhotoDownloadCallback; + _downloadRepository.onTaskProgress = _onTaskProgressCallback; + } + + void _onTaskProgressCallback(TaskProgressUpdate update) { + onTaskProgress?.call(update); + } + + void _onImageDownloadCallback(TaskStatusUpdate update) { + onImageDownloadStatus?.call(update); + } + + void _onVideoDownloadCallback(TaskStatusUpdate update) { + onVideoDownloadStatus?.call(update); + } + + void _onLivePhotoDownloadCallback(TaskStatusUpdate update) { + onLivePhotoDownloadStatus?.call(update); + } + + Future saveImage(Task task) async { + final filePath = await task.filePath(); + final title = task.filename; + final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; + final data = await File(filePath).readAsBytes(); + + final Asset? resultAsset = await _fileMediaRepository.saveImage( + data, + title: title, + relativePath: relativePath, + ); + + return resultAsset != null; + } + + Future saveVideo(Task task) async { + final filePath = await task.filePath(); + final title = task.filename; + final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; + final file = File(filePath); + + final Asset? resultAsset = await _fileMediaRepository.saveVideo( + file, + title: title, + relativePath: relativePath, + ); + + return resultAsset != null; + } + + Future saveLivePhotos( + Task task, + String livePhotosId, + ) async { + try { + final records = await _downloadRepository.getLiveVideoTasks(); + if (records.length < 2) { + return false; + } + + final imageRecord = records.firstWhere( + (record) { + final metadata = LivePhotosMetadata.fromJson(record.task.metaData); + return metadata.id == livePhotosId && + metadata.part == LivePhotosPart.image; + }, + ); + + final videoRecord = records.firstWhere((record) { + final metadata = LivePhotosMetadata.fromJson(record.task.metaData); + return metadata.id == livePhotosId && + metadata.part == LivePhotosPart.video; + }); + + final imageFilePath = await imageRecord.task.filePath(); + final videoFilePath = await videoRecord.task.filePath(); + + final resultAsset = await _fileMediaRepository.saveLivePhoto( + image: File(imageFilePath), + video: File(videoFilePath), + title: task.filename, + ); + + await _downloadRepository.deleteRecordsWithIds([ + imageRecord.task.taskId, + videoRecord.task.taskId, + ]); + + return resultAsset != null; + } catch (error) { + debugPrint("Error saving live photo: $error"); + return false; + } + } + + Future cancelDownload(String id) async { + return await FileDownloader().cancelTaskWithId(id); + } + + Future download(Asset asset) async { + if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) { + await _downloadRepository.download( + _buildDownloadTask( + asset.remoteId!, + asset.fileName, + group: downloadGroupLivePhoto, + metadata: LivePhotosMetadata( + part: LivePhotosPart.image, + id: asset.remoteId!, + ).toJson(), + ), + ); + + await _downloadRepository.download( + _buildDownloadTask( + asset.livePhotoVideoId!, + asset.fileName.toUpperCase().replaceAll(".HEIC", '.MOV'), + group: downloadGroupLivePhoto, + metadata: LivePhotosMetadata( + part: LivePhotosPart.video, + id: asset.remoteId!, + ).toJson(), + ), + ); + } else { + await _downloadRepository.download( + _buildDownloadTask( + asset.remoteId!, + asset.fileName, + group: asset.isImage ? downloadGroupImage : downloadGroupVideo, + ), + ); + } + } + + DownloadTask _buildDownloadTask( + String id, + String filename, { + String? group, + String? metadata, + }) { + final path = r'/assets/{id}/original'.replaceAll('{id}', id); + final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final headers = ApiService.getRequestHeaders(); + + return DownloadTask( + taskId: id, + url: serverEndpoint + path, + headers: headers, + filename: filename, + updates: Updates.statusAndProgress, + group: group ?? '', + metaData: metadata ?? '', + ); + } +} diff --git a/mobile/lib/services/hash.service.dart b/mobile/lib/services/hash.service.dart index 3827e421e6108..bb19340d2ff35 100644 --- a/mobile/lib/services/hash.service.dart +++ b/mobile/lib/services/hash.service.dart @@ -130,7 +130,9 @@ class HashService { final validHashes = anyNull ? toAdd.where((e) => e.hash.length == 20).toList(growable: false) : toAdd; - await _assetRepository.upsertDeviceAssets(validHashes); + + await _assetRepository + .transaction(() => _assetRepository.upsertDeviceAssets(validHashes)); _log.fine("Hashed ${validHashes.length}/${toHash.length} assets"); } diff --git a/mobile/lib/services/image_viewer.service.dart b/mobile/lib/services/image_viewer.service.dart deleted file mode 100644 index c94244175b529..0000000000000 --- a/mobile/lib/services/image_viewer.service.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'dart:io'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/response_extensions.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/interfaces/file_media.interface.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/file_media.repository.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:logging/logging.dart'; - -import 'package:path_provider/path_provider.dart'; - -final imageViewerServiceProvider = Provider( - (ref) => ImageViewerService( - ref.watch(apiServiceProvider), - ref.watch(fileMediaRepositoryProvider), - ), -); - -class ImageViewerService { - final ApiService _apiService; - final IFileMediaRepository _fileMediaRepository; - final Logger _log = Logger("ImageViewerService"); - - ImageViewerService(this._apiService, this._fileMediaRepository); - - Future downloadAsset(Asset asset) async { - File? imageFile; - File? videoFile; - try { - // Download LivePhotos image and motion part - if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) { - var imageResponse = - await _apiService.assetsApi.downloadAssetWithHttpInfo( - asset.remoteId!, - ); - - var motionResponse = - await _apiService.assetsApi.downloadAssetWithHttpInfo( - asset.livePhotoVideoId!, - ); - - if (imageResponse.statusCode != 200 || - motionResponse.statusCode != 200) { - final failedResponse = - imageResponse.statusCode != 200 ? imageResponse : motionResponse; - _log.severe( - "Motion asset download failed", - failedResponse.toLoggerString(), - ); - return false; - } - - Asset? resultAsset; - - final tempDir = await getTemporaryDirectory(); - videoFile = await File('${tempDir.path}/livephoto.mov').create(); - imageFile = await File('${tempDir.path}/livephoto.heic').create(); - videoFile.writeAsBytesSync(motionResponse.bodyBytes); - imageFile.writeAsBytesSync(imageResponse.bodyBytes); - - resultAsset = await _fileMediaRepository.saveLivePhoto( - image: imageFile, - video: videoFile, - title: asset.fileName, - ); - - if (resultAsset == null) { - _log.warning( - "Asset cannot be saved as a live photo. This is most likely a motion photo. Saving only the image file", - ); - resultAsset = await _fileMediaRepository - .saveImage(imageResponse.bodyBytes, title: asset.fileName); - } - - return resultAsset != null; - } else { - var res = await _apiService.assetsApi - .downloadAssetWithHttpInfo(asset.remoteId!); - - if (res.statusCode != 200) { - _log.severe("Asset download failed", res.toLoggerString()); - return false; - } - - final Asset? resultAsset; - final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; - - if (asset.isImage) { - resultAsset = await _fileMediaRepository.saveImage( - res.bodyBytes, - title: asset.fileName, - relativePath: relativePath, - ); - } else { - final tempDir = await getTemporaryDirectory(); - videoFile = await File('${tempDir.path}/${asset.fileName}').create(); - videoFile.writeAsBytesSync(res.bodyBytes); - resultAsset = await _fileMediaRepository.saveVideo( - videoFile, - title: asset.fileName, - relativePath: relativePath, - ); - } - return resultAsset != null; - } - } catch (error, stack) { - _log.severe("Error saving downloaded asset", error, stack); - return false; - } finally { - // Clear temp files - imageFile?.delete(); - videoFile?.delete(); - } - } -} diff --git a/mobile/lib/services/stack.service.dart b/mobile/lib/services/stack.service.dart index 8bff21fef61a6..1ca56ff2791cd 100644 --- a/mobile/lib/services/stack.service.dart +++ b/mobile/lib/services/stack.service.dart @@ -61,7 +61,8 @@ class StackService { removeAssets.add(asset); } - await _assetRepository.updateAll(removeAssets); + await _assetRepository + .transaction(() => _assetRepository.updateAll(removeAssets)); } catch (error) { debugPrint("Error while deleting stack: $error"); } diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index c3f927fc93937..e23c2d1b1b216 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -5,48 +5,66 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/etag.repository.dart'; +import 'package:immich_mobile/repositories/exif_info.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/utils/datetime_comparison.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; final syncServiceProvider = Provider( (ref) => SyncService( - ref.watch(dbProvider), ref.watch(hashServiceProvider), ref.watch(entityServiceProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(albumApiRepositoryProvider), + ref.watch(albumRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(exifInfoRepositoryProvider), + ref.watch(userRepositoryProvider), + ref.watch(etagRepositoryProvider), ), ); class SyncService { - final Isar _db; final HashService _hashService; final EntityService _entityService; final IAlbumMediaRepository _albumMediaRepository; final IAlbumApiRepository _albumApiRepository; + final IAlbumRepository _albumRepository; + final IAssetRepository _assetRepository; + final IExifInfoRepository _exifInfoRepository; + final IUserRepository _userRepository; + final IETagRepository _eTagRepository; final AsyncMutex _lock = AsyncMutex(); final Logger _log = Logger('SyncService'); SyncService( - this._db, this._hashService, this._entityService, this._albumMediaRepository, this._albumApiRepository, + this._albumRepository, + this._assetRepository, + this._exifInfoRepository, + this._userRepository, + this._eTagRepository, ); // public methods: @@ -119,7 +137,7 @@ class SyncService { /// Returns `true`if there were any changes Future _syncUsersFromServer(List users) async { users.sortBy((u) => u.id); - final dbUsers = await _db.users.where().sortById().findAll(); + final dbUsers = await _userRepository.getAll(sortBy: UserSort.id); assert(dbUsers.isSortedBy((u) => u.id), "dbUsers not sorted!"); final List toDelete = []; final List toUpsert = []; @@ -141,9 +159,9 @@ class SyncService { onlySecond: (User b) => toDelete.add(b.isarId), ); if (changes) { - await _db.writeTxn(() async { - await _db.users.deleteAll(toDelete); - await _db.users.putAll(toUpsert); + await _userRepository.transaction(() async { + await _userRepository.deleteById(toDelete); + await _userRepository.upsertAll(toUpsert); }); } return changes; @@ -152,15 +170,15 @@ class SyncService { /// Syncs a new asset to the db. Returns `true` if successful Future _syncNewAssetToDb(Asset a) async { final Asset? inDb = - await _db.assets.getByOwnerIdChecksum(a.ownerId, a.checksum); + await _assetRepository.getByOwnerIdChecksum(a.ownerId, a.checksum); if (inDb != null) { // unify local/remote assets by replacing the // local-only asset in the DB with a local&remote asset a = inDb.updatedCopy(a); } try { - await _db.writeTxn(() => a.put(_db)); - } on IsarError catch (e) { + await _assetRepository.update(a); + } catch (e) { _log.severe("Failed to put new asset into db", e); return false; } @@ -175,9 +193,9 @@ class SyncService { DateTime since, ) getChangedAssets, ) async { - final currentUser = Store.get(StoreKey.currentUser); + final currentUser = await _userRepository.me(); final DateTime? since = - _db.eTags.getSync(currentUser.isarId)?.time?.toUtc(); + (await _eTagRepository.get(currentUser.isarId))?.time?.toUtc(); if (since == null) return null; final DateTime now = DateTime.now(); final (toUpsert, toDelete) = await getChangedAssets(users, since); @@ -198,7 +216,7 @@ class SyncService { return true; } return false; - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to sync remote assets to db", e); } return null; @@ -206,23 +224,21 @@ class SyncService { /// Deletes remote-only assets, updates merged assets to be local-only Future handleRemoteAssetRemoval(List idsToDelete) { - return _db.writeTxn(() async { - final idsToRemove = await _db.assets - .remote(idsToDelete) - .filter() - .localIdIsNull() - .idProperty() - .findAll(); - await _db.assets.deleteAll(idsToRemove); - await _db.exifInfos.deleteAll(idsToRemove); - final onlyLocal = await _db.assets.remote(idsToDelete).findAll(); - if (onlyLocal.isNotEmpty) { - for (final Asset a in onlyLocal) { - a.remoteId = null; - a.isTrashed = false; - } - await _db.assets.putAll(onlyLocal); + return _assetRepository.transaction(() async { + await _assetRepository.deleteAllByRemoteId( + idsToDelete, + state: AssetState.remote, + ); + final merged = await _assetRepository.getAllByRemoteId( + idsToDelete, + state: AssetState.merged, + ); + if (merged.isEmpty) return; + for (final Asset asset in merged) { + asset.remoteId = null; + asset.isTrashed = false; } + await _assetRepository.updateAll(merged); }); } @@ -237,12 +253,7 @@ class SyncService { return false; } await _syncUsersFromServer(serverUsers); - final List users = await _db.users - .filter() - .isPartnerSharedWithEqualTo(true) - .or() - .isarIdEqualTo(Store.get(StoreKey.currentUser).isarId) - .findAll(); + final List users = await _userRepository.getAllAccessible(); bool changes = false; for (User u in users) { changes |= await _syncRemoteAssetsForUser(u, loadAssets); @@ -259,11 +270,10 @@ class SyncService { if (remote == null) { return false; } - final List inDb = await _db.assets - .where() - .ownerIdEqualToAnyChecksum(user.isarId) - .sortByChecksum() - .findAll(); + final List inDb = await _assetRepository.getAll( + ownerId: user.isarId, + sortBy: AssetSort.checksum, + ); assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!"); remote.sort(Asset.compareByChecksum); @@ -278,9 +288,9 @@ class SyncService { } final idsToDelete = toRemove.map((e) => e.id).toList(); try { - await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete)); + await _assetRepository.deleteById(idsToDelete); await upsertAssetsWithExif(toAdd + toUpdate); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to sync remote assets to db", e); } await _updateUserAssetsETag([user], now); @@ -289,12 +299,12 @@ class SyncService { Future _updateUserAssetsETag(List users, DateTime time) { final etags = users.map((u) => ETag(id: u.id, time: time)).toList(); - return _db.writeTxn(() => _db.eTags.putAll(etags)); + return _eTagRepository.upsertAll(etags); } Future _clearUserAssetsETag(List users) { final ids = users.map((u) => u.id).toList(); - return _db.writeTxn(() => _db.eTags.deleteAllById(ids)); + return _eTagRepository.deleteByIds(ids); } /// Syncs remote albums to the database @@ -305,15 +315,13 @@ class SyncService { ) async { remoteAlbums.sortBy((e) => e.remoteId!); - final baseQuery = _db.albums.where().remoteIdIsNotNull().filter(); - final QueryBuilder query; - if (isShared) { - query = baseQuery.sharedEqualTo(true); - } else { - final User me = Store.get(StoreKey.currentUser); - query = baseQuery.owner((q) => q.isarIdEqualTo(me.isarId)); - } - final List dbAlbums = await query.sortByRemoteId().findAll(); + final User me = await _userRepository.me(); + final List dbAlbums = await _albumRepository.getAll( + remote: true, + shared: isShared ? true : null, + ownerId: isShared ? null : me.isarId, + sortBy: AlbumSort.remoteId, + ); assert(dbAlbums.isSortedBy((e) => e.remoteId!), "dbAlbums not sorted!"); final List toDelete = []; @@ -333,10 +341,7 @@ class SyncService { if (isShared && toDelete.isNotEmpty) { final List idsToRemove = sharedAssetsToRemove(toDelete, existing); if (idsToRemove.isNotEmpty) { - await _db.writeTxn(() async { - await _db.assets.deleteAll(idsToRemove); - await _db.exifInfos.deleteAll(idsToRemove); - }); + await _assetRepository.deleteById(idsToRemove); } } else { assert(toDelete.isEmpty); @@ -360,8 +365,11 @@ class SyncService { // i.e. it will always be null. Save it here. final originalDto = dto; dto = await _albumApiRepository.get(dto.remoteId!); - final assetsInDb = - await album.assets.filter().sortByOwnerId().thenByChecksum().findAll(); + + final assetsInDb = await _assetRepository.getByAlbum( + album, + sortBy: AssetSort.ownerIdChecksum, + ); assert(assetsInDb.isSorted(Asset.compareByOwnerChecksum), "inDb unsorted!"); final List assetsOnRemote = dto.remoteAssets.toList(); assetsOnRemote.sort(Asset.compareByOwnerChecksum); @@ -391,7 +399,7 @@ class SyncService { final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd); await upsertAssetsWithExif(updated); final assetsToLink = existingInDb + updated; - final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast(); + final usersToLink = await _userRepository.getByIds(userIdsToAdd); album.name = dto.name; album.shared = dto.shared; @@ -402,32 +410,33 @@ class SyncService { album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp; album.shared = dto.shared; album.activityEnabled = dto.activityEnabled; - if (album.thumbnail.value?.remoteId != dto.remoteThumbnailAssetId) { - album.thumbnail.value = await _db.assets - .where() - .remoteIdEqualTo(dto.remoteThumbnailAssetId) - .findFirst(); + final remoteThumbnailAssetId = dto.remoteThumbnailAssetId; + if (remoteThumbnailAssetId != null && + album.thumbnail.value?.remoteId != remoteThumbnailAssetId) { + album.thumbnail.value = + await _assetRepository.getByRemoteId(remoteThumbnailAssetId); } // write & commit all changes to DB try { - await _db.writeTxn(() async { - await _db.assets.putAll(toUpdate); - await album.thumbnail.save(); - await album.sharedUsers - .update(link: usersToLink, unlink: usersToUnlink); - await album.assets.update(link: assetsToLink, unlink: toUnlink.cast()); - await _db.albums.put(album); + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(toUpdate); + await _albumRepository.addUsers(album, usersToLink); + await _albumRepository.removeUsers(album, usersToUnlink); + await _albumRepository.addAssets(album, assetsToLink); + await _albumRepository.removeAssets(album, toUnlink); + await _albumRepository.recalculateMetadata(album); + await _albumRepository.update(album); }); _log.info("Synced changes of remote album ${album.name} to DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to sync remote album to database", e); } if (album.shared || dto.shared) { - final userId = Store.get(StoreKey.currentUser).isarId; + final userId = (await _userRepository.me()).isarId; final foreign = - await album.assets.filter().not().ownerIdEqualTo(userId).findAll(); + await _assetRepository.getByAlbum(album, notOwnedBy: [userId]); existing.addAll(foreign); // delete assets in DB unless they belong to this user or part of some other shared album @@ -456,7 +465,7 @@ class SyncService { await upsertAssetsWithExif(updated); await _entityService.fillAlbumWithDatabaseEntities(album); - await _db.writeTxn(() => _db.albums.store(album)); + await _albumRepository.create(album); } else { _log.warning( "Failed to add album from server: assetCount ${album.remoteAssetCount} != " @@ -474,27 +483,18 @@ class SyncService { _log.info("Removing local album $album from DB"); // delete assets in DB unless they are remote or part of some other album deleteCandidates.addAll( - await album.assets.filter().remoteIdIsNull().findAll(), + await _assetRepository.getByAlbum(album, state: AssetState.local), ); } else if (album.shared) { - final User user = Store.get(StoreKey.currentUser); // delete assets in DB unless they belong to this user or are part of some other shared album or belong to a partner - final userIds = await _db.users - .filter() - .isPartnerSharedWithEqualTo(true) - .isarIdProperty() - .findAll(); - userIds.add(user.isarId); - final orphanedAssets = await album.assets - .filter() - .not() - .anyOf(userIds, (q, int id) => q.ownerIdEqualTo(id)) - .findAll(); + final userIds = + (await _userRepository.getAllAccessible()).map((user) => user.isarId); + final orphanedAssets = + await _assetRepository.getByAlbum(album, notOwnedBy: userIds); deleteCandidates.addAll(orphanedAssets); } try { - final bool ok = await _db.writeTxn(() => _db.albums.delete(album.id)); - assert(ok); + await _albumRepository.delete(album.id); _log.info("Removed local album $album from DB"); } catch (e) { _log.severe("Failed to remove local album $album from DB", e); @@ -509,7 +509,7 @@ class SyncService { ]) async { onDevice.sort((a, b) => a.id.compareTo(b.id)); final inDb = - await _db.albums.where().localIdIsNotNull().sortByLocalId().findAll(); + await _albumRepository.getAll(remote: false, sortBy: AlbumSort.localId); final List deleteCandidates = []; final List existing = []; assert(inDb.isSorted((a, b) => a.localId!.compareTo(b.localId!)), "sort!"); @@ -536,10 +536,9 @@ class SyncService { "${toDelete.length} assets to delete, ${toUpdate.length} to update", ); if (toDelete.isNotEmpty || toUpdate.isNotEmpty) { - await _db.writeTxn(() async { - await _db.assets.deleteAll(toDelete); - await _db.exifInfos.deleteAll(toDelete); - await _db.assets.putAll(toUpdate); + await _assetRepository.transaction(() async { + await _assetRepository.deleteById(toDelete); + await _assetRepository.updateAll(toUpdate); }); _log.info( "Removed ${toDelete.length} and updated ${toUpdate.length} local assets from DB", @@ -570,13 +569,13 @@ class SyncService { await _syncDeviceAlbumFast(deviceAlbum, dbAlbum)) { return true; } - // general case, e.g. some assets have been deleted or there are excluded albums on iOS - final inDb = await dbAlbum.assets - .filter() - .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId) - .sortByChecksum() - .findAll(); + final inDb = await _assetRepository.getByAlbum( + dbAlbum, + ownerId: (await _userRepository.me()).isarId, + sortBy: AssetSort.checksum, + ); + assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!"); final int assetCountOnDevice = await _albumMediaRepository.getAssetCount(deviceAlbum.localId!); @@ -597,15 +596,14 @@ class SyncService { "Only excluded assets in local album ${deviceAlbum.name} changed. Stopping sync.", ); if (assetCountOnDevice != - _db.eTags.getByIdSync(deviceAlbum.eTagKeyAssetCount)?.assetCount) { - await _db.writeTxn( - () => _db.eTags.put( - ETag( - id: deviceAlbum.eTagKeyAssetCount, - assetCount: assetCountOnDevice, - ), + (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount)) + ?.assetCount) { + await _eTagRepository.upsertAll([ + ETag( + id: deviceAlbum.eTagKeyAssetCount, + assetCount: assetCountOnDevice, ), - ); + ]); } return false; } @@ -625,23 +623,21 @@ class SyncService { dbAlbum.thumbnail.value = null; } try { - await _db.writeTxn(() async { - await _db.assets.putAll(updated); - await _db.assets.putAll(toUpdate); - await dbAlbum.assets - .update(link: existingInDb + updated, unlink: toDelete); - await _db.albums.put(dbAlbum); - dbAlbum.thumbnail.value ??= await dbAlbum.assets.filter().findFirst(); - await dbAlbum.thumbnail.save(); - await _db.eTags.put( + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(updated + toUpdate); + await _albumRepository.addAssets(dbAlbum, existingInDb + updated); + await _albumRepository.removeAssets(dbAlbum, toDelete); + await _albumRepository.recalculateMetadata(dbAlbum); + await _albumRepository.update(dbAlbum); + await _eTagRepository.upsertAll([ ETag( id: deviceAlbum.eTagKeyAssetCount, assetCount: assetCountOnDevice, ), - ); + ]); }); _log.info("Synced changes of local album ${deviceAlbum.name} to DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to update synced album ${deviceAlbum.name} in DB", e); } @@ -657,7 +653,8 @@ class SyncService { final int totalOnDevice = await _albumMediaRepository.getAssetCount(deviceAlbum.localId!); final int lastKnownTotal = - (await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount))?.assetCount ?? + (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount)) + ?.assetCount ?? 0; if (totalOnDevice <= lastKnownTotal) { return false; @@ -675,16 +672,17 @@ class SyncService { _removeDuplicates(newAssets); final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets); try { - await _db.writeTxn(() async { - await _db.assets.putAll(updated); - await dbAlbum.assets.update(link: existingInDb + updated); - await _db.albums.put(dbAlbum); - await _db.eTags.put( - ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice), + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(updated); + await _albumRepository.addAssets(dbAlbum, existingInDb + updated); + await _albumRepository.recalculateMetadata(dbAlbum); + await _albumRepository.update(dbAlbum); + await _eTagRepository.upsertAll( + [ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice)], ); }); _log.info("Fast synced local album ${deviceAlbum.name} to DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe( "Failed to fast sync local album ${deviceAlbum.name} to DB", e, @@ -719,9 +717,9 @@ class SyncService { final thumb = existingInDb.firstOrNull ?? updated.firstOrNull; album.thumbnail.value = thumb; try { - await _db.writeTxn(() => _db.albums.store(album)); + await _albumRepository.create(album); _log.info("Added a new local album to DB: ${album.name}"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to add new local album ${album.name} to DB", e); } } @@ -732,7 +730,7 @@ class SyncService { ) async { if (assets.isEmpty) return ([].cast(), [].cast()); - final List inDb = await _db.assets.getAllByOwnerIdChecksum( + final List inDb = await _assetRepository.getAllByOwnerIdChecksum( assets.map((a) => a.ownerId).toInt64List(), assets.map((a) => a.checksum).toList(growable: false), ); @@ -746,7 +744,7 @@ class SyncService { } if (b.canUpdate(assets[i])) { final updated = b.updatedCopy(assets[i]); - assert(updated.id != Isar.autoIncrement); + assert(updated.isInDb); toUpsert.add(updated); } else { existing.add(b); @@ -758,24 +756,22 @@ class SyncService { /// Inserts or updates the assets in the database with their ExifInfo (if any) Future upsertAssetsWithExif(List assets) async { - if (assets.isEmpty) { - return; - } - final exifInfos = assets.map((e) => e.exifInfo).whereNotNull().toList(); + if (assets.isEmpty) return; + final exifInfos = assets.map((e) => e.exifInfo).nonNulls.toList(); try { - await _db.writeTxn(() async { - await _db.assets.putAll(assets); + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(assets); for (final Asset added in assets) { added.exifInfo?.id = added.id; } - await _db.exifInfos.putAll(exifInfos); + await _exifInfoRepository.updateAll(exifInfos); }); _log.info("Upserted ${assets.length} assets into the DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to upsert ${assets.length} assets into the DB", e); // give details on the errors assets.sort(Asset.compareByOwnerChecksum); - final inDb = await _db.assets.getAllByOwnerIdChecksum( + final inDb = await _assetRepository.getAllByOwnerIdChecksum( assets.map((e) => e.ownerId).toInt64List(), assets.map((e) => e.checksum).toList(growable: false), ); @@ -783,7 +779,7 @@ class SyncService { final Asset a = assets[i]; final Asset? b = inDb[i]; if (b == null) { - if (a.id != Isar.autoIncrement) { + if (!a.isInDb) { _log.warning( "Trying to update an asset that does not exist in DB:\n$a", ); @@ -827,19 +823,19 @@ class SyncService { return deviceAlbum.name != dbAlbum.name || !deviceAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) || await _albumMediaRepository.getAssetCount(deviceAlbum.localId!) != - (await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount)) + (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount)) ?.assetCount; } Future _removeAllLocalAlbumsAndAssets() async { try { - final assets = await _db.assets.where().localIdIsNotNull().findAll(); + final assets = await _assetRepository.getAllLocal(); final (toDelete, toUpdate) = _handleAssetRemoval(assets, [], remote: false); - await _db.writeTxn(() async { - await _db.assets.deleteAll(toDelete); - await _db.assets.putAll(toUpdate); - await _db.albums.where().localIdIsNotNull().deleteAll(); + await _assetRepository.transaction(() async { + await _assetRepository.deleteById(toDelete); + await _assetRepository.updateAll(toUpdate); + await _albumRepository.deleteAllLocal(); }); return true; } catch (e) { diff --git a/mobile/lib/utils/download.dart b/mobile/lib/utils/download.dart new file mode 100644 index 0000000000000..c701f353a2e6e --- /dev/null +++ b/mobile/lib/utils/download.dart @@ -0,0 +1,3 @@ +const downloadGroupImage = 'group_image'; +const downloadGroupVideo = 'group_video'; +const downloadGroupLivePhoto = 'group_livephoto'; diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index 3263373554df2..14678903ba298 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -131,11 +131,7 @@ class MultiselectGrid extends HookConsumerWidget { processing.value = true; if (shareLocal) { // Share = Download + Send to OS specific share sheet - // Filter offline assets since we cannot fetch their original file - final liveAssets = selection.value.nonOfflineOnly( - errorCallback: errorBuilder('asset_action_share_err_offline'.tr()), - ); - handleShareAssets(ref, context, liveAssets); + handleShareAssets(ref, context, selection.value); } else { final ids = remoteSelection(errorMessage: "home_page_share_err_local".tr()) diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 8b5684d0fa241..c3f1390dba04a 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -9,7 +9,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/services/stack.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; @@ -172,7 +172,16 @@ class BottomGalleryBar extends ConsumerWidget { } shareAsset() { - ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context); + if (asset.isOffline) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'asset_action_share_err_offline'.tr(), + gravity: ToastGravity.BOTTOM, + ); + return; + } + ref.read(downloadStateProvider.notifier).shareAsset(asset, context); } void handleEdit() async { @@ -202,7 +211,17 @@ class BottomGalleryBar extends ConsumerWidget { if (asset.isLocal) { return; } - ref.read(imageViewerStateProvider.notifier).downloadAsset( + if (asset.isOffline) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'asset_action_share_err_offline'.tr(), + gravity: ToastGravity.BOTTOM, + ); + return; + } + + ref.read(downloadStateProvider.notifier).downloadAsset( asset, context, ); diff --git a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart index 6de8f5da33944..f400224e0a0be 100644 --- a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart @@ -5,7 +5,7 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart'; -import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/top_control_app_bar.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; @@ -94,7 +94,7 @@ class GalleryAppBar extends ConsumerWidget { } handleDownloadAsset() { - ref.read(imageViewerStateProvider.notifier).downloadAsset(asset, context); + ref.read(downloadStateProvider.notifier).downloadAsset(asset, context); } return IgnorePointer( diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 51383fe1950f0..01b717ef5b977 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -176,7 +176,7 @@ class LoginForm extends HookConsumerWidget { populateTestLoginInfo1() { usernameController.text = 'testuser@email.com'; passwordController.text = 'password'; - serverEndpointController.text = 'http://10.1.15.216:2283/api'; + serverEndpointController.text = 'http://192.168.1.16:2283/api'; } login() async { diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 3b981e0ccb5bb..985029f106d27 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -383,7 +383,7 @@ class SearchApi { /// Parameters: /// /// * [RandomSearchDto] randomSearchDto (required): - Future searchRandom(RandomSearchDto randomSearchDto,) async { + Future?> searchRandom(RandomSearchDto randomSearchDto,) async { final response = await searchRandomWithHttpInfo(randomSearchDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -392,8 +392,11 @@ class SearchApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SearchResponseDto',) as SearchResponseDto; - + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + } return null; } diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 419cb451e2a02..3fcab05bbb275 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -29,7 +29,6 @@ class RandomSearchDto { this.libraryId, this.make, this.model, - this.page, this.personIds = const [], this.size, this.state, @@ -145,15 +144,6 @@ class RandomSearchDto { String? model; - /// Minimum value: 1 - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - num? page; - List personIds; /// Minimum value: 1 @@ -276,7 +266,6 @@ class RandomSearchDto { other.libraryId == libraryId && other.make == make && other.model == model && - other.page == page && _deepEquality.equals(other.personIds, personIds) && other.size == size && other.state == state && @@ -312,7 +301,6 @@ class RandomSearchDto { (libraryId == null ? 0 : libraryId!.hashCode) + (make == null ? 0 : make!.hashCode) + (model == null ? 0 : model!.hashCode) + - (page == null ? 0 : page!.hashCode) + (personIds.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + @@ -330,7 +318,7 @@ class RandomSearchDto { (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map toJson() { final json = {}; @@ -413,11 +401,6 @@ class RandomSearchDto { json[r'model'] = this.model; } else { // json[r'model'] = null; - } - if (this.page != null) { - json[r'page'] = this.page; - } else { - // json[r'page'] = null; } json[r'personIds'] = this.personIds; if (this.size != null) { @@ -514,7 +497,6 @@ class RandomSearchDto { libraryId: mapValueOfType(json, r'libraryId'), make: mapValueOfType(json, r'make'), model: mapValueOfType(json, r'model'), - page: num.parse('${json[r'page']}'), personIds: json[r'personIds'] is Iterable ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index aaea00d699bbe..9dadbd1028a64 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -78,6 +78,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.0.0" + background_downloader: + dependency: "direct main" + description: + name: background_downloader + sha256: "6a945db1a1c7727a4bc9c1d7c882cfb1a819f873b77e01d5e5dd6a3fb231cb28" + url: "https://pub.dev" + source: hosted + version: "8.5.5" boolean_selector: dependency: transitive description: @@ -744,10 +752,10 @@ packages: dependency: "direct main" description: name: http - sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 url: "https://pub.dev" source: hosted - version: "0.13.6" + version: "1.2.2" http_multi_server: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index dc1eb11ca7f24..092b0bb75cf1d 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -32,7 +32,7 @@ dependencies: flutter_svg: ^2.0.9 package_info_plus: ^8.0.1 url_launcher: ^6.2.4 - http: ^0.13.6 + http: ^1.1.0 cancellation_token_http: ^2.0.0 easy_localization: ^3.0.3 share_plus: ^10.0.0 @@ -56,6 +56,7 @@ dependencies: thumbhash: 0.1.0+1 async: ^2.11.0 dynamic_color: ^1.7.0 #package to apply system theme + background_downloader: ^8.5.5 #image editing packages crop_image: ^1.0.13 diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index 8520d89b435e5..c85487c7d04da 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -1,17 +1,21 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; -import 'package:isar/isar.dart'; +import 'package:mocktail/mocktail.dart'; import '../../repository.mocks.dart'; import '../../service.mocks.dart'; import '../../test_utils.dart'; void main() { + int assetIdCounter = 0; Asset makeAsset({ required String checksum, String? localId, @@ -20,6 +24,7 @@ void main() { }) { final DateTime date = DateTime(2000); return Asset( + id: assetIdCounter++, checksum: checksum, localId: localId, remoteId: remoteId, @@ -37,9 +42,13 @@ void main() { } group('Test SyncService grouped', () { - late final Isar db; final MockHashService hs = MockHashService(); final MockEntityService entityService = MockEntityService(); + final MockAlbumRepository albumRepository = MockAlbumRepository(); + final MockAssetRepository assetRepository = MockAssetRepository(); + final MockExifInfoRepository exifInfoRepository = MockExifInfoRepository(); + final MockUserRepository userRepository = MockUserRepository(); + final MockETagRepository eTagRepository = MockETagRepository(); final MockAlbumMediaRepository albumMediaRepository = MockAlbumMediaRepository(); final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository(); @@ -53,7 +62,7 @@ void main() { late SyncService s; setUpAll(() async { WidgetsFlutterBinding.ensureInitialized(); - db = await TestUtils.initIsar(); + final db = await TestUtils.initIsar(); ImmichLogger(); db.writeTxnSync(() => db.clearSync()); Store.init(db); @@ -67,16 +76,43 @@ void main() { makeAsset(checksum: "e", localId: "3"), ]; setUp(() { - db.writeTxnSync(() { - db.assets.clearSync(); - db.assets.putAllSync(initialAssets); - }); s = SyncService( - db, hs, entityService, albumMediaRepository, albumApiRepository, + albumRepository, + assetRepository, + exifInfoRepository, + userRepository, + eTagRepository, + ); + when(() => eTagRepository.get(owner.isarId)) + .thenAnswer((_) async => ETag(id: owner.id, time: DateTime.now())); + when(() => eTagRepository.deleteByIds(["1"])).thenAnswer((_) async {}); + when(() => eTagRepository.upsertAll(any())).thenAnswer((_) async {}); + when(() => userRepository.me()).thenAnswer((_) async => owner); + when(() => userRepository.getAll(sortBy: UserSort.id)) + .thenAnswer((_) async => [owner]); + when(() => userRepository.getAllAccessible()) + .thenAnswer((_) async => [owner]); + when( + () => assetRepository.getAll( + ownerId: owner.isarId, + sortBy: AssetSort.checksum, + ), + ).thenAnswer((_) async => initialAssets); + when(() => assetRepository.getAllByOwnerIdChecksum(any(), any())) + .thenAnswer((_) async => [initialAssets[3], null, null]); + when(() => assetRepository.updateAll(any())).thenAnswer((_) async => []); + when(() => assetRepository.deleteById(any())).thenAnswer((_) async {}); + when(() => exifInfoRepository.updateAll(any())) + .thenAnswer((_) async => []); + when(() => assetRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); + when(() => assetRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), ); }); test('test inserting existing assets', () async { @@ -85,7 +121,6 @@ void main() { makeAsset(checksum: "b", remoteId: "2-1"), makeAsset(checksum: "c", remoteId: "1-1"), ]; - expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -93,7 +128,7 @@ void main() { refreshUsers: () => [owner], ); expect(c1, isFalse); - expect(db.assets.countSync(), 5); + verifyNever(() => assetRepository.updateAll(any())); }); test('test inserting new assets', () async { @@ -105,7 +140,6 @@ void main() { makeAsset(checksum: "f", remoteId: "1-4"), makeAsset(checksum: "g", remoteId: "3-1"), ]; - expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -113,7 +147,11 @@ void main() { refreshUsers: () => [owner], ); expect(c1, isTrue); - expect(db.assets.countSync(), 7); + final updatedAsset = initialAssets[3].updatedCopy(remoteAssets[3]); + verify( + () => assetRepository + .updateAll([remoteAssets[4], remoteAssets[5], updatedAsset]), + ); }); test('test syncing duplicate assets', () async { @@ -125,7 +163,6 @@ void main() { makeAsset(checksum: "i", remoteId: "2-1c"), makeAsset(checksum: "j", remoteId: "2-1d"), ]; - expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -133,7 +170,12 @@ void main() { refreshUsers: () => [owner], ); expect(c1, isTrue); - expect(db.assets.countSync(), 8); + when( + () => assetRepository.getAll( + ownerId: owner.isarId, + sortBy: AssetSort.checksum, + ), + ).thenAnswer((_) async => remoteAssets); final bool c2 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -141,7 +183,13 @@ void main() { refreshUsers: () => [owner], ); expect(c2, isFalse); - expect(db.assets.countSync(), 8); + final currentState = [...remoteAssets]; + when( + () => assetRepository.getAll( + ownerId: owner.isarId, + sortBy: AssetSort.checksum, + ), + ).thenAnswer((_) async => currentState); remoteAssets.removeAt(4); final bool c3 = await s.syncRemoteAssetsToDb( users: [owner], @@ -150,7 +198,6 @@ void main() { refreshUsers: () => [owner], ); expect(c3, isTrue); - expect(db.assets.countSync(), 7); remoteAssets.add(makeAsset(checksum: "k", remoteId: "2-1e")); remoteAssets.add(makeAsset(checksum: "l", remoteId: "2-2")); final bool c4 = await s.syncRemoteAssetsToDb( @@ -160,10 +207,21 @@ void main() { refreshUsers: () => [owner], ); expect(c4, isTrue); - expect(db.assets.countSync(), 9); }); test('test efficient sync', () async { + when( + () => assetRepository.deleteAllByRemoteId( + [initialAssets[1].remoteId!, initialAssets[2].remoteId!], + state: AssetState.remote, + ), + ).thenAnswer((_) async {}); + when( + () => assetRepository + .getAllByRemoteId(["2-1", "1-1"], state: AssetState.merged), + ).thenAnswer((_) async => [initialAssets[2]]); + when(() => assetRepository.getAllByOwnerIdChecksum(any(), any())) + .thenAnswer((_) async => [initialAssets[0], null, null]); //afg final List toUpsert = [ makeAsset(checksum: "a", remoteId: "0-1"), // changed makeAsset(checksum: "f", remoteId: "0-2"), // new @@ -171,6 +229,8 @@ void main() { ]; toUpsert[0].isFavorite = true; final List toDelete = ["2-1", "1-1"]; + final expected = [...toUpsert]; + expected[0].id = initialAssets[0].id; final bool c = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: (user, since) async => (toUpsert, toDelete), @@ -178,7 +238,7 @@ void main() { refreshUsers: () => throw Exception(), ); expect(c, isTrue); - expect(db.assets.countSync(), 6); + verify(() => assetRepository.updateAll(expected)); }); }); } diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index 6e220e85a2dc2..c76a003eec2a0 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -4,6 +4,8 @@ import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:mocktail/mocktail.dart'; @@ -16,6 +18,10 @@ class MockUserRepository extends Mock implements IUserRepository {} class MockBackupRepository extends Mock implements IBackupRepository {} +class MockExifInfoRepository extends Mock implements IExifInfoRepository {} + +class MockETagRepository extends Mock implements IETagRepository {} + class MockAlbumMediaRepository extends Mock implements IAlbumMediaRepository {} class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {} diff --git a/mobile/test/services/album.service_test.dart b/mobile/test/services/album.service_test.dart index b2c2ec4427ab4..fb46dceed592f 100644 --- a/mobile/test/services/album.service_test.dart +++ b/mobile/test/services/album.service_test.dart @@ -29,6 +29,13 @@ void main() { albumMediaRepository = MockAlbumMediaRepository(); albumApiRepository = MockAlbumApiRepository(); + when(() => albumRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); + when(() => assetRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); + sut = AlbumService( userService, syncService, @@ -144,7 +151,7 @@ void main() { ), ); when( - () => albumRepository.getById(AlbumStub.oneAsset.id), + () => albumRepository.get(AlbumStub.oneAsset.id), ).thenAnswer((_) async => AlbumStub.oneAsset); when( () => albumRepository.addAssets(AlbumStub.oneAsset, [AssetStub.image2]), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1077762ac3a56..970230f4e3d3e 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4615,7 +4615,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SearchResponseDto" + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" } } }, @@ -10463,10 +10466,6 @@ "nullable": true, "type": "string" }, - "page": { - "minimum": 1, - "type": "number" - }, "personIds": { "items": { "format": "uuid", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index e88f431e8c787..aa3501079bf86 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -852,7 +852,6 @@ export type RandomSearchDto = { libraryId?: string | null; make?: string; model?: string | null; - page?: number; personIds?: string[]; size?: number; state?: string | null; @@ -2523,7 +2522,7 @@ export function searchRandom({ randomSearchDto }: { }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: SearchResponseDto; + data: AssetResponseDto[]; }>("/search/random", oazapfts.json({ ...opts, method: "POST", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 9446010127450..55b9babcb476b 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -2,7 +2,6 @@ import { BullModule } from '@nestjs/bullmq'; import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@nestjs/core'; -import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; import _ from 'lodash'; @@ -42,7 +41,6 @@ const imports = [ BullModule.registerQueue(...bullQueues), ClsModule.forRoot(clsConfig), ConfigModule.forRoot(immichAppConfig), - EventEmitterModule.forRoot(), OpenTelemetryModule.forRoot(otelConfig), TypeOrmModule.forRootAsync({ inject: [ModuleRef], @@ -114,16 +112,3 @@ export class MicroservicesModule implements OnModuleInit, OnModuleDestroy { providers: [...common, ...commands, SchedulerRegistry], }) export class ImmichAdminModule {} - -@Module({ - imports: [ - ConfigModule.forRoot(immichAppConfig), - EventEmitterModule.forRoot(), - TypeOrmModule.forRoot(databaseConfig), - TypeOrmModule.forFeature(entities), - OpenTelemetryModule.forRoot(otelConfig), - ], - controllers: [...controllers], - providers: [...common, ...middleware, SchedulerRegistry], -}) -export class AppTestModule {} diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts index 6bf85d1553c54..92c3cc11032ce 100644 --- a/server/src/bin/sync-sql.ts +++ b/server/src/bin/sync-sql.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node import { INestApplication } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { EventEmitterModule } from '@nestjs/event-emitter'; import { SchedulerRegistry } from '@nestjs/schedule'; import { Test } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -85,7 +84,6 @@ class SqlGenerator { logger: this.sqlLogger, }), TypeOrmModule.forFeature(entities), - EventEmitterModule.forRoot(), OpenTelemetryModule.forRoot(otelConfig), ], providers: [...repositories, AuthService, SchedulerRegistry], diff --git a/server/src/config.ts b/server/src/config.ts index 3317351f9ff3a..53374d581f6ba 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -20,7 +20,7 @@ import { VideoContainer, } from 'src/enum'; import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; -import { ImageOutputConfig } from 'src/interfaces/media.interface'; +import { ImageOptions } from 'src/interfaces/media.interface'; export interface SystemConfig { ffmpeg: { @@ -110,8 +110,8 @@ export interface SystemConfig { template: string; }; image: { - thumbnail: ImageOutputConfig; - preview: ImageOutputConfig; + thumbnail: ImageOptions; + preview: ImageOptions; colorspace: Colorspace; extractEmbedded: boolean; }; diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index 5b6deb2981bc5..9fdb2746fc1d3 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -32,7 +32,7 @@ export class SearchController { @Post('random') @HttpCode(HttpStatus.OK) @Authenticated() - searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise { + searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise { return this.service.searchRandom(auth, dto); } diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts index 8ed53344cc02f..816ab004460a9 100644 --- a/server/src/cores/system-config.core.ts +++ b/server/src/cores/system-config.core.ts @@ -4,7 +4,6 @@ import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import { load as loadYaml } from 'js-yaml'; import * as _ from 'lodash'; -import { Subject } from 'rxjs'; import { SystemConfig, defaults } from 'src/config'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { SystemMetadataKey } from 'src/enum'; @@ -24,8 +23,6 @@ export class SystemConfigCore { private config: SystemConfig | null = null; private lastUpdated: number | null = null; - config$ = new Subject(); - private constructor( private repository: ISystemMetadataRepository, private logger: ILoggerRepository, @@ -42,6 +39,11 @@ export class SystemConfigCore { instance = null; } + invalidateCache() { + this.config = null; + this.lastUpdated = null; + } + async getConfig({ withCache }: { withCache: boolean }): Promise { if (!withCache || !this.config) { const lastUpdated = this.lastUpdated; @@ -74,14 +76,7 @@ export class SystemConfigCore { await this.repository.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); - const config = await this.getConfig({ withCache: false }); - this.config$.next(config); - return config; - } - - async refreshConfig() { - const newConfig = await this.getConfig({ withCache: false }); - this.config$.next(newConfig); + return this.getConfig({ withCache: false }); } isUsingConfigFile() { diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 9b6910391af9b..278236823924f 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -1,11 +1,9 @@ import { SetMetadata, applyDecorators } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; -import { OnEventOptions } from '@nestjs/event-emitter/dist/interfaces'; import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; import _ from 'lodash'; import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; import { MetadataKey } from 'src/enum'; -import { EmitEvent, ServerEvent } from 'src/interfaces/event.interface'; +import { EmitEvent } from 'src/interfaces/event.interface'; import { setUnion } from 'src/utils/set'; // PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the @@ -133,15 +131,14 @@ export interface GenerateSqlQueries { /** Decorator to enable versioning/tracking of generated Sql */ export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options); -export const OnServerEvent = (event: ServerEvent, options?: OnEventOptions) => - OnEvent(event, { suppressErrors: false, ...options }); - -export type EmitConfig = { - event: EmitEvent; +export type EventConfig = { + name: EmitEvent; + /** handle socket.io server events as well */ + server?: boolean; /** lower value has higher priority, defaults to 0 */ priority?: number; }; -export const OnEmit = (config: EmitConfig) => SetMetadata(MetadataKey.ON_EMIT_CONFIG, config); +export const OnEvent = (config: EventConfig) => SetMetadata(MetadataKey.EVENT_CONFIG, config); type LifecycleRelease = 'NEXT_RELEASE' | string; type LifecycleMetadata = { diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index ddc6c192c5faa..5c5dce1a1190a 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -99,12 +99,6 @@ class BaseSearchDto { @Optional({ nullable: true, emptyToNull: true }) lensModel?: string | null; - @IsInt() - @Min(1) - @Type(() => Number) - @Optional() - page?: number; - @IsInt() @Min(1) @Max(1000) @@ -170,12 +164,24 @@ export class MetadataSearchDto extends RandomSearchDto { @Optional() @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) order?: AssetOrder; + + @IsInt() + @Min(1) + @Type(() => Number) + @Optional() + page?: number; } export class SmartSearchDto extends BaseSearchDto { @IsString() @IsNotEmpty() query!: string; + + @IsInt() + @Min(1) + @Type(() => Number) + @Optional() + page?: number; } export class SearchPlacesDto { diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index c12a54cd613e6..039dbd20ff36a 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -492,7 +492,7 @@ class SystemConfigGeneratedImageDto { size!: number; } -class SystemConfigImageDto { +export class SystemConfigImageDto { @Type(() => SystemConfigGeneratedImageDto) @ValidateNested() @IsObject() diff --git a/server/src/enum.ts b/server/src/enum.ts index e0c1e27859de9..757291b118be6 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -310,7 +310,7 @@ export enum MetadataKey { ADMIN_ROUTE = 'admin_route', SHARED_ROUTE = 'shared_route', API_KEY_SECURITY = 'api_key', - ON_EMIT_CONFIG = 'on_emit_config', + EVENT_CONFIG = 'event_config', } export enum RouteKey { diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index c6808e3aa87a6..750a85209474c 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -141,6 +141,12 @@ export interface AssetUpdateDuplicateOptions { duplicateIds: string[]; } +export interface UpsertFileOptions { + assetId: string; + type: AssetFileType; + path: string; +} + export type AssetPathEntity = Pick; export const IAssetRepository = 'IAssetRepository'; @@ -194,5 +200,6 @@ export interface IAssetRepository { getDuplicates(options: AssetBuilderOptions): Promise; getAllForUserFullSync(options: AssetFullSyncOptions): Promise; getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise; - upsertFile(options: { assetId: string; type: AssetFileType; path: string }): Promise; + upsertFile(file: UpsertFileOptions): Promise; + upsertFiles(files: UpsertFileOptions[]): Promise; } diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index bc5ce90f40b9b..02027d87e626a 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -4,13 +4,19 @@ import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.d export const IEventRepository = 'IEventRepository'; -type EmitEventMap = { +type EventMap = { // app events 'app.bootstrap': ['api' | 'microservices']; 'app.shutdown': []; // config events - 'config.update': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; + 'config.update': [ + { + newConfig: SystemConfig; + /** When the server starts, `oldConfig` is `undefined` */ + oldConfig?: SystemConfig; + }, + ]; 'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; // album events @@ -43,12 +49,18 @@ type EmitEventMap = { // user events 'user.signup': [{ notify: boolean; id: string; tempPassword?: string }]; + + // websocket events + 'websocket.connect': [{ userId: string }]; }; -export type EmitEvent = keyof EmitEventMap; +export const serverEvents = ['config.update'] as const; +export type ServerEvents = (typeof serverEvents)[number]; + +export type EmitEvent = keyof EventMap; export type EmitHandler = (...args: ArgsOf) => Promise | void; -export type ArgOf = EmitEventMap[T][0]; -export type ArgsOf = EmitEventMap[T]; +export type ArgOf = EventMap[T][0]; +export type ArgsOf = EventMap[T]; export enum ClientEvent { UPLOAD_SUCCESS = 'on_upload_success', @@ -82,19 +94,15 @@ export interface ClientEventMap { [ClientEvent.SESSION_DELETE]: string; } -export enum ServerEvent { - CONFIG_UPDATE = 'config.update', - WEBSOCKET_CONNECT = 'websocket.connect', -} - -export interface ServerEventMap { - [ServerEvent.CONFIG_UPDATE]: null; - [ServerEvent.WEBSOCKET_CONNECT]: { userId: string }; -} +export type EventItem = { + event: T; + handler: EmitHandler; + server: boolean; +}; export interface IEventRepository { - on(event: T, handler: EmitHandler): void; - emit(event: T, ...args: ArgsOf): Promise; + on(item: EventItem): void; + emit(event: T, ...args: ArgsOf): Promise; /** * Send to connected clients for a specific user @@ -105,7 +113,7 @@ export interface IEventRepository { */ clientBroadcast(event: E, data: ClientEventMap[E]): void; /** - * Notify listeners in this and connected processes. Subscribe to an event with `@OnServerEvent` + * Send to all connected servers */ - serverSend(event: E, data: ServerEventMap[E]): boolean; + serverSend(event: T, ...args: ArgsOf): void; } diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index af2726b858aee..aa3090675e3fe 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -37,9 +37,7 @@ export enum JobName { // thumbnails QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails', - GENERATE_PREVIEW = 'generate-preview', - GENERATE_THUMBNAIL = 'generate-thumbnail', - GENERATE_THUMBHASH = 'generate-thumbhash', + GENERATE_THUMBNAILS = 'generate-thumbnails', GENERATE_PERSON_THUMBNAIL = 'generate-person-thumbnail', // metadata @@ -212,9 +210,7 @@ export type JobItem = // Thumbnails | { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob } - | { name: JobName.GENERATE_PREVIEW; data: IEntityJob } - | { name: JobName.GENERATE_THUMBNAIL; data: IEntityJob } - | { name: JobName.GENERATE_THUMBHASH; data: IEntityJob } + | { name: JobName.GENERATE_THUMBNAILS; data: IEntityJob } // User | { name: JobName.USER_DELETE_CHECK; data?: IBaseJob } diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index 64ba6236e80f0..2bc8ccde36d8b 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -10,16 +10,44 @@ export interface CropOptions { height: number; } -export interface ImageOutputConfig { +export interface ImageOptions { format: ImageFormat; quality: number; size: number; } -export interface ThumbnailOptions extends ImageOutputConfig { +export interface RawImageInfo { + width: number; + height: number; + channels: 1 | 2 | 3 | 4; +} + +interface DecodeImageOptions { colorspace: string; crop?: CropOptions; processInvalidImages: boolean; + raw?: RawImageInfo; +} + +export interface DecodeToBufferOptions extends DecodeImageOptions { + size: number; +} + +export type GenerateThumbnailOptions = ImageOptions & DecodeImageOptions; + +export type GenerateThumbnailFromBufferOptions = GenerateThumbnailOptions & { raw: RawImageInfo }; + +export type GenerateThumbhashOptions = DecodeImageOptions; + +export type GenerateThumbhashFromBufferOptions = GenerateThumbhashOptions & { raw: RawImageInfo }; + +export interface GenerateThumbnailsOptions { + colorspace: string; + crop?: CropOptions; + preview?: ImageOptions; + processInvalidImages: boolean; + thumbhash?: boolean; + thumbnail?: ImageOptions; } export interface VideoStreamInfo { @@ -78,6 +106,11 @@ export interface BitrateDistribution { unit: string; } +export interface ImageBuffer { + data: Buffer; + info: RawImageInfo; +} + export interface VideoCodecSWConfig { getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeCommand; } @@ -93,8 +126,11 @@ export interface ProbeOptions { export interface IMediaRepository { // image extract(input: string, output: string): Promise; - generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise; - generateThumbhash(imagePath: string): Promise; + decodeImage(input: string, options: DecodeToBufferOptions): Promise; + generateThumbnail(input: string, options: GenerateThumbnailOptions, outputFile: string): Promise; + generateThumbnail(input: Buffer, options: GenerateThumbnailFromBufferOptions, outputFile: string): Promise; + generateThumbhash(input: string, options: GenerateThumbhashOptions): Promise; + generateThumbhash(input: Buffer, options: GenerateThumbhashFromBufferOptions): Promise; getImageDimensions(input: string): Promise; // video diff --git a/server/src/interfaces/person.interface.ts b/server/src/interfaces/person.interface.ts index 5708274a6e99f..65814e0046f46 100644 --- a/server/src/interfaces/person.interface.ts +++ b/server/src/interfaces/person.interface.ts @@ -1,6 +1,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { PersonEntity } from 'src/entities/person.entity'; +import { SourceType } from 'src/enum'; import { Paginated, PaginationOptions } from 'src/utils/pagination'; import { FindManyOptions, FindOptionsRelations, FindOptionsSelect } from 'typeorm'; @@ -40,10 +41,12 @@ export interface PeopleStatistics { hidden: number; } -export interface DeleteAllFacesOptions { - sourceType?: string; +export interface DeleteFacesOptions { + sourceType: SourceType; } +export type UnassignFacesOptions = DeleteFacesOptions; + export interface IPersonRepository { getAll(pagination: PaginationOptions, options?: FindManyOptions): Paginated; getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated; @@ -59,7 +62,7 @@ export interface IPersonRepository { createFaces(entities: Partial[]): Promise; delete(entities: PersonEntity[]): Promise; deleteAll(): Promise; - deleteAllFaces(options: DeleteAllFacesOptions): Promise; + deleteFaces(options: DeleteFacesOptions): Promise; replaceFaces(assetId: string, entities: Partial[], sourceType?: string): Promise; getAllFaces(pagination: PaginationOptions, options?: FindManyOptions): Paginated; getFaceById(id: string): Promise; @@ -75,6 +78,7 @@ export interface IPersonRepository { reassignFace(assetFaceId: string, newPersonId: string): Promise; getNumberOfPeople(userId: string): Promise; reassignFaces(data: UpdateFacesData): Promise; + unassignFaces(options: UnassignFacesOptions): Promise; update(person: Partial): Promise; updateAll(people: Partial[]): Promise; getLatestFaceDate(): Promise; diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index 0ba524c00a272..63d74a35fb626 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -116,7 +116,6 @@ export interface SearchPeopleOptions { export interface SearchOrderOptions { orderDirection?: 'ASC' | 'DESC'; - random?: boolean; } export interface SearchPaginationOptions { @@ -177,6 +176,7 @@ export interface ISearchRepository { searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated; searchDuplicates(options: AssetDuplicateSearch): Promise; searchFaces(search: FaceEmbeddingSearch): Promise; + searchRandom(size: number, options: AssetSearchOptions): Promise; upsert(assetId: string, embedding: number[]): Promise; searchPlaces(placeName: string): Promise; getAssetsByCity(userIds: string[]): Promise; diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 69309325848ed..eda91482bb1d0 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -1132,3 +1132,27 @@ RETURNING "id", "createdAt", "updatedAt" + +-- AssetRepository.upsertFiles +INSERT INTO + "asset_files" ( + "id", + "assetId", + "createdAt", + "updatedAt", + "type", + "path" + ) +VALUES + (DEFAULT, $1, DEFAULT, DEFAULT, $2, $3) +ON CONFLICT ("assetId", "type") DO +UPDATE +SET + "assetId" = EXCLUDED."assetId", + "type" = EXCLUDED."type", + "path" = EXCLUDED."path", + "updatedAt" = DEFAULT +RETURNING + "id", + "createdAt", + "updatedAt" diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 58b2999012cf7..cd9a84b016d0f 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -77,10 +77,11 @@ FROM "asset"."fileCreatedAt" >= $1 AND "exifInfo"."lensModel" = $2 AND 1 = 1 + AND "asset"."ownerId" IN ($3) AND 1 = 1 AND ( - "asset"."isFavorite" = $3 - AND "asset"."isArchived" = $4 + "asset"."isFavorite" = $4 + AND "asset"."isArchived" = $5 ) ) AND ("asset"."deletedAt" IS NULL) @@ -91,6 +92,190 @@ ORDER BY LIMIT 101 +-- SearchRepository.searchRandom +SELECT DISTINCT + "distinctAlias"."asset_id" AS "ids_asset_id", + "distinctAlias"."asset_id" +FROM + ( + SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", + "asset"."originalPath" AS "asset_originalPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", + "stack"."id" AS "stack_id", + "stack"."ownerId" AS "stack_ownerId", + "stack"."primaryAssetId" AS "stack_primaryAssetId", + "stackedAssets"."id" AS "stackedAssets_id", + "stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId", + "stackedAssets"."ownerId" AS "stackedAssets_ownerId", + "stackedAssets"."libraryId" AS "stackedAssets_libraryId", + "stackedAssets"."deviceId" AS "stackedAssets_deviceId", + "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", + "stackedAssets"."originalPath" AS "stackedAssets_originalPath", + "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", + "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", + "stackedAssets"."createdAt" AS "stackedAssets_createdAt", + "stackedAssets"."updatedAt" AS "stackedAssets_updatedAt", + "stackedAssets"."deletedAt" AS "stackedAssets_deletedAt", + "stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt", + "stackedAssets"."localDateTime" AS "stackedAssets_localDateTime", + "stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt", + "stackedAssets"."isFavorite" AS "stackedAssets_isFavorite", + "stackedAssets"."isArchived" AS "stackedAssets_isArchived", + "stackedAssets"."isExternal" AS "stackedAssets_isExternal", + "stackedAssets"."isOffline" AS "stackedAssets_isOffline", + "stackedAssets"."checksum" AS "stackedAssets_checksum", + "stackedAssets"."duration" AS "stackedAssets_duration", + "stackedAssets"."isVisible" AS "stackedAssets_isVisible", + "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", + "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", + "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" + FROM + "assets" "asset" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" + LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" + LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" + AND ("stackedAssets"."deletedAt" IS NULL) + WHERE + ( + "asset"."fileCreatedAt" >= $1 + AND "exifInfo"."lensModel" = $2 + AND 1 = 1 + AND "asset"."ownerId" IN ($3) + AND 1 = 1 + AND ( + "asset"."isFavorite" = $4 + AND "asset"."isArchived" = $5 + ) + AND "asset"."id" > $6 + ) + AND ("asset"."deletedAt" IS NULL) + ) "distinctAlias" +ORDER BY + "distinctAlias"."asset_id" ASC, + "asset_id" ASC +LIMIT + 100 +SELECT DISTINCT + "distinctAlias"."asset_id" AS "ids_asset_id", + "distinctAlias"."asset_id" +FROM + ( + SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", + "asset"."originalPath" AS "asset_originalPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", + "stack"."id" AS "stack_id", + "stack"."ownerId" AS "stack_ownerId", + "stack"."primaryAssetId" AS "stack_primaryAssetId", + "stackedAssets"."id" AS "stackedAssets_id", + "stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId", + "stackedAssets"."ownerId" AS "stackedAssets_ownerId", + "stackedAssets"."libraryId" AS "stackedAssets_libraryId", + "stackedAssets"."deviceId" AS "stackedAssets_deviceId", + "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", + "stackedAssets"."originalPath" AS "stackedAssets_originalPath", + "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", + "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", + "stackedAssets"."createdAt" AS "stackedAssets_createdAt", + "stackedAssets"."updatedAt" AS "stackedAssets_updatedAt", + "stackedAssets"."deletedAt" AS "stackedAssets_deletedAt", + "stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt", + "stackedAssets"."localDateTime" AS "stackedAssets_localDateTime", + "stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt", + "stackedAssets"."isFavorite" AS "stackedAssets_isFavorite", + "stackedAssets"."isArchived" AS "stackedAssets_isArchived", + "stackedAssets"."isExternal" AS "stackedAssets_isExternal", + "stackedAssets"."isOffline" AS "stackedAssets_isOffline", + "stackedAssets"."checksum" AS "stackedAssets_checksum", + "stackedAssets"."duration" AS "stackedAssets_duration", + "stackedAssets"."isVisible" AS "stackedAssets_isVisible", + "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", + "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", + "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" + FROM + "assets" "asset" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" + LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" + LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" + AND ("stackedAssets"."deletedAt" IS NULL) + WHERE + ( + "asset"."fileCreatedAt" >= $1 + AND "exifInfo"."lensModel" = $2 + AND 1 = 1 + AND "asset"."ownerId" IN ($3) + AND 1 = 1 + AND ( + "asset"."isFavorite" = $4 + AND "asset"."isArchived" = $5 + ) + AND "asset"."id" < $6 + ) + AND ("asset"."deletedAt" IS NULL) + ) "distinctAlias" +ORDER BY + "distinctAlias"."asset_id" ASC, + "asset_id" ASC +LIMIT + 100 + -- SearchRepository.searchSmart START TRANSACTION SET diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 0ec347ed77ab7..8bca755c32e26 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -801,7 +801,12 @@ export class AssetRepository implements IAssetRepository { } @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] }) - async upsertFile({ assetId, type, path }: { assetId: string; type: AssetFileType; path: string }): Promise { - await this.fileRepository.upsert({ assetId, type, path }, { conflictPaths: ['assetId', 'type'] }); + async upsertFile(file: { assetId: string; type: AssetFileType; path: string }): Promise { + await this.fileRepository.upsert(file, { conflictPaths: ['assetId', 'type'] }); + } + + @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] }) + async upsertFiles(files: { assetId: string; type: AssetFileType; path: string }[]): Promise { + await this.fileRepository.upsert(files, { conflictPaths: ['assetId', 'type'] }); } } diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 9aa12e15dd560..a8b2fa67c3395 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -1,6 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; -import { EventEmitter2 } from '@nestjs/event-emitter'; import { OnGatewayConnection, OnGatewayDisconnect, @@ -13,16 +12,17 @@ import { ArgsOf, ClientEventMap, EmitEvent, - EmitHandler, + EventItem, IEventRepository, - ServerEvent, - ServerEventMap, + serverEvents, + ServerEvents, } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthService } from 'src/services/auth.service'; import { Instrumentation } from 'src/utils/instrumentation'; +import { handlePromiseError } from 'src/utils/misc'; -type EmitHandlers = Partial<{ [T in EmitEvent]: EmitHandler[] }>; +type EmitHandlers = Partial<{ [T in EmitEvent]: Array> }>; @Instrumentation() @WebSocketGateway({ @@ -39,7 +39,6 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect constructor( private moduleRef: ModuleRef, - private eventEmitter: EventEmitter2, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(EventRepository.name); @@ -48,14 +47,10 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect afterInit(server: Server) { this.logger.log('Initialized websocket server'); - for (const event of Object.values(ServerEvent)) { - if (event === ServerEvent.WEBSOCKET_CONNECT) { - continue; - } - - server.on(event, (data: unknown) => { + for (const event of serverEvents) { + server.on(event, (...args: ArgsOf) => { this.logger.debug(`Server event: ${event} (receive)`); - this.eventEmitter.emit(event, data); + handlePromiseError(this.onEvent({ name: event, args, server: true }), this.logger); }); } } @@ -72,7 +67,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect if (auth.session) { await client.join(auth.session.id); } - this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id }); + await this.onEvent({ name: 'websocket.connect', args: [{ userId: auth.user.id }], server: false }); } catch (error: Error | any) { this.logger.error(`Websocket connection error: ${error}`, error?.stack); client.emit('error', 'unauthorized'); @@ -85,18 +80,29 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect await client.leave(client.nsp.name); } - on(event: T, handler: EmitHandler): void { + on(item: EventItem): void { + const event = item.event; + if (!this.emitHandlers[event]) { this.emitHandlers[event] = []; } - this.emitHandlers[event].push(handler); + this.emitHandlers[event].push(item); } async emit(event: T, ...args: ArgsOf): Promise { - const handlers = this.emitHandlers[event] || []; - for (const handler of handlers) { - await handler(...args); + return this.onEvent({ name: event, args, server: false }); + } + + private async onEvent(event: { name: T; args: ArgsOf; server: boolean }): Promise { + const handlers = this.emitHandlers[event.name] || []; + for (const { handler, server } of handlers) { + // exclude handlers that ignore server events + if (!server && event.server) { + continue; + } + + await handler(...event.args); } } @@ -108,9 +114,8 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect this.server?.emit(event, data); } - serverSend(event: E, data: ServerEventMap[E]) { + serverSend(event: T, ...args: ArgsOf): void { this.logger.debug(`Server event: ${event} (send)`); - this.server?.serverSideEmit(event, data); - return this.eventEmitter.emit(event, data); + this.server?.serverSideEmit(event, ...args); } } diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index cd4c7135be80f..3f154ee01615a 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -36,9 +36,7 @@ export const JOBS_TO_QUEUE: Record = { // thumbnails [JobName.QUEUE_GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION, - [JobName.GENERATE_PREVIEW]: QueueName.THUMBNAIL_GENERATION, - [JobName.GENERATE_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, - [JobName.GENERATE_THUMBHASH]: QueueName.THUMBNAIL_GENERATION, + [JobName.GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION, [JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, // tags diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index d001aa3158b0f..cca87f44f2b81 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -8,10 +8,12 @@ import sharp from 'sharp'; import { Colorspace, LogLevel } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { + DecodeToBufferOptions, + GenerateThumbhashOptions, + GenerateThumbnailOptions, IMediaRepository, ImageDimensions, ProbeOptions, - ThumbnailOptions, TranscodeCommand, VideoInfo, } from 'src/interfaces/media.interface'; @@ -57,19 +59,12 @@ export class MediaRepository implements IMediaRepository { return true; } - async generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise { - // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes - const pipeline = sharp(input, { failOn: options.processInvalidImages ? 'none' : 'error', limitInputPixels: false }) - .pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16') - .rotate(); + decodeImage(input: string, options: DecodeToBufferOptions) { + return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true }); + } - if (options.crop) { - pipeline.extract(options.crop); - } - - await pipeline - .resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true }) - .withIccProfile(options.colorspace) + async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise { + await this.getImageDecodingPipeline(input, options) .toFormat(options.format, { quality: options.quality, // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp @@ -78,6 +73,40 @@ export class MediaRepository implements IMediaRepository { .toFile(output); } + private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) { + let pipeline = sharp(input, { + // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes + failOn: options.processInvalidImages ? 'none' : 'error', + limitInputPixels: false, + raw: options.raw, + }); + + if (!options.raw) { + pipeline = pipeline + .pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16') + .withIccProfile(options.colorspace) + .rotate(); + } + + if (options.crop) { + pipeline = pipeline.extract(options.crop); + } + + return pipeline.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true }); + } + + async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise { + const [{ rgbaToThumbHash }, { data, info }] = await Promise.all([ + import('thumbhash'), + sharp(input, options) + .resize(100, 100, { fit: 'inside', withoutEnlargement: true }) + .raw() + .ensureAlpha() + .toBuffer({ resolveWithObject: true }), + ]); + return Buffer.from(rgbaToThumbHash(info.width, info.height, data)); + } + async probe(input: string, options?: ProbeOptions): Promise { const results = await probe(input, options?.countFrames ? ['-count_packets'] : []); // gets frame count quickly: https://stackoverflow.com/a/28376817 return { @@ -150,19 +179,6 @@ export class MediaRepository implements IMediaRepository { }); } - async generateThumbhash(imagePath: string): Promise { - const maxSize = 100; - - const { data, info } = await sharp(imagePath) - .resize(maxSize, maxSize, { fit: 'inside', withoutEnlargement: true }) - .raw() - .ensureAlpha() - .toBuffer({ resolveWithObject: true }); - - const thumbhash = await import('thumbhash'); - return Buffer.from(thumbhash.rgbaToThumbHash(info.width, info.height, data)); - } - async getImageDimensions(input: string): Promise { const { width = 0, height = 0 } = await sharp(input).metadata(); return { width, height }; diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 2607d2a9ec7c4..0350e8a953027 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -9,13 +9,14 @@ import { PersonEntity } from 'src/entities/person.entity'; import { PaginationMode, SourceType } from 'src/enum'; import { AssetFaceId, - DeleteAllFacesOptions, + DeleteFacesOptions, IPersonRepository, PeopleStatistics, PersonNameResponse, PersonNameSearchOptions, PersonSearchOptions, PersonStatistics, + UnassignFacesOptions, UpdateFacesData, } from 'src/interfaces/person.interface'; import { Instrumentation } from 'src/utils/instrumentation'; @@ -39,12 +40,23 @@ export class PersonRepository implements IPersonRepository { .createQueryBuilder() .update() .set({ personId: newPersonId }) - .where(_.omitBy({ personId: oldPersonId ?? undefined, id: faceIds ? In(faceIds) : undefined }, _.isUndefined)) + .where(_.omitBy({ personId: oldPersonId, id: faceIds ? In(faceIds) : undefined }, _.isUndefined)) .execute(); return result.affected ?? 0; } + async unassignFaces({ sourceType }: UnassignFacesOptions): Promise { + await this.assetFaceRepository + .createQueryBuilder() + .update() + .set({ personId: null }) + .where({ sourceType }) + .execute(); + + await this.vacuum({ reindexVectors: false }); + } + async delete(entities: PersonEntity[]): Promise { await this.personRepository.remove(entities); } @@ -53,21 +65,14 @@ export class PersonRepository implements IPersonRepository { await this.personRepository.clear(); } - async deleteAllFaces({ sourceType }: DeleteAllFacesOptions): Promise { - if (!sourceType) { - return this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE'); - } - + async deleteFaces({ sourceType }: DeleteFacesOptions): Promise { await this.assetFaceRepository .createQueryBuilder('asset_faces') .delete() .andWhere('sourceType = :sourceType', { sourceType }) .execute(); - await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search'); - if (sourceType === SourceType.MACHINE_LEARNING) { - await this.assetFaceRepository.query('REINDEX INDEX face_index'); - } + await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING }); } getAllFaces( @@ -331,4 +336,13 @@ export class PersonRepository implements IPersonRepository { const { id } = await this.personRepository.save(person); return this.personRepository.findOneByOrFail({ id }); } + + private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise { + await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search, person'); + await this.assetFaceRepository.query('REINDEX TABLE asset_faces'); + await this.assetFaceRepository.query('REINDEX TABLE person'); + if (reindexVectors) { + await this.assetFaceRepository.query('REINDEX TABLE face_search'); + } + } } diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 60694b6bfe800..cb80c8d2f1c4e 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -1,5 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { randomUUID } from 'node:crypto'; import { getVectorExtension } from 'src/database.config'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; @@ -63,22 +64,15 @@ export class SearchRepository implements ISearchRepository { { takenAfter: DummyValue.DATE, lensModel: DummyValue.STRING, - ownerId: DummyValue.UUID, withStacked: true, isFavorite: true, - ownerIds: [DummyValue.UUID], + userIds: [DummyValue.UUID], }, ], }) async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated { let builder = this.assetRepository.createQueryBuilder('asset'); - builder = searchAssetBuilder(builder, options); - builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); - - if (options.random) { - // TODO replace with complicated SQL magic after kysely migration - builder.addSelect('RANDOM() as r').orderBy('r'); - } + builder = searchAssetBuilder(builder, options).orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); return paginatedBuilder(builder, { mode: PaginationMode.SKIP_TAKE, @@ -87,6 +81,35 @@ export class SearchRepository implements ISearchRepository { }); } + @GenerateSql({ + params: [ + 100, + { + takenAfter: DummyValue.DATE, + lensModel: DummyValue.STRING, + withStacked: true, + isFavorite: true, + userIds: [DummyValue.UUID], + }, + ], + }) + async searchRandom(size: number, options: AssetSearchOptions): Promise { + const builder1 = searchAssetBuilder(this.assetRepository.createQueryBuilder('asset'), options); + const builder2 = builder1.clone(); + + const uuid = randomUUID(); + builder1.andWhere('asset.id > :uuid', { uuid }).orderBy('asset.id').take(size); + builder2.andWhere('asset.id < :uuid', { uuid }).orderBy('asset.id').take(size); + + const [assets1, assets2] = await Promise.all([builder1.getMany(), builder2.getMany()]); + const missingCount = size - assets1.length; + for (let i = 0; i < missingCount && i < assets2.length; i++) { + assets1.push(assets2[i]); + } + + return assets1; + } + private createPersonFilter(builder: SelectQueryBuilder, personIds: string[]) { return builder .select(`${builder.alias}."assetId"`) diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 2e2d676939336..f36d26fa7c5b3 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -395,7 +395,7 @@ describe(AssetService.name, () => { it('should run the refresh thumbnails job', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1' } }]); + expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }]); }); it('should run the transcode video', async () => { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index b3f824f226c1a..aa88eaf9576b4 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -322,7 +322,7 @@ export class AssetService { } case AssetJobName.REGENERATE_THUMBNAIL: { - jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id } }); + jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id } }); break; } diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index ee6176115b311..9ba190d30afae 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Duration } from 'luxon'; import semver from 'semver'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { IConfigRepository } from 'src/interfaces/config.interface'; import { DatabaseExtension, @@ -74,7 +74,7 @@ export class DatabaseService { this.logger.setContext(DatabaseService.name); } - @OnEmit({ event: 'app.bootstrap', priority: -200 }) + @OnEvent({ name: 'app.bootstrap', priority: -200 }) async onBootstrap() { const version = await this.databaseRepository.getPostgresVersion(); const current = semver.coerce(version); diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 1c810facb453a..8d7c15073d48b 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -1,6 +1,5 @@ import { BadRequestException } from '@nestjs/common'; -import { SystemConfig } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { defaults } from 'src/config'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { @@ -60,6 +59,19 @@ describe(JobService.name, () => { expect(sut).toBeDefined(); }); + describe('onConfigUpdate', () => { + it('should update concurrency', () => { + sut.onBootstrap('microservices'); + sut.onConfigUpdate({ oldConfig: defaults, newConfig: defaults }); + + expect(jobMock.setConcurrency).toHaveBeenCalledTimes(14); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FACIAL_RECOGNITION, 1); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DUPLICATE_DETECTION, 1); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BACKGROUND_TASK, 5); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.STORAGE_TEMPLATE_MIGRATION, 1); + }); + }); + describe('handleNightlyJobs', () => { it('should run the scheduled jobs', async () => { await sut.handleNightlyJobs(); @@ -239,36 +251,6 @@ describe(JobService.name, () => { expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length); }); - it('should subscribe to config changes', async () => { - await sut.init(makeMockHandlers(JobStatus.FAILED)); - - SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ - job: { - [QueueName.BACKGROUND_TASK]: { concurrency: 10 }, - [QueueName.SMART_SEARCH]: { concurrency: 10 }, - [QueueName.METADATA_EXTRACTION]: { concurrency: 10 }, - [QueueName.FACE_DETECTION]: { concurrency: 10 }, - [QueueName.SEARCH]: { concurrency: 10 }, - [QueueName.SIDECAR]: { concurrency: 10 }, - [QueueName.LIBRARY]: { concurrency: 10 }, - [QueueName.MIGRATION]: { concurrency: 10 }, - [QueueName.THUMBNAIL_GENERATION]: { concurrency: 10 }, - [QueueName.VIDEO_CONVERSION]: { concurrency: 10 }, - [QueueName.NOTIFICATION]: { concurrency: 5 }, - }, - } as SystemConfig); - - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.BACKGROUND_TASK, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SMART_SEARCH, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.FACE_DETECTION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SIDECAR, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.LIBRARY, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.MIGRATION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.THUMBNAIL_GENERATION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.VIDEO_CONVERSION, 10); - }); - const tests: Array<{ item: JobItem; jobs: JobName[] }> = [ { item: { name: JobName.SIDECAR_SYNC, data: { id: 'asset-1' } }, @@ -288,7 +270,7 @@ describe(JobService.name, () => { }, { item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1', source: 'upload' } }, - jobs: [JobName.GENERATE_PREVIEW], + jobs: [JobName.GENERATE_THUMBNAILS], }, { item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1' } }, @@ -299,28 +281,16 @@ describe(JobService.name, () => { jobs: [], }, { - item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1' } }, - jobs: [JobName.GENERATE_THUMBNAIL, JobName.GENERATE_THUMBHASH], + item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }, + jobs: [], }, { - item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1', source: 'upload' } }, - jobs: [ - JobName.GENERATE_THUMBNAIL, - JobName.GENERATE_THUMBHASH, - JobName.SMART_SEARCH, - JobName.FACE_DETECTION, - JobName.VIDEO_CONVERSION, - ], + item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1', source: 'upload' } }, + jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION, JobName.VIDEO_CONVERSION], }, { - item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-live-image', source: 'upload' } }, - jobs: [ - JobName.GENERATE_THUMBNAIL, - JobName.GENERATE_THUMBHASH, - JobName.SMART_SEARCH, - JobName.FACE_DETECTION, - JobName.VIDEO_CONVERSION, - ], + item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-live-image', source: 'upload' } }, + jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION, JobName.VIDEO_CONVERSION], }, { item: { name: JobName.SMART_SEARCH, data: { id: 'asset-1' } }, @@ -338,11 +308,11 @@ describe(JobService.name, () => { for (const { item, jobs } of tests) { it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => { - if (item.name === JobName.GENERATE_PREVIEW && item.data.source === 'upload') { + if (item.name === JobName.GENERATE_THUMBNAILS && item.data.source === 'upload') { if (item.data.id === 'asset-live-image') { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); + assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]); } else { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoMotionAsset]); } } @@ -361,7 +331,7 @@ describe(JobService.name, () => { } }); - it(`should not queue any jobs when ${item.name} finishes with 'false'`, async () => { + it(`should not queue any jobs when ${item.name} fails`, async () => { await sut.init(makeMockHandlers(JobStatus.FAILED)); await jobMock.addHandler.mock.calls[0][2](item); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index f978f334108d1..68da13a8e4d4f 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -1,11 +1,12 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { snakeCase } from 'lodash'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEvent } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; import { AssetType, ManualJobName } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { ConcurrentQueueName, IJobRepository, @@ -45,6 +46,7 @@ const asJobItem = (dto: JobCreateDto): JobItem => { @Injectable() export class JobService { private configCore: SystemConfigCore; + private isMicroservices = false; constructor( @Inject(IAssetRepository) private assetRepository: IAssetRepository, @@ -59,6 +61,28 @@ export class JobService { this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } + @OnEvent({ name: 'app.bootstrap' }) + onBootstrap(app: ArgOf<'app.bootstrap'>) { + this.isMicroservices = app === 'microservices'; + } + + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig: config, oldConfig }: ArgOf<'config.update'>) { + if (!oldConfig || !this.isMicroservices) { + return; + } + + this.logger.debug(`Updating queue concurrency settings`); + for (const queueName of Object.values(QueueName)) { + let concurrency = 1; + if (this.isConcurrentQueue(queueName)) { + concurrency = config.job[queueName].concurrency; + } + this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`); + this.jobRepository.setConcurrency(queueName, concurrency); + } + } + async create(dto: JobCreateDto): Promise { await this.jobRepository.queue(asJobItem(dto)); } @@ -209,18 +233,6 @@ export class JobService { } }); } - - this.configCore.config$.subscribe((config) => { - this.logger.debug(`Updating queue concurrency settings`); - for (const queueName of Object.values(QueueName)) { - let concurrency = 1; - if (this.isConcurrentQueue(queueName)) { - concurrency = config.job[queueName].concurrency; - } - this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`); - this.jobRepository.setConcurrency(queueName, concurrency); - } - }); } private isConcurrentQueue(name: QueueName): name is ConcurrentQueueName { @@ -281,7 +293,7 @@ export class JobService { case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: { if (item.data.source === 'upload' || item.data.source === 'copy') { - await this.jobRepository.queue({ name: JobName.GENERATE_PREVIEW, data: item.data }); + await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: item.data }); } break; } @@ -295,40 +307,33 @@ export class JobService { break; } - case JobName.GENERATE_PREVIEW: { - const jobs: JobItem[] = [ - { name: JobName.GENERATE_THUMBNAIL, data: item.data }, - { name: JobName.GENERATE_THUMBHASH, data: item.data }, - ]; - - if (item.data.source === 'upload') { - jobs.push({ name: JobName.SMART_SEARCH, data: item.data }, { name: JobName.FACE_DETECTION, data: item.data }); - - const [asset] = await this.assetRepository.getByIds([item.data.id]); - if (asset) { - if (asset.type === AssetType.VIDEO) { - jobs.push({ name: JobName.VIDEO_CONVERSION, data: item.data }); - } else if (asset.livePhotoVideoId) { - jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } }); - } - } - } - - await this.jobRepository.queueAll(jobs); - break; - } - - case JobName.GENERATE_THUMBNAIL: { - if (!(item.data.notify || item.data.source === 'upload')) { + case JobName.GENERATE_THUMBNAILS: { + if (!item.data.notify && item.data.source !== 'upload') { break; } const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]); + if (!asset) { + this.logger.warn(`Could not find asset ${item.data.id} after generating thumbnails`); + break; + } - // Only live-photo motion part will be marked as not visible immediately on upload. Skip notifying clients - if (asset && asset.isVisible) { + const jobs: JobItem[] = [ + { name: JobName.SMART_SEARCH, data: item.data }, + { name: JobName.FACE_DETECTION, data: item.data }, + ]; + + if (asset.type === AssetType.VIDEO) { + jobs.push({ name: JobName.VIDEO_CONVERSION, data: item.data }); + } else if (asset.livePhotoVideoId) { + jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } }); + } + + await this.jobRepository.queueAll(jobs); + if (asset.isVisible) { this.eventRepository.clientSend(ClientEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset)); } + break; } diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 8b14c76cbcc1d..bcf0f1d0b550a 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -1,7 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { Stats } from 'node:fs'; -import { SystemConfig } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { defaults, SystemConfig } from 'src/config'; import { mapLibrary } from 'src/dtos/library.dto'; import { UserEntity } from 'src/entities/user.entity'; import { AssetType } from 'src/enum'; @@ -81,22 +80,26 @@ describe(LibraryService.name, () => { }); describe('onBootstrapEvent', () => { - it('should init cron job and subscribe to config changes', async () => { + it('should init cron job and handle config changes', async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryScan); await sut.onBootstrap(); - expect(systemMock.get).toHaveBeenCalled(); - expect(jobMock.addCronJob).toHaveBeenCalled(); - SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ - library: { - scan: { - enabled: true, - cronExpression: '0 1 * * *', + expect(jobMock.addCronJob).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); + + await sut.onConfigUpdate({ + oldConfig: defaults, + newConfig: { + library: { + scan: { + enabled: true, + cronExpression: '0 1 * * *', + }, + watch: { enabled: false }, }, - watch: { enabled: true }, - }, - } as SystemConfig); + } as SystemConfig, + }); expect(jobMock.updateCronJob).toHaveBeenCalledWith('libraryScan', '0 1 * * *', true); }); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 52b786089cbaf..b8b478531ff96 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -4,7 +4,7 @@ import path, { basename, parse } from 'node:path'; import picomatch from 'picomatch'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { CreateLibraryDto, LibraryResponseDto, @@ -61,7 +61,7 @@ export class LibraryService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap() { const config = await this.configCore.getConfig({ withCache: false }); @@ -83,19 +83,24 @@ export class LibraryService { if (this.watchLibraries) { await this.watchAll(); } - - this.configCore.config$.subscribe(({ library }) => { - this.jobRepository.updateCronJob('libraryScan', library.scan.cronExpression, library.scan.enabled); - - if (library.watch.enabled !== this.watchLibraries) { - // Watch configuration changed, update accordingly - this.watchLibraries = library.watch.enabled; - handlePromiseError(this.watchLibraries ? this.watchAll() : this.unwatchAll(), this.logger); - } - }); } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.update', server: true }) + async onConfigUpdate({ newConfig: { library }, oldConfig }: ArgOf<'config.update'>) { + if (!oldConfig || !this.watchLock) { + return; + } + + this.jobRepository.updateCronJob('libraryScan', library.scan.cronExpression, library.scan.enabled); + + if (library.watch.enabled !== this.watchLibraries) { + // Watch configuration changed, update accordingly + this.watchLibraries = library.watch.enabled; + await (this.watchLibraries ? this.watchAll() : this.unwatchAll()); + } + } + + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { const { scan } = newConfig.library; if (!validateCronExpression(scan.cronExpression)) { @@ -185,7 +190,7 @@ export class LibraryService { } } - @OnEmit({ event: 'app.shutdown' }) + @OnEvent({ name: 'app.shutdown' }) async onShutdown() { await this.unwatchAll(); } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index c0903fa101412..88e9f478bdf35 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -15,7 +15,7 @@ import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interfac import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMediaRepository } from 'src/interfaces/media.interface'; +import { IMediaRepository, RawImageInfo } from 'src/interfaces/media.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -94,7 +94,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -127,7 +127,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.trashed.id }, }, ]); @@ -152,7 +152,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.archived.id }, }, ]); @@ -202,7 +202,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -226,7 +226,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_THUMBNAIL, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -250,7 +250,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_THUMBHASH, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -259,10 +259,19 @@ describe(MediaService.name, () => { }); }); - describe('handleGeneratePreview', () => { + describe('handleGenerateThumbnails', () => { + let rawBuffer: Buffer; + let rawInfo: RawImageInfo; + + beforeEach(() => { + rawBuffer = Buffer.from('image data'); + rawInfo = { width: 100, height: 100, channels: 3 }; + mediaMock.decodeImage.mockResolvedValue({ data: rawBuffer, info: rawInfo }); + }); + it('should skip thumbnail generation if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith(); }); @@ -270,80 +279,100 @@ describe(MediaService.name, () => { it('should skip video thumbnail generation if no video stream', async () => { mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams); assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith(); }); it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); - expect(await sut.handleGeneratePreview({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); + expect(await sut.handleGenerateThumbnails({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith(); }); - it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => { - systemMock.get.mockResolvedValue({ image: { preview: { format } } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; - - await sut.handleGeneratePreview({ id: assetStub.image.id }); - - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith('/original/path.jpg', previewPath, { - size: 1440, - format, - quality: 80, - colorspace: Colorspace.SRGB, - processInvalidImages: false, - }); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: previewPath, - }); - }); - it('should delete previous preview if different path', async () => { systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); + assetMock.getById.mockResolvedValue(assetStub.image); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); }); - it('should generate a P3 thumbnail for a wide gamut image', async () => { - assetMock.getByIds.mockResolvedValue([ - { ...assetStub.image, exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity }, - ]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + it('should generate P3 thumbnails for a wide gamut image', async () => { + assetMock.getById.mockResolvedValue({ + ...assetStub.image, + exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity, + }); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - '/original/path.jpg', - 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { - size: 1440, - format: ImageFormat.JPEG, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, }); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, + size: 1440, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.P3, + format: ImageFormat.WEBP, + size: 250, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + ); + + expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce(); + expect(mediaMock.generateThumbhash).toHaveBeenCalledWith(rawBuffer, { + colorspace: Colorspace.P3, + processInvalidImages: false, + raw: rawInfo, + }); + + expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + }, + ]); + expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); }); it('should generate a thumbnail for a video', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -361,17 +390,24 @@ describe(MediaService.name, () => { twoPass: false, }), ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - }); + expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + }, + ]); }); it('should tonemap thumbnail for hdr video', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -389,11 +425,18 @@ describe(MediaService.name, () => { twoPass: false, }), ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - }); + expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + }, + ]); }); it('should always generate video thumbnail in one pass', async () => { @@ -401,8 +444,8 @@ describe(MediaService.name, () => { systemMock.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '5000k' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -424,8 +467,8 @@ describe(MediaService.name, () => { it('should use scaling divisible by 2 even when using quick sync', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -438,233 +481,207 @@ describe(MediaService.name, () => { ); }); - it('should run successfully', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); - }); - }); + it.each(Object.values(ImageFormat))('should generate an image preview in %s format', async (format) => { + systemMock.get.mockResolvedValue({ image: { preview: { format } } }); + assetMock.getById.mockResolvedValue(assetStub.image); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; + const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.webp`; - describe('handleGenerateThumbnail', () => { - it('should skip thumbnail generation if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); - }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + colorspace: Colorspace.SRGB, + processInvalidImages: false, + size: 1440, + }); - expect(await sut.handleGenerateThumbnail({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); - - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); - }); - - it.each(Object.values(ImageFormat))( - 'should generate a %s thumbnail for an image when specified', - async (format) => { - systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; - - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith('/original/path.jpg', thumbnailPath, { - size: 250, - format, - quality: 80, + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { colorspace: Colorspace.SRGB, + format, + size: 1440, + quality: 80, processInvalidImages: false, - }); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.THUMBNAIL, - path: thumbnailPath, - }); - }, - ); + raw: rawInfo, + }, + previewPath, + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.SRGB, + format: ImageFormat.WEBP, + size: 250, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + thumbnailPath, + ); + }); + + it.each(Object.values(ImageFormat))('should generate an image thumbnail in %s format', async (format) => { + systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } }); + assetMock.getById.mockResolvedValue(assetStub.image); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.jpeg`; + const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + colorspace: Colorspace.SRGB, + processInvalidImages: false, + size: 1440, + }); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.SRGB, + format: ImageFormat.JPEG, + size: 1440, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + previewPath, + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.SRGB, + format, + size: 250, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + thumbnailPath, + ); + }); it('should delete previous thumbnail if different path', async () => { systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); + assetMock.getById.mockResolvedValue(assetStub.image); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext'); }); - }); - it('should generate a P3 thumbnail for a wide gamut image', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + it('should extract embedded image if enabled and available', async () => { + mediaMock.extract.mockResolvedValue(true); + mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(extractedPath, { colorspace: Colorspace.P3, processInvalidImages: false, - }, - ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.THUMBNAIL, - path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + size: 1440, + }); + expect(extractedPath?.endsWith('.tmp')).toBe(true); + expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); }); - }); - it('should extract embedded image if enabled and available', async () => { - mediaMock.extract.mockResolvedValue(true); - mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + it('should resize original image if embedded image is too small', async () => { + mediaMock.extract.mockResolvedValue(true); + mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); - expect(mediaMock.generateThumbnail.mock.calls).toEqual([ - [ - extractedPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ], - ]); - expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); - }); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, + }); + const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + expect(extractedPath?.endsWith('.tmp')).toBe(true); + expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); + }); - it('should resize original image if embedded image is too small', async () => { - mediaMock.extract.mockResolvedValue(true); - mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + it('should resize original image if embedded image not found', async () => { + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.generateThumbnail.mock.calls).toEqual([ - [ + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, + }); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + }); + + it('should resize original image if embedded image extraction is not enabled', async () => { + systemMock.get.mockResolvedValue({ image: { extractEmbedded: false } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mediaMock.extract).not.toHaveBeenCalled(); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, + }); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + }); + + it('should process invalid images if enabled', async () => { + vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true'); + + assetMock.getById.mockResolvedValue(assetStub.imageDng); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith( assetStub.imageDng.originalPath, + expect.objectContaining({ processInvalidImages: true }), + ); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ processInvalidImages: true }), + 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ processInvalidImages: true }), 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ], - ]); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); - expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); - }); + ); - it('should resize original image if embedded image not found', async () => { - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce(); + expect(mediaMock.generateThumbhash).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ processInvalidImages: true }), + ); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); - }); - - it('should resize original image if embedded image extraction is not enabled', async () => { - systemMock.get.mockResolvedValue({ image: { extractEmbedded: false } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); - - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(mediaMock.extract).not.toHaveBeenCalled(); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); - }); - - it('should process invalid images if enabled', async () => { - vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true'); - - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); - - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: true, - }, - ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); - vi.unstubAllEnvs(); - }); - - describe('handleGenerateThumbhash', () => { - it('should skip thumbhash generation if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); - await sut.handleGenerateThumbhash({ id: assetStub.image.id }); - expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); - }); - - it('should skip thumbhash generation if resize path is missing', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); - await sut.handleGenerateThumbhash({ id: assetStub.noResizePath.id }); - expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); - }); - - it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); - - expect(await sut.handleGenerateThumbhash({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); - - expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); - }); - - it('should generate a thumbhash', async () => { - const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); - - await sut.handleGenerateThumbhash({ id: assetStub.image.id }); - - expect(mediaMock.generateThumbhash).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); }); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 1b69c5acd5504..71f432e040c43 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,6 +1,7 @@ -import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { dirname } from 'node:path'; -import { GeneratedImageType, StorageCore } from 'src/cores/storage.core'; + +import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { AssetEntity } from 'src/entities/asset.entity'; @@ -18,7 +19,7 @@ import { VideoCodec, VideoContainer, } from 'src/enum'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; +import { IAssetRepository, UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IBaseJob, @@ -95,18 +96,10 @@ export class MediaService { for (const asset of assets) { const { previewFile, thumbnailFile } = getAssetFiles(asset.files); - if (!previewFile || force) { - jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id: asset.id } }); + if (!previewFile || !thumbnailFile || !asset.thumbhash || force) { + jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id: asset.id } }); continue; } - - if (!thumbnailFile) { - jobs.push({ name: JobName.GENERATE_THUMBNAIL, data: { id: asset.id } }); - } - - if (!asset.thumbhash) { - jobs.push({ name: JobName.GENERATE_THUMBHASH, data: { id: asset.id } }); - } } await this.jobRepository.queueAll(jobs); @@ -181,141 +174,127 @@ export class MediaService { return JobStatus.SUCCESS; } - async handleGeneratePreview({ id }: IEntityJob): Promise { - const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true }); + async handleGenerateThumbnails({ id }: IEntityJob): Promise { + const asset = await this.assetRepository.getById(id, { exifInfo: true, files: true }); if (!asset) { + this.logger.warn(`Thumbnail generation failed for asset ${id}: not found`); return JobStatus.FAILED; } if (!asset.isVisible) { + this.logger.verbose(`Thumbnail generation skipped for asset ${id}: not visible`); return JobStatus.SKIPPED; } - const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW); - if (!previewPath) { + let generated: { previewPath: string; thumbnailPath: string; thumbhash: Buffer }; + if (asset.type === AssetType.IMAGE) { + generated = await this.generateImageThumbnails(asset); + } else if (asset.type === AssetType.VIDEO) { + generated = await this.generateVideoThumbnails(asset); + } else { + this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`); return JobStatus.SKIPPED; } - const { previewFile } = getAssetFiles(asset.files); - if (previewFile && previewFile.path !== previewPath) { + const { previewFile, thumbnailFile } = getAssetFiles(asset.files); + const toUpsert: UpsertFileOptions[] = []; + if (previewFile?.path !== generated.previewPath) { + toUpsert.push({ assetId: asset.id, path: generated.previewPath, type: AssetFileType.PREVIEW }); + } + + if (thumbnailFile?.path !== generated.thumbnailPath) { + toUpsert.push({ assetId: asset.id, path: generated.thumbnailPath, type: AssetFileType.THUMBNAIL }); + } + + if (toUpsert.length > 0) { + await this.assetRepository.upsertFiles(toUpsert); + } + + const pathsToDelete = []; + if (previewFile && previewFile.path !== generated.previewPath) { this.logger.debug(`Deleting old preview for asset ${asset.id}`); - await this.storageRepository.unlink(previewFile.path); + pathsToDelete.push(previewFile.path); } - await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.PREVIEW, path: previewPath }); - await this.assetRepository.update({ id: asset.id, updatedAt: new Date() }); - await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date() }); - - return JobStatus.SUCCESS; - } - - private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType) { - const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true }); - const { size, format, quality } = image[type]; - const path = StorageCore.getImagePath(asset, type, format); - this.storageCore.ensureFolders(path); - - switch (asset.type) { - case AssetType.IMAGE: { - const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath); - const extractedPath = StorageCore.getTempPathInDir(dirname(path)); - const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); - - try { - const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size)); - const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; - const imageOptions = { - format, - size, - colorspace, - quality, - processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', - }; - - const outputPath = useExtracted ? extractedPath : asset.originalPath; - await this.mediaRepository.generateThumbnail(outputPath, path, imageOptions); - } finally { - if (didExtract) { - await this.storageRepository.unlink(extractedPath); - } - } - break; - } - - case AssetType.VIDEO: { - const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); - const mainVideoStream = this.getMainStream(videoStreams); - if (!mainVideoStream) { - this.logger.warn(`Skipped thumbnail generation for asset ${asset.id}: no video streams found`); - return; - } - const mainAudioStream = this.getMainStream(audioStreams); - const config = ThumbnailConfig.create({ ...ffmpeg, targetResolution: size.toString() }); - const options = config.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); - await this.mediaRepository.transcode(asset.originalPath, path, options); - break; - } - - default: { - throw new UnsupportedMediaTypeException(`Unsupported asset type for thumbnail generation: ${asset.type}`); - } - } - - const assetLabel = asset.isExternal ? asset.originalPath : asset.id; - this.logger.log( - `Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} ${type} for asset ${assetLabel}`, - ); - - return path; - } - - async handleGenerateThumbnail({ id }: IEntityJob): Promise { - const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true }); - if (!asset) { - return JobStatus.FAILED; - } - - if (!asset.isVisible) { - return JobStatus.SKIPPED; - } - - const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL); - if (!thumbnailPath) { - return JobStatus.SKIPPED; - } - - const { thumbnailFile } = getAssetFiles(asset.files); - if (thumbnailFile && thumbnailFile.path !== thumbnailPath) { + if (thumbnailFile && thumbnailFile.path !== generated.thumbnailPath) { this.logger.debug(`Deleting old thumbnail for asset ${asset.id}`); - await this.storageRepository.unlink(thumbnailFile.path); + pathsToDelete.push(thumbnailFile.path); } - await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.THUMBNAIL, path: thumbnailPath }); - await this.assetRepository.update({ id: asset.id, updatedAt: new Date() }); - await this.assetRepository.upsertJobStatus({ assetId: asset.id, thumbnailAt: new Date() }); + if (pathsToDelete.length > 0) { + await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path))); + } + + if (asset.thumbhash != generated.thumbhash) { + await this.assetRepository.update({ id: asset.id, thumbhash: generated.thumbhash }); + } + + await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date(), thumbnailAt: new Date() }); return JobStatus.SUCCESS; } - async handleGenerateThumbhash({ id }: IEntityJob): Promise { - const [asset] = await this.assetRepository.getByIds([id], { files: true }); - if (!asset) { - return JobStatus.FAILED; + private async generateImageThumbnails(asset: AssetEntity) { + const { image } = await this.configCore.getConfig({ withCache: true }); + const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); + const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); + this.storageCore.ensureFolders(previewPath); + + const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath); + const extractedPath = StorageCore.getTempPathInDir(dirname(previewPath)); + const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); + + try { + const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size)); + const inputPath = useExtracted ? extractedPath : asset.originalPath; + const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; + const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true'; + + const decodeOptions = { colorspace, processInvalidImages, size: image.preview.size }; + const { data, info } = await this.mediaRepository.decodeImage(inputPath, decodeOptions); + + const options = { colorspace, processInvalidImages, raw: info }; + const outputs = await Promise.all([ + this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...options }, thumbnailPath), + this.mediaRepository.generateThumbnail(data, { ...image.preview, ...options }, previewPath), + this.mediaRepository.generateThumbhash(data, options), + ]); + + return { previewPath, thumbnailPath, thumbhash: outputs[2] }; + } finally { + if (didExtract) { + await this.storageRepository.unlink(extractedPath); + } } + } - if (!asset.isVisible) { - return JobStatus.SKIPPED; + private async generateVideoThumbnails(asset: AssetEntity) { + const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true }); + const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); + const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); + this.storageCore.ensureFolders(previewPath); + + const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); + const mainVideoStream = this.getMainStream(videoStreams); + if (!mainVideoStream) { + throw new Error(`No video streams found for asset ${asset.id}`); } + const mainAudioStream = this.getMainStream(audioStreams); - const { previewFile } = getAssetFiles(asset.files); - if (!previewFile) { - return JobStatus.FAILED; - } + const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() }); + const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() }); - const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path); - await this.assetRepository.update({ id: asset.id, thumbhash }); + const previewOptions = previewConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); + const thumbnailOptions = thumbnailConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); + await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions); + await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions); - return JobStatus.SUCCESS; + const thumbhash = await this.mediaRepository.generateThumbhash(previewPath, { + colorspace: image.colorspace, + processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', + }); + + return { previewPath, thumbnailPath, thumbhash }; } async handleQueueVideoConversion(job: IBaseJob): Promise { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 224ef03b3b019..9499a4bdd99b0 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -8,7 +8,7 @@ import path from 'node:path'; import { SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; @@ -132,7 +132,7 @@ export class MetadataService { ); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(app: ArgOf<'app.bootstrap'>) { if (app !== 'microservices') { return; @@ -141,7 +141,12 @@ export class MetadataService { await this.init(config); } - @OnEmit({ event: 'config.update' }) + @OnEvent({ name: 'app.shutdown' }) + async onShutdown() { + await this.repository.teardown(); + } + + @OnEvent({ name: 'config.update' }) async onConfigUpdate({ newConfig }: ArgOf<'config.update'>) { await this.init(newConfig); } @@ -164,11 +169,6 @@ export class MetadataService { } } - @OnEmit({ event: 'app.shutdown' }) - async onShutdown() { - await this.repository.teardown(); - } - async handleLivePhotoLinking(job: IEntityJob): Promise { const { id } = job; const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true }); @@ -333,12 +333,12 @@ export class MetadataService { return this.processSidecar(id, false); } - @OnEmit({ event: 'asset.tag' }) + @OnEvent({ name: 'asset.tag' }) async handleTagAsset({ assetId }: ArgOf<'asset.tag'>) { await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } }); } - @OnEmit({ event: 'asset.untag' }) + @OnEvent({ name: 'asset.untag' }) async handleUntagAsset({ assetId }: ArgOf<'asset.untag'>) { await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } }); } diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 80f1b2be415aa..23604b6ef6fb9 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { ArgOf } from 'src/interfaces/event.interface'; import { IDeleteFilesJob, JobName } from 'src/interfaces/job.interface'; import { AssetService } from 'src/services/asset.service'; @@ -43,7 +43,7 @@ export class MicroservicesService { private versionService: VersionService, ) {} - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(app: ArgOf<'app.bootstrap'>) { if (app !== 'microservices') { return; @@ -68,9 +68,7 @@ export class MicroservicesService { [JobName.MIGRATE_ASSET]: (data) => this.mediaService.handleAssetMigration(data), [JobName.MIGRATE_PERSON]: (data) => this.personService.handlePersonMigration(data), [JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data), - [JobName.GENERATE_PREVIEW]: (data) => this.mediaService.handleGeneratePreview(data), - [JobName.GENERATE_THUMBNAIL]: (data) => this.mediaService.handleGenerateThumbnail(data), - [JobName.GENERATE_THUMBHASH]: (data) => this.mediaService.handleGenerateThumbhash(data), + [JobName.GENERATE_THUMBNAILS]: (data) => this.mediaService.handleGenerateThumbnails(data), [JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data), [JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data), [JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataService.handleQueueMetadataExtraction(data), diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index a0b9436f75435..106f0be08248d 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -6,7 +6,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; +import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; @@ -100,6 +100,15 @@ describe(NotificationService.name, () => { expect(sut).toBeDefined(); }); + describe('onConfigUpdate', () => { + it('should emit client and server events', () => { + const update = { newConfig: defaults }; + expect(sut.onConfigUpdate(update)).toBeUndefined(); + expect(eventMock.clientBroadcast).toHaveBeenCalledWith(ClientEvent.CONFIG_UPDATE, {}); + expect(eventMock.serverSend).toHaveBeenCalledWith('config.update', update); + }); + }); + describe('onConfigValidateEvent', () => { it('validates smtp config when enabling smtp', async () => { const oldConfig = configs.smtpDisabled; @@ -155,7 +164,7 @@ describe(NotificationService.name, () => { it('should queue the generate thumbnail job', async () => { await sut.onAssetShow({ assetId: 'asset-id', userId: 'user-id' }); expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.GENERATE_THUMBNAIL, + name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-id', notify: true }, }); }); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index bdb23ce700ab2..626e536c409e2 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AlbumEntity } from 'src/entities/album.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; @@ -43,7 +43,13 @@ export class NotificationService { this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } - @OnEmit({ event: 'config.validate', priority: -100 }) + @OnEvent({ name: 'config.update' }) + onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { + this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {}); + this.eventRepository.serverSend('config.update', { oldConfig, newConfig }); + } + + @OnEvent({ name: 'config.validate', priority: -100 }) async onConfigValidate({ oldConfig, newConfig }: ArgOf<'config.validate'>) { try { if ( @@ -58,74 +64,74 @@ export class NotificationService { } } - @OnEmit({ event: 'asset.hide' }) + @OnEvent({ name: 'asset.hide' }) onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) { this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId); } - @OnEmit({ event: 'asset.show' }) + @OnEvent({ name: 'asset.show' }) async onAssetShow({ assetId }: ArgOf<'asset.show'>) { - await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAIL, data: { id: assetId, notify: true } }); + await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: { id: assetId, notify: true } }); } - @OnEmit({ event: 'asset.trash' }) + @OnEvent({ name: 'asset.trash' }) onAssetTrash({ assetId, userId }: ArgOf<'asset.trash'>) { this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, [assetId]); } - @OnEmit({ event: 'asset.delete' }) + @OnEvent({ name: 'asset.delete' }) onAssetDelete({ assetId, userId }: ArgOf<'asset.delete'>) { this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, userId, assetId); } - @OnEmit({ event: 'assets.trash' }) + @OnEvent({ name: 'assets.trash' }) onAssetsTrash({ assetIds, userId }: ArgOf<'assets.trash'>) { this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, assetIds); } - @OnEmit({ event: 'assets.restore' }) + @OnEvent({ name: 'assets.restore' }) onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) { this.eventRepository.clientSend(ClientEvent.ASSET_RESTORE, userId, assetIds); } - @OnEmit({ event: 'stack.create' }) + @OnEvent({ name: 'stack.create' }) onStackCreate({ userId }: ArgOf<'stack.create'>) { this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); } - @OnEmit({ event: 'stack.update' }) + @OnEvent({ name: 'stack.update' }) onStackUpdate({ userId }: ArgOf<'stack.update'>) { this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); } - @OnEmit({ event: 'stack.delete' }) + @OnEvent({ name: 'stack.delete' }) onStackDelete({ userId }: ArgOf<'stack.delete'>) { this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); } - @OnEmit({ event: 'stacks.delete' }) + @OnEvent({ name: 'stacks.delete' }) onStacksDelete({ userId }: ArgOf<'stacks.delete'>) { this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); } - @OnEmit({ event: 'user.signup' }) + @OnEvent({ name: 'user.signup' }) async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) { if (notify) { await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id, tempPassword } }); } } - @OnEmit({ event: 'album.update' }) + @OnEvent({ name: 'album.update' }) async onAlbumUpdate({ id, updatedBy }: ArgOf<'album.update'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id, senderId: updatedBy } }); } - @OnEmit({ event: 'album.invite' }) + @OnEvent({ name: 'album.invite' }) async onAlbumInvite({ id, userId }: ArgOf<'album.invite'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } }); } - @OnEmit({ event: 'session.delete' }) + @OnEvent({ name: 'session.delete' }) onSessionDelete({ sessionId }: ArgOf<'session.delete'>) { // after the response is sent setTimeout(() => this.eventRepository.clientSend(ClientEvent.SESSION_DELETE, sessionId, sessionId), 500); diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 03da110ac6049..5214808de0344 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -2,7 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { CacheControl, Colorspace, SourceType, SystemMetadataKey } from 'src/enum'; +import { CacheControl, Colorspace, ImageFormat, SourceType, SystemMetadataKey } from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; @@ -660,7 +660,7 @@ describe(PersonService.name, () => { expect(systemMock.set).not.toHaveBeenCalled(); }); - it('should delete existing people and faces if forced', async () => { + it('should delete existing people if forced', async () => { jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); personMock.getAll.mockResolvedValue({ items: [faceStub.face1.person, personStub.randomPerson], @@ -675,7 +675,8 @@ describe(PersonService.name, () => { await sut.handleQueueRecognizeFaces({ force: true }); - expect(personMock.deleteAllFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); + expect(personMock.deleteFaces).not.toHaveBeenCalled(); + expect(personMock.unassignFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, @@ -961,12 +962,11 @@ describe(PersonService.name, () => { expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs'); expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', { - format: 'jpeg', + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, size: 250, quality: 80, - colorspace: Colorspace.P3, crop: { left: 238, top: 163, @@ -975,6 +975,7 @@ describe(PersonService.name, () => { }, processInvalidImages: false, }, + 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', ); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', @@ -990,13 +991,12 @@ describe(PersonService.name, () => { await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.image.originalPath, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', + assetStub.primaryImage.originalPath, { - format: 'jpeg', + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, size: 250, quality: 80, - colorspace: Colorspace.P3, crop: { left: 0, top: 85, @@ -1005,6 +1005,7 @@ describe(PersonService.name, () => { }, processInvalidImages: false, }, + 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', ); }); @@ -1017,12 +1018,11 @@ describe(PersonService.name, () => { expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', { - format: 'jpeg', + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, size: 250, quality: 80, - colorspace: Colorspace.P3, crop: { left: 591, top: 591, @@ -1031,33 +1031,7 @@ describe(PersonService.name, () => { }, processInvalidImages: false, }, - ); - }); - - it('should use preview path for videos', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.end); - assetMock.getById.mockResolvedValue(assetStub.video); - mediaMock.getImageDimensions.mockResolvedValue({ width: 2560, height: 1440 }); - - await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); - - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - '/uploads/user-id/thumbs/path.jpg', 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', - { - format: 'jpeg', - size: 250, - quality: 80, - colorspace: Colorspace.P3, - crop: { - left: 1741, - top: 851, - width: 588, - height: 588, - }, - processInvalidImages: false, - }, ); }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 651c8eebee54e..b009696b637fe 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -276,16 +276,6 @@ export class PersonService { this.logger.debug(`Deleted ${people.length} people`); } - private async deleteAllPeople() { - const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.repository.getAll({ ...pagination, skip: 0 }), - ); - - for await (const people of personPagination) { - await this.delete(people); // deletes thumbnails too - } - } - async handlePersonCleanup(): Promise { const people = await this.repository.getAllWithoutFaces(); await this.delete(people); @@ -299,7 +289,7 @@ export class PersonService { } if (force) { - await this.repository.deleteAllFaces({ sourceType: SourceType.MACHINE_LEARNING }); + await this.repository.deleteFaces({ sourceType: SourceType.MACHINE_LEARNING }); await this.handlePersonCleanup(); } @@ -407,7 +397,7 @@ export class PersonService { const { waiting } = await this.jobRepository.getJobCounts(QueueName.FACIAL_RECOGNITION); if (force) { - await this.repository.deleteAllFaces({ sourceType: SourceType.MACHINE_LEARNING }); + await this.repository.unassignFaces({ sourceType: SourceType.MACHINE_LEARNING }); await this.handlePersonCleanup(); } else if (waiting) { this.logger.debug( @@ -571,15 +561,15 @@ export class PersonService { this.storageCore.ensureFolders(thumbnailPath); const thumbnailOptions = { + colorspace: image.colorspace, format: ImageFormat.JPEG, size: FACE_THUMBNAIL_SIZE, - colorspace: image.colorspace, quality: image.thumbnail.quality, crop: this.getCrop({ old: { width: oldWidth, height: oldHeight }, new: { width, height } }, { x1, y1, x2, y2 }), processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', - } as const; + }; - await this.mediaRepository.generateThumbnail(inputPath, thumbnailPath, thumbnailOptions); + await this.mediaRepository.generateThumbnail(inputPath, thumbnailOptions, thumbnailPath); await this.repository.update({ id: person.id, thumbnailPath }); return JobStatus.SUCCESS; diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index dc6e71f345943..c3cc5399c8d73 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -94,20 +94,10 @@ export class SearchService { return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); } - async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise { + async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise { const userIds = await this.getUserIdsToSearch(auth); - const page = dto.page ?? 1; - const size = dto.size || 250; - const { hasNextPage, items } = await this.searchRepository.searchMetadata( - { page, size }, - { - ...dto, - userIds, - random: true, - }, - ); - - return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); + const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds }); + return items.map((item) => mapAsset(item, { auth })); } async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index a192c2f308ba0..708fe32db52cb 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -3,7 +3,7 @@ import { getBuildMetadata, getServerLicensePublicKey } from 'src/config'; import { serverVersion } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { ServerAboutResponseDto, @@ -42,7 +42,7 @@ export class ServerService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(): Promise { const featureFlags = await this.getFeatures(); if (featureFlags.configFile) { diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index a75594100f231..ef7865d25c4d9 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { SystemConfig } from 'src/config'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; import { ArgOf } from 'src/interfaces/event.interface'; @@ -39,7 +39,7 @@ export class SmartInfoService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(app: ArgOf<'app.bootstrap'>) { if (app !== 'microservices') { return; @@ -49,7 +49,12 @@ export class SmartInfoService { await this.init(config); } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.update' }) + async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { + await this.init(newConfig, oldConfig); + } + + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { try { getCLIPModelInfo(newConfig.machineLearning.clip.modelName); @@ -60,11 +65,6 @@ export class SmartInfoService { } } - @OnEmit({ event: 'config.update' }) - async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { - await this.init(newConfig, oldConfig); - } - private async init(newConfig: SystemConfig, oldConfig?: SystemConfig) { if (!isSmartSearchEnabled(newConfig.machineLearning)) { return; diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index e8e222c7b2491..36a50c41bd623 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -1,6 +1,5 @@ import { Stats } from 'node:fs'; import { SystemConfig, defaults } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; @@ -74,7 +73,7 @@ describe(StorageTemplateService.name, () => { loggerMock, ); - SystemConfigCore.create(systemMock, loggerMock).config$.next(defaults); + sut.onConfigUpdate({ newConfig: defaults }); }); describe('onConfigValidate', () => { @@ -164,13 +163,15 @@ describe(StorageTemplateService.name, () => { originalPath: newMotionPicturePath, }); }); - it('Should use handlebar if condition for album', async () => { + + it('should use handlebar if condition for album', async () => { const asset = assetStub.image; const user = userStub.user1; const album = albumStub.oneAsset; const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}'; - SystemConfigCore.create(systemMock, loggerMock).config$.next(config); + + sut.onConfigUpdate({ oldConfig: defaults, newConfig: config }); userMock.get.mockResolvedValue(user); assetMock.getByIds.mockResolvedValueOnce([asset]); @@ -185,12 +186,13 @@ describe(StorageTemplateService.name, () => { pathType: AssetPathType.ORIGINAL, }); }); - it('Should use handlebar else condition for album', async () => { + + it('should use handlebar else condition for album', async () => { const asset = assetStub.image; const user = userStub.user1; const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}'; - SystemConfigCore.create(systemMock, loggerMock).config$.next(config); + sut.onConfigUpdate({ oldConfig: defaults, newConfig: config }); userMock.get.mockResolvedValue(user); assetMock.getByIds.mockResolvedValueOnce([asset]); @@ -205,6 +207,7 @@ describe(StorageTemplateService.name, () => { pathType: AssetPathType.ORIGINAL, }); }); + it('should migrate previously failed move from original path when it still exists', async () => { userMock.get.mockResolvedValue(userStub.user1); const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; @@ -242,6 +245,7 @@ describe(StorageTemplateService.name, () => { originalPath: newPath, }); }); + it('should migrate previously failed move from previous new path when old path no longer exists, should validate file size still matches before moving', async () => { userMock.get.mockResolvedValue(userStub.user1); const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 30d0eb575f1de..33b08efc9bc53 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -3,7 +3,6 @@ import handlebar from 'handlebars'; import { DateTime } from 'luxon'; import path from 'node:path'; import sanitize from 'sanitize-filename'; -import { SystemConfig } from 'src/config'; import { supportedDayTokens, supportedHourTokens, @@ -15,7 +14,7 @@ import { } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType, AssetType, StorageFolder } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; @@ -76,7 +75,6 @@ export class StorageTemplateService { ) { this.logger.setContext(StorageTemplateService.name); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - this.configCore.config$.subscribe((config) => this.onConfig(config)); this.storageCore = StorageCore.create( assetRepository, cryptoRepository, @@ -88,7 +86,16 @@ export class StorageTemplateService { ); } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig }: ArgOf<'config.update'>) { + const template = newConfig.storageTemplate.template; + if (!this._template || template !== this.template.raw) { + this.logger.debug(`Compiling new storage template: ${template}`); + this._template = this.compile(template); + } + } + + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { try { const { compiled } = this.compile(newConfig.storageTemplate.template); @@ -282,14 +289,6 @@ export class StorageTemplateService { } } - private onConfig(config: SystemConfig) { - const template = config.storageTemplate.template; - if (!this._template || template !== this.template.raw) { - this.logger.debug(`Compiling new storage template: ${template}`); - this._template = this.compile(template); - } - } - private compile(template: string) { return { raw: template, diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index 6d15f097d3956..b32e48ea498ad 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { join } from 'node:path'; import { StorageCore } from 'src/cores/storage.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { StorageFolder, SystemMetadataKey } from 'src/enum'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface'; @@ -21,7 +21,7 @@ export class StorageService { this.logger.setContext(StorageService.name); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap() { await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => { const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false }; diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 514d8aa0f8d58..ac517bb3ff587 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -6,14 +6,13 @@ import { CQMode, ImageFormat, LogLevel, - SystemMetadataKey, ToneMapping, TranscodeHWAccel, TranscodePolicy, VideoCodec, VideoContainer, } from 'src/enum'; -import { IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { QueueName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @@ -381,14 +380,13 @@ describe(SystemConfigService.name, () => { }); describe('updateConfig', () => { - it('should update the config and emit client and server events', async () => { + it('should update the config and emit an event', async () => { systemMock.get.mockResolvedValue(partialConfig); - await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig); - - expect(eventMock.clientBroadcast).toHaveBeenCalled(); - expect(eventMock.serverSend).toHaveBeenCalledWith(ServerEvent.CONFIG_UPDATE, null); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); + expect(eventMock.emit).toHaveBeenCalledWith( + 'config.update', + expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }), + ); }); it('should throw an error if a config file is in use', async () => { diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index 8a7f9123e00c2..100ab6f47c9e9 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { instanceToPlain } from 'class-transformer'; import _ from 'lodash'; -import { SystemConfig, defaults } from 'src/config'; +import { defaults } from 'src/config'; import { supportedDayTokens, supportedHourTokens, @@ -13,10 +13,10 @@ import { supportedYearTokens, } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit, OnServerEvent } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto'; import { LogLevel } from 'src/enum'; -import { ArgOf, ClientEvent, IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; +import { ArgOf, IEventRepository } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { toPlainObject } from 'src/utils/object'; @@ -32,13 +32,12 @@ export class SystemConfigService { ) { this.logger.setContext(SystemConfigService.name); this.core = SystemConfigCore.create(repository, this.logger); - this.core.config$.subscribe((config) => this.setLogLevel(config)); } - @OnEmit({ event: 'app.bootstrap', priority: -100 }) + @OnEvent({ name: 'app.bootstrap', priority: -100 }) async onBootstrap() { const config = await this.core.getConfig({ withCache: false }); - this.core.config$.next(config); + await this.eventRepository.emit('config.update', { newConfig: config }); } async getConfig(): Promise { @@ -50,7 +49,18 @@ export class SystemConfigService { return mapConfig(defaults); } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig: { logging } }: ArgOf<'config.update'>) { + const envLevel = this.getEnvLogLevel(); + const configLevel = logging.enabled ? logging.level : false; + const level = envLevel ?? configLevel; + this.logger.setLogLevel(level); + this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`); + // TODO only do this if the event is a socket.io event + this.core.invalidateCache(); + } + + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig, oldConfig }: ArgOf<'config.validate'>) { if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) { throw new Error('Logging cannot be changed while the environment variable IMMICH_LOG_LEVEL is set.'); @@ -73,9 +83,6 @@ export class SystemConfigService { const newConfig = await this.core.updateConfig(dto); - // TODO probably move web socket emits to a separate service - this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {}); - this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null); await this.eventRepository.emit('config.update', { newConfig, oldConfig }); return mapConfig(newConfig); @@ -101,19 +108,6 @@ export class SystemConfigService { return theme.customCss; } - @OnServerEvent(ServerEvent.CONFIG_UPDATE) - async onConfigUpdateEvent() { - await this.core.refreshConfig(); - } - - private setLogLevel({ logging }: SystemConfig) { - const envLevel = this.getEnvLogLevel(); - const configLevel = logging.enabled ? logging.level : false; - const level = envLevel ?? configLevel; - this.logger.setLogLevel(level); - this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`); - } - private getEnvLogLevel() { return process.env.IMMICH_LOG_LEVEL as LogLevel; } diff --git a/server/src/services/trash.service.ts b/server/src/services/trash.service.ts index 88340f7d7c2c7..51771d38a2aa9 100644 --- a/server/src/services/trash.service.ts +++ b/server/src/services/trash.service.ts @@ -1,5 +1,5 @@ import { Inject } from '@nestjs/common'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { TrashResponseDto } from 'src/dtos/trash.dto'; @@ -54,7 +54,7 @@ export class TrashService { return { count }; } - @OnEmit({ event: 'assets.delete' }) + @OnEvent({ name: 'assets.delete' }) async onAssetsDelete() { await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); } diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 468e8c9bdd52c..0c7ae52cac7a8 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -3,11 +3,11 @@ import { DateTime } from 'luxon'; import semver, { SemVer } from 'semver'; import { isDev, serverVersion } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit, OnServerEvent } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; import { SystemMetadataKey } from 'src/enum'; -import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface'; +import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; @@ -37,7 +37,7 @@ export class VersionService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(): Promise { await this.handleVersionCheck(); } @@ -90,8 +90,8 @@ export class VersionService { return JobStatus.SUCCESS; } - @OnServerEvent(ServerEvent.WEBSOCKET_CONNECT) - async onWebsocketConnection({ userId }: ServerEventMap[ServerEvent.WEBSOCKET_CONNECT]) { + @OnEvent({ name: 'websocket.connect' }) + async onWebsocketConnection({ userId }: ArgOf<'websocket.connect'>) { this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion); const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE); if (metadata) { diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 5f4577f4df59b..498dd3456b932 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -120,7 +120,7 @@ export function searchAssetBuilder( } if (withPeople) { - builder.leftJoinAndSelect(`${builder.alias}.person`, 'person'); + builder.leftJoinAndSelect('faces.person', 'person'); } if (withSmartInfo) { diff --git a/server/src/utils/events.ts b/server/src/utils/events.ts index f5b079dea4ef3..fbac5545789df 100644 --- a/server/src/utils/events.ts +++ b/server/src/utils/events.ts @@ -1,6 +1,6 @@ import { ModuleRef, Reflector } from '@nestjs/core'; import _ from 'lodash'; -import { EmitConfig } from 'src/decorators'; +import { EventConfig } from 'src/decorators'; import { MetadataKey } from 'src/enum'; import { EmitEvent, EmitHandler, IEventRepository } from 'src/interfaces/event.interface'; import { services } from 'src/services'; @@ -9,6 +9,7 @@ type Item = { event: T; handler: EmitHandler; priority: number; + server: boolean; label: string; }; @@ -35,14 +36,15 @@ export const setupEventHandlers = (moduleRef: ModuleRef) => { continue; } - const options = reflector.get(MetadataKey.ON_EMIT_CONFIG, handler); - if (!options) { + const event = reflector.get(MetadataKey.EVENT_CONFIG, handler); + if (!event) { continue; } items.push({ - event: options.event, - priority: options.priority || 0, + event: event.name, + priority: event.priority || 0, + server: event.server ?? false, handler: handler.bind(instance), label: `${Service.name}.${handler.name}`, }); @@ -52,8 +54,8 @@ export const setupEventHandlers = (moduleRef: ModuleRef) => { const handlers = _.orderBy(items, ['priority'], ['asc']); // register by priority - for (const { event, handler } of handlers) { - repository.on(event as EmitEvent, handler); + for (const handler of handlers) { + repository.on(handler); } return handlers; diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index ba2f5e10d98cc..50fff31e55e4c 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -39,5 +39,6 @@ export const newAssetRepositoryMock = (): Mocked => { getChangedDeltaSync: vitest.fn(), getDuplicates: vitest.fn(), upsertFile: vitest.fn(), + upsertFiles: vitest.fn(), }; }; diff --git a/server/test/repositories/event.repository.mock.ts b/server/test/repositories/event.repository.mock.ts index a9af627599b60..78c62e95f2c95 100644 --- a/server/test/repositories/event.repository.mock.ts +++ b/server/test/repositories/event.repository.mock.ts @@ -3,7 +3,7 @@ import { Mocked, vitest } from 'vitest'; export const newEventRepositoryMock = (): Mocked => { return { - on: vitest.fn(), + on: vitest.fn() as any, emit: vitest.fn() as any, clientSend: vitest.fn(), clientBroadcast: vitest.fn(), diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index 4c344a986679f..a809b08162347 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -3,8 +3,9 @@ import { Mocked, vitest } from 'vitest'; export const newMediaRepositoryMock = (): Mocked => { return { - generateThumbnail: vitest.fn(), - generateThumbhash: vitest.fn(), + generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()), + generateThumbhash: vitest.fn().mockImplementation(() => Promise.resolve()), + decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }), extract: vitest.fn().mockResolvedValue(false), probe: vitest.fn(), transcode: vitest.fn(), diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index 77e8ccf010671..6ffe7bf97be1c 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -18,7 +18,7 @@ export const newPersonRepositoryMock = (): Mocked => { updateAll: vitest.fn(), delete: vitest.fn(), deleteAll: vitest.fn(), - deleteAllFaces: vitest.fn(), + deleteFaces: vitest.fn(), getStatistics: vitest.fn(), getAllFaces: vitest.fn(), @@ -26,6 +26,7 @@ export const newPersonRepositoryMock = (): Mocked => { getRandomFace: vitest.fn(), reassignFaces: vitest.fn(), + unassignFaces: vitest.fn(), createFaces: vitest.fn(), replaceFaces: vitest.fn(), getFaces: vitest.fn(), diff --git a/server/test/repositories/search.repository.mock.ts b/server/test/repositories/search.repository.mock.ts index 5426316b650ef..be0e753e30577 100644 --- a/server/test/repositories/search.repository.mock.ts +++ b/server/test/repositories/search.repository.mock.ts @@ -7,6 +7,7 @@ export const newSearchRepositoryMock = (): Mocked => { searchSmart: vitest.fn(), searchDuplicates: vitest.fn(), searchFaces: vitest.fn(), + searchRandom: vitest.fn(), upsert: vitest.fn(), searchPlaces: vitest.fn(), getAssetsByCity: vitest.fn(),