mirror of
https://github.com/immich-app/immich.git
synced 2025-05-30 19:55:43 -04:00
Merge branch 'fix/user-removal-from-option-menu-on-the-top-in-shared-album' of https://github.com/Pranav-8bit/immich into fix/user-removal-from-option-menu-on-the-top-in-shared-album
This commit is contained in:
commit
321d1ed1b3
@ -2,37 +2,37 @@
|
|||||||
# Manual edits may be lost in future updates.
|
# Manual edits may be lost in future updates.
|
||||||
|
|
||||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||||
version = "4.41.0"
|
version = "4.43.0"
|
||||||
constraints = "4.41.0"
|
constraints = "4.43.0"
|
||||||
hashes = [
|
hashes = [
|
||||||
"h1:0mc+YrjQrcctGrGYDmzlcqcgSv9MYB74rvMaZylIKC8=",
|
"h1:2kDVLD36BOVgBzI9p0WIQ+xjFfMmjaItA0l8SyZWEPo=",
|
||||||
"h1:0zUx4vk4jOORQqn6xHBF7dO6N6bielFHdJ0mgF4Obn8=",
|
"h1:2sGJDAwFEgO8+3y+2suYO+yrjNOzSsihad0hbM3+jPg=",
|
||||||
"h1:AsIZW3uLFNOZO7kL/K7/Y/S0IYxUV9Hz85NNk/3TTsA=",
|
"h1:A1WPQFcdD+7FrFBFrKcx4CiSr75xSmsO93C0e5NBAeQ=",
|
||||||
"h1:FSgYM4+LHMbX/a4Y1kx7FPPWmXqS3/MQYzvjMJHHHWM=",
|
"h1:BuXs/1ohmF4fWyOErY6vNbm7DaEIfbLSepSiZ2ol9I8=",
|
||||||
"h1:Tx6Nh3BWP1x9L3KK/Eyi+ET0T26g3+jf1jyiuqpNIis=",
|
"h1:QPh+X19oyo808sqdeJaVqahZcQgcG1jCi3DA5zpjz6U=",
|
||||||
"h1:VRI9wu8P43xxfpeTndRwsisLnqncfnmEYMOEH5zH4pQ=",
|
"h1:RI7c7dhSJoIkfou5b8ITRpM5MqsQD3FULj1h/rI4rJk=",
|
||||||
"h1:YxQqmiES/Yanq/VfGqBEqg+VIO7FGhO88aKoWFHyGIg=",
|
"h1:gdI5JTCPjewdGq1bhGAs+V5qCcmJ73N2gtMfuFybJp4=",
|
||||||
"h1:ZWHiaesjgDLKWlfdNj0oKyj/DWdxcfsO6NINu39zfpY=",
|
"h1:h4lnJpCIYZ7dsN9IO2mmwNdWNiQYEPoAEUjLF2sZ5kc=",
|
||||||
"h1:a2aCgDDBz3ccrr8YstIMl7VFnKo1xZAp+rOv59PPJ7U=",
|
"h1:jTaExrX/eR7vGT5wayGqH8ZtXS2zyk0WmD3zbAKFIQU=",
|
||||||
"h1:aRyv8tB6wBAF9lKsLEdiHyCqnK5LfZq0FqMXCcUB4UU=",
|
"h1:l5NKJUOQJ1mHl1eekeXaxUZ+g+8Yv4aGcIN9vuK6GL4=",
|
||||||
"h1:lXpuO7zv2uD2GzPE1ARxznreRAh+QHTc2lAJ7iOoFgY=",
|
"h1:sNbvm66/2vc8B/khyioOO8eNaU8nb89x693AN7fQheU=",
|
||||||
"h1:sA1xq0QNQ4fH8SHXouYNq50xirVD18SamKQwPsBQrrY=",
|
"h1:tXS4g1yE420AU4mvZ7RrYI+yYTutkRID3l+W0gBH4BM=",
|
||||||
"h1:v7sHvKq7oqMYPn47ULHFyIQsKD9o+6Xg/uHbxQUixEw=",
|
"h1:vA+kES7uqmKA9K0U45IXR94jaTQZCHZLCHqMUeGxKMI=",
|
||||||
"h1:wo/x4atWyXuWGlfR6h5nH0YwBAmBwTRY27HtWP8ycLo=",
|
"h1:zV131k79+ob9p4jrLDgztDNvZvt8fvrrzpn0nPikBw8=",
|
||||||
"zh:339d26e06dc6fb299ea8aad9476a60fd65bb1d40631ae8eeb81cddf2dd2bebc8",
|
"zh:006d111d6eafe6eeb5df2f91bd0ca320f979bd71f8cd8c475f10b2bd94acba55",
|
||||||
"zh:3dec2ad96ac2c283fd34ce65781b55c4edbb4d5c5cb53da8e31537176c0ed562",
|
"zh:031fbb5cac23a841dc18e270cbfcd3ce9f4ba504edbd3c78931f7ed9827220a8",
|
||||||
"zh:5f63a5f8080319a2fff09d4d49944829fa708723436520787cfb60725ced80cf",
|
"zh:07a72fe8b55afee99529bf4169ab6abfac5eabcd10968c29101925bcd358b09f",
|
||||||
"zh:67162c28ccea71cb8141ed15c0637e35621354ebe14878e0b75a8f160fc5505d",
|
"zh:0d14727d011c2d9df4c3058f527d2409223449ab48b46cbc86922eb553ef77c1",
|
||||||
"zh:6ac1e07f5347b6395aca690ed22101bb25e957d25f986f760ff673a7adfd5ef6",
|
"zh:155ce1333672d26cd18a5866b0761489d91682beffee58e45c3a1b68e8491d3d",
|
||||||
"zh:70282a723c7b52fcabde2baad41c864ed3a8d69f0c4d27a6b6933cac434cffc6",
|
"zh:35a2a1939a965335b29ebdbfd759d93a97c0f589d9cd218f537dee6f600e3fb9",
|
||||||
|
"zh:52912fe421e7d911431f77788db2ea13836efd65a2e82385adb52c6a84d4ee90",
|
||||||
|
"zh:57374318d9194ea1db08884b0541a9055823d5970ad48f9a57547ac231163007",
|
||||||
|
"zh:5fb942b9e2553c058fe09fe12fb39dd175cd6715bb41c059c1a70df2bfc64dc1",
|
||||||
|
"zh:63cabd2bda201b09b35a3279d1f813ab71394b9b90fc5cf8962a5eba207803bc",
|
||||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||||
"zh:924cd23abc326c6b3914e2cd9c94c7832c2552e1e9ae258fb9fd9aedaa5f7ce7",
|
"zh:978ee67d3d53970a5c474ab40b00adee97f4153b16804a2b6b7ee205ae69d18a",
|
||||||
"zh:a4b75e4c239879296259e7d54f1befbc7fdc16da2d62d1294e9f73add4cae61e",
|
"zh:bbafdbef631b5c80570087817b42b16b1a76d556d692853a71c47fb48663cf00",
|
||||||
"zh:a6ceb08feb63b00c7141783b31e45a154c76fd8cdebbdf371074805f0053572d",
|
"zh:be91b3f2a697cbbb41f65aad2600972d0ede1e962a7d8a00bb3177cb77d86666",
|
||||||
"zh:afae1843f9ba85f2f6d94108c65cf43a457e83531a632d44d863e935160cb2ba",
|
"zh:efe168ad4aaa6156ce5a31d4e50e9d54d38ee5a5888412f9e690c0de5d619683",
|
||||||
"zh:bd6628ce60c778960a5755f7010b7e2cc5c6ff0341a21c175341b28058ec843d",
|
|
||||||
"zh:cd30866a1ff99d72b5fa1699db582fa4f25562e6ab21dcc6870324f3056108e0",
|
|
||||||
"zh:df5924cca691a8220aaaebb5cb55c3d6c32ff0a881f198695eff28155eb12b54",
|
|
||||||
"zh:e78d0696c941aba58df1cb36b8a0d25cd5f3963f01d9338fdbda74db58afdd49",
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
cloudflare = {
|
cloudflare = {
|
||||||
source = "cloudflare/cloudflare"
|
source = "cloudflare/cloudflare"
|
||||||
version = "4.41.0"
|
version = "4.43.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,37 +2,37 @@
|
|||||||
# Manual edits may be lost in future updates.
|
# Manual edits may be lost in future updates.
|
||||||
|
|
||||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||||
version = "4.41.0"
|
version = "4.43.0"
|
||||||
constraints = "4.41.0"
|
constraints = "4.43.0"
|
||||||
hashes = [
|
hashes = [
|
||||||
"h1:0mc+YrjQrcctGrGYDmzlcqcgSv9MYB74rvMaZylIKC8=",
|
"h1:2kDVLD36BOVgBzI9p0WIQ+xjFfMmjaItA0l8SyZWEPo=",
|
||||||
"h1:0zUx4vk4jOORQqn6xHBF7dO6N6bielFHdJ0mgF4Obn8=",
|
"h1:2sGJDAwFEgO8+3y+2suYO+yrjNOzSsihad0hbM3+jPg=",
|
||||||
"h1:AsIZW3uLFNOZO7kL/K7/Y/S0IYxUV9Hz85NNk/3TTsA=",
|
"h1:A1WPQFcdD+7FrFBFrKcx4CiSr75xSmsO93C0e5NBAeQ=",
|
||||||
"h1:FSgYM4+LHMbX/a4Y1kx7FPPWmXqS3/MQYzvjMJHHHWM=",
|
"h1:BuXs/1ohmF4fWyOErY6vNbm7DaEIfbLSepSiZ2ol9I8=",
|
||||||
"h1:Tx6Nh3BWP1x9L3KK/Eyi+ET0T26g3+jf1jyiuqpNIis=",
|
"h1:QPh+X19oyo808sqdeJaVqahZcQgcG1jCi3DA5zpjz6U=",
|
||||||
"h1:VRI9wu8P43xxfpeTndRwsisLnqncfnmEYMOEH5zH4pQ=",
|
"h1:RI7c7dhSJoIkfou5b8ITRpM5MqsQD3FULj1h/rI4rJk=",
|
||||||
"h1:YxQqmiES/Yanq/VfGqBEqg+VIO7FGhO88aKoWFHyGIg=",
|
"h1:gdI5JTCPjewdGq1bhGAs+V5qCcmJ73N2gtMfuFybJp4=",
|
||||||
"h1:ZWHiaesjgDLKWlfdNj0oKyj/DWdxcfsO6NINu39zfpY=",
|
"h1:h4lnJpCIYZ7dsN9IO2mmwNdWNiQYEPoAEUjLF2sZ5kc=",
|
||||||
"h1:a2aCgDDBz3ccrr8YstIMl7VFnKo1xZAp+rOv59PPJ7U=",
|
"h1:jTaExrX/eR7vGT5wayGqH8ZtXS2zyk0WmD3zbAKFIQU=",
|
||||||
"h1:aRyv8tB6wBAF9lKsLEdiHyCqnK5LfZq0FqMXCcUB4UU=",
|
"h1:l5NKJUOQJ1mHl1eekeXaxUZ+g+8Yv4aGcIN9vuK6GL4=",
|
||||||
"h1:lXpuO7zv2uD2GzPE1ARxznreRAh+QHTc2lAJ7iOoFgY=",
|
"h1:sNbvm66/2vc8B/khyioOO8eNaU8nb89x693AN7fQheU=",
|
||||||
"h1:sA1xq0QNQ4fH8SHXouYNq50xirVD18SamKQwPsBQrrY=",
|
"h1:tXS4g1yE420AU4mvZ7RrYI+yYTutkRID3l+W0gBH4BM=",
|
||||||
"h1:v7sHvKq7oqMYPn47ULHFyIQsKD9o+6Xg/uHbxQUixEw=",
|
"h1:vA+kES7uqmKA9K0U45IXR94jaTQZCHZLCHqMUeGxKMI=",
|
||||||
"h1:wo/x4atWyXuWGlfR6h5nH0YwBAmBwTRY27HtWP8ycLo=",
|
"h1:zV131k79+ob9p4jrLDgztDNvZvt8fvrrzpn0nPikBw8=",
|
||||||
"zh:339d26e06dc6fb299ea8aad9476a60fd65bb1d40631ae8eeb81cddf2dd2bebc8",
|
"zh:006d111d6eafe6eeb5df2f91bd0ca320f979bd71f8cd8c475f10b2bd94acba55",
|
||||||
"zh:3dec2ad96ac2c283fd34ce65781b55c4edbb4d5c5cb53da8e31537176c0ed562",
|
"zh:031fbb5cac23a841dc18e270cbfcd3ce9f4ba504edbd3c78931f7ed9827220a8",
|
||||||
"zh:5f63a5f8080319a2fff09d4d49944829fa708723436520787cfb60725ced80cf",
|
"zh:07a72fe8b55afee99529bf4169ab6abfac5eabcd10968c29101925bcd358b09f",
|
||||||
"zh:67162c28ccea71cb8141ed15c0637e35621354ebe14878e0b75a8f160fc5505d",
|
"zh:0d14727d011c2d9df4c3058f527d2409223449ab48b46cbc86922eb553ef77c1",
|
||||||
"zh:6ac1e07f5347b6395aca690ed22101bb25e957d25f986f760ff673a7adfd5ef6",
|
"zh:155ce1333672d26cd18a5866b0761489d91682beffee58e45c3a1b68e8491d3d",
|
||||||
"zh:70282a723c7b52fcabde2baad41c864ed3a8d69f0c4d27a6b6933cac434cffc6",
|
"zh:35a2a1939a965335b29ebdbfd759d93a97c0f589d9cd218f537dee6f600e3fb9",
|
||||||
|
"zh:52912fe421e7d911431f77788db2ea13836efd65a2e82385adb52c6a84d4ee90",
|
||||||
|
"zh:57374318d9194ea1db08884b0541a9055823d5970ad48f9a57547ac231163007",
|
||||||
|
"zh:5fb942b9e2553c058fe09fe12fb39dd175cd6715bb41c059c1a70df2bfc64dc1",
|
||||||
|
"zh:63cabd2bda201b09b35a3279d1f813ab71394b9b90fc5cf8962a5eba207803bc",
|
||||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||||
"zh:924cd23abc326c6b3914e2cd9c94c7832c2552e1e9ae258fb9fd9aedaa5f7ce7",
|
"zh:978ee67d3d53970a5c474ab40b00adee97f4153b16804a2b6b7ee205ae69d18a",
|
||||||
"zh:a4b75e4c239879296259e7d54f1befbc7fdc16da2d62d1294e9f73add4cae61e",
|
"zh:bbafdbef631b5c80570087817b42b16b1a76d556d692853a71c47fb48663cf00",
|
||||||
"zh:a6ceb08feb63b00c7141783b31e45a154c76fd8cdebbdf371074805f0053572d",
|
"zh:be91b3f2a697cbbb41f65aad2600972d0ede1e962a7d8a00bb3177cb77d86666",
|
||||||
"zh:afae1843f9ba85f2f6d94108c65cf43a457e83531a632d44d863e935160cb2ba",
|
"zh:efe168ad4aaa6156ce5a31d4e50e9d54d38ee5a5888412f9e690c0de5d619683",
|
||||||
"zh:bd6628ce60c778960a5755f7010b7e2cc5c6ff0341a21c175341b28058ec843d",
|
|
||||||
"zh:cd30866a1ff99d72b5fa1699db582fa4f25562e6ab21dcc6870324f3056108e0",
|
|
||||||
"zh:df5924cca691a8220aaaebb5cb55c3d6c32ff0a881f198695eff28155eb12b54",
|
|
||||||
"zh:e78d0696c941aba58df1cb36b8a0d25cd5f3963f01d9338fdbda74db58afdd49",
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
cloudflare = {
|
cloudflare = {
|
||||||
source = "cloudflare/cloudflare"
|
source = "cloudflare/cloudflare"
|
||||||
version = "4.41.0"
|
version = "4.43.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,7 @@ The default configuration looks like this:
|
|||||||
"acceptedVideoCodecs": ["h264"],
|
"acceptedVideoCodecs": ["h264"],
|
||||||
"targetAudioCodec": "aac",
|
"targetAudioCodec": "aac",
|
||||||
"acceptedAudioCodecs": ["aac", "mp3", "libopus"],
|
"acceptedAudioCodecs": ["aac", "mp3", "libopus"],
|
||||||
|
"acceptedContainers": ["mov", "ogg", "webm"],
|
||||||
"targetResolution": "720",
|
"targetResolution": "720",
|
||||||
"maxBitrate": "0",
|
"maxBitrate": "0",
|
||||||
"bframes": -1,
|
"bframes": -1,
|
||||||
@ -32,7 +33,8 @@ The default configuration looks like this:
|
|||||||
"preferredHwDevice": "auto",
|
"preferredHwDevice": "auto",
|
||||||
"transcode": "required",
|
"transcode": "required",
|
||||||
"tonemap": "hable",
|
"tonemap": "hable",
|
||||||
"accel": "disabled"
|
"accel": "disabled",
|
||||||
|
"accelDecode": false
|
||||||
},
|
},
|
||||||
"job": {
|
"job": {
|
||||||
"backgroundTask": {
|
"backgroundTask": {
|
||||||
@ -60,10 +62,13 @@ The default configuration looks like this:
|
|||||||
"concurrency": 5
|
"concurrency": 5
|
||||||
},
|
},
|
||||||
"thumbnailGeneration": {
|
"thumbnailGeneration": {
|
||||||
"concurrency": 5
|
"concurrency": 3
|
||||||
},
|
},
|
||||||
"videoConversion": {
|
"videoConversion": {
|
||||||
"concurrency": 1
|
"concurrency": 1
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"concurrency": 5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"logging": {
|
"logging": {
|
||||||
@ -78,40 +83,46 @@ The default configuration looks like this:
|
|||||||
"modelName": "ViT-B-32__openai"
|
"modelName": "ViT-B-32__openai"
|
||||||
},
|
},
|
||||||
"duplicateDetection": {
|
"duplicateDetection": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"maxDistance": 0.03
|
"maxDistance": 0.01
|
||||||
},
|
},
|
||||||
"facialRecognition": {
|
"facialRecognition": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"modelName": "buffalo_l",
|
"modelName": "buffalo_l",
|
||||||
"minScore": 0.7,
|
"minScore": 0.7,
|
||||||
"maxDistance": 0.6,
|
"maxDistance": 0.5,
|
||||||
"minFaces": 3
|
"minFaces": 3
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"map": {
|
"map": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"lightStyle": "",
|
"lightStyle": "https://tiles.immich.cloud/v1/style/light.json",
|
||||||
"darkStyle": ""
|
"darkStyle": "https://tiles.immich.cloud/v1/style/dark.json"
|
||||||
},
|
},
|
||||||
"reverseGeocoding": {
|
"reverseGeocoding": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
"metadata": {
|
||||||
|
"faces": {
|
||||||
|
"import": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"oauth": {
|
"oauth": {
|
||||||
"enabled": false,
|
"autoLaunch": false,
|
||||||
"issuerUrl": "",
|
"autoRegister": true,
|
||||||
|
"buttonText": "Login with OAuth",
|
||||||
"clientId": "",
|
"clientId": "",
|
||||||
"clientSecret": "",
|
"clientSecret": "",
|
||||||
|
"defaultStorageQuota": 0,
|
||||||
|
"enabled": false,
|
||||||
|
"issuerUrl": "",
|
||||||
|
"mobileOverrideEnabled": false,
|
||||||
|
"mobileRedirectUri": "",
|
||||||
"scope": "openid email profile",
|
"scope": "openid email profile",
|
||||||
"signingAlgorithm": "RS256",
|
"signingAlgorithm": "RS256",
|
||||||
|
"profileSigningAlgorithm": "none",
|
||||||
"storageLabelClaim": "preferred_username",
|
"storageLabelClaim": "preferred_username",
|
||||||
"storageQuotaClaim": "immich_quota",
|
"storageQuotaClaim": "immich_quota"
|
||||||
"defaultStorageQuota": 0,
|
|
||||||
"buttonText": "Login with OAuth",
|
|
||||||
"autoRegister": true,
|
|
||||||
"autoLaunch": false,
|
|
||||||
"mobileOverrideEnabled": false,
|
|
||||||
"mobileRedirectUri": ""
|
|
||||||
},
|
},
|
||||||
"passwordLogin": {
|
"passwordLogin": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
@ -122,11 +133,16 @@ The default configuration looks like this:
|
|||||||
"template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}"
|
"template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}"
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
"thumbnailFormat": "webp",
|
"thumbnail": {
|
||||||
"thumbnailSize": 250,
|
"format": "webp",
|
||||||
"previewFormat": "jpeg",
|
"size": 250,
|
||||||
"previewSize": 1440,
|
"quality": 80
|
||||||
"quality": 80,
|
},
|
||||||
|
"preview": {
|
||||||
|
"format": "jpeg",
|
||||||
|
"size": 1440,
|
||||||
|
"quality": 80
|
||||||
|
},
|
||||||
"colorspace": "p3",
|
"colorspace": "p3",
|
||||||
"extractEmbedded": false
|
"extractEmbedded": false
|
||||||
},
|
},
|
||||||
@ -140,23 +156,35 @@ The default configuration looks like this:
|
|||||||
"theme": {
|
"theme": {
|
||||||
"customCss": ""
|
"customCss": ""
|
||||||
},
|
},
|
||||||
"user": {
|
|
||||||
"deleteDelay": 7
|
|
||||||
},
|
|
||||||
"library": {
|
"library": {
|
||||||
"scan": {
|
"scan": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"cronExpression": "0 0 * * *"
|
"cronExpression": "0 0 * * *"
|
||||||
},
|
},
|
||||||
"watch": {
|
"watch": {
|
||||||
"enabled": false,
|
"enabled": false
|
||||||
"usePolling": false,
|
|
||||||
"interval": 10000
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"externalDomain": "",
|
"externalDomain": "",
|
||||||
"loginPageMessage": ""
|
"loginPageMessage": ""
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"smtp": {
|
||||||
|
"enabled": false,
|
||||||
|
"from": "",
|
||||||
|
"replyTo": "",
|
||||||
|
"transport": {
|
||||||
|
"ignoreCert": false,
|
||||||
|
"host": "",
|
||||||
|
"port": 587,
|
||||||
|
"username": "",
|
||||||
|
"password": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"deleteDelay": 7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
@ -76,7 +76,6 @@ describe('/asset', () => {
|
|||||||
let user2Assets: AssetMediaResponseDto[];
|
let user2Assets: AssetMediaResponseDto[];
|
||||||
let locationAsset: AssetMediaResponseDto;
|
let locationAsset: AssetMediaResponseDto;
|
||||||
let ratingAsset: AssetMediaResponseDto;
|
let ratingAsset: AssetMediaResponseDto;
|
||||||
let facesAsset: AssetMediaResponseDto;
|
|
||||||
|
|
||||||
const setupTests = async () => {
|
const setupTests = async () => {
|
||||||
await utils.resetDatabase();
|
await utils.resetDatabase();
|
||||||
@ -236,7 +235,7 @@ describe('/asset', () => {
|
|||||||
await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });
|
await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });
|
||||||
|
|
||||||
// asset faces
|
// asset faces
|
||||||
facesAsset = await utils.createAsset(admin.accessToken, {
|
const facesAsset = await utils.createAsset(admin.accessToken, {
|
||||||
assetData: {
|
assetData: {
|
||||||
filename: 'portrait.jpg',
|
filename: 'portrait.jpg',
|
||||||
bytes: await readFile(facesAssetFilepath),
|
bytes: await readFile(facesAssetFilepath),
|
||||||
|
@ -64,19 +64,19 @@ custom_lint:
|
|||||||
allowed:
|
allowed:
|
||||||
# required / wanted
|
# required / wanted
|
||||||
- lib/entities/*.entity.dart
|
- lib/entities/*.entity.dart
|
||||||
- lib/repositories/{album,asset,backup,exif_info,user}.repository.dart
|
- lib/repositories/{album,asset,backup,database,etag,exif_info,user}.repository.dart
|
||||||
# acceptable exceptions for the time being
|
# acceptable exceptions for the time being (until Isar is fully replaced)
|
||||||
- integration_test/test_utils/general_helper.dart
|
- integration_test/test_utils/general_helper.dart
|
||||||
- lib/main.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/pages/common/album_asset_selection.page.dart
|
||||||
- lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart
|
- lib/routing/router.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/immich_logger.service.dart # not really a service... more a util
|
||||||
- lib/services/{asset,background,backup,immich_logger,sync}.service.dart
|
- lib/utils/{db,migration,renderlist_generator}.dart
|
||||||
- lib/widgets/asset_grid/asset_grid_data_structure.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:
|
- import_rule_openapi:
|
||||||
message: openapi must only be used through ApiRepositories
|
message: openapi must only be used through ApiRepositories
|
||||||
|
@ -588,5 +588,16 @@
|
|||||||
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
|
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
|
||||||
"viewer_remove_from_stack": "Remove from Stack",
|
"viewer_remove_from_stack": "Remove from Stack",
|
||||||
"viewer_stack_use_as_main_asset": "Use as Main Asset",
|
"viewer_stack_use_as_main_asset": "Use as Main Asset",
|
||||||
"viewer_unstack": "Un-Stack"
|
"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"
|
||||||
|
}
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
PODS:
|
PODS:
|
||||||
|
- background_downloader (0.0.1):
|
||||||
|
- Flutter
|
||||||
- connectivity_plus (0.0.1):
|
- connectivity_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- ReachabilitySwift
|
- ReachabilitySwift
|
||||||
@ -99,6 +101,7 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
|
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
|
||||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||||
@ -137,6 +140,8 @@ SPEC REPOS:
|
|||||||
- Toast
|
- Toast
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
background_downloader:
|
||||||
|
:path: ".symlinks/plugins/background_downloader/ios"
|
||||||
connectivity_plus:
|
connectivity_plus:
|
||||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
@ -189,6 +194,7 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
|
background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf
|
||||||
connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
|
connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
|
||||||
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
|
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
|
||||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||||
|
@ -70,19 +70,6 @@ extension AssetListExtension on Iterable<Asset> {
|
|||||||
}
|
}
|
||||||
return this;
|
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<Asset> nonOfflineOnly({
|
|
||||||
void Function()? errorCallback,
|
|
||||||
}) {
|
|
||||||
final bool onlyLive = every((e) => false);
|
|
||||||
if (!onlyLive) {
|
|
||||||
if (errorCallback != null) errorCallback();
|
|
||||||
return where((a) => false);
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SortedByProperty<T> on Iterable<T> {
|
extension SortedByProperty<T> on Iterable<T> {
|
||||||
|
@ -1,21 +1,43 @@
|
|||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/user.entity.dart';
|
import 'package:immich_mobile/entities/user.entity.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/database.interface.dart';
|
||||||
|
|
||||||
abstract interface class IAlbumRepository {
|
abstract interface class IAlbumRepository implements IDatabaseRepository {
|
||||||
Future<int> count({bool? local});
|
|
||||||
Future<Album> create(Album album);
|
Future<Album> create(Album album);
|
||||||
Future<Album?> getById(int id);
|
|
||||||
|
Future<Album?> get(int id);
|
||||||
|
|
||||||
Future<Album?> getByName(
|
Future<Album?> getByName(
|
||||||
String name, {
|
String name, {
|
||||||
bool? shared,
|
bool? shared,
|
||||||
bool? remote,
|
bool? remote,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Future<List<Album>> getAll({
|
||||||
|
bool? shared,
|
||||||
|
bool? remote,
|
||||||
|
int? ownerId,
|
||||||
|
AlbumSort? sortBy,
|
||||||
|
});
|
||||||
|
|
||||||
Future<Album> update(Album album);
|
Future<Album> update(Album album);
|
||||||
|
|
||||||
Future<void> delete(int albumId);
|
Future<void> delete(int albumId);
|
||||||
Future<List<Album>> getAll({bool? shared});
|
|
||||||
|
Future<void> deleteAllLocal();
|
||||||
|
|
||||||
|
Future<int> count({bool? local});
|
||||||
|
|
||||||
|
Future<void> addUsers(Album album, List<User> users);
|
||||||
|
|
||||||
Future<void> removeUsers(Album album, List<User> users);
|
Future<void> removeUsers(Album album, List<User> users);
|
||||||
|
|
||||||
Future<void> addAssets(Album album, List<Asset> assets);
|
Future<void> addAssets(Album album, List<Asset> assets);
|
||||||
|
|
||||||
Future<void> removeAssets(Album album, List<Asset> assets);
|
Future<void> removeAssets(Album album, List<Asset> assets);
|
||||||
|
|
||||||
Future<Album> recalculateMetadata(Album album);
|
Future<Album> recalculateMetadata(Album album);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AlbumSort { remoteId, localId }
|
||||||
|
@ -1,27 +1,62 @@
|
|||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
import 'package:immich_mobile/entities/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/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<Asset?> getByRemoteId(String id);
|
Future<Asset?> getByRemoteId(String id);
|
||||||
Future<List<Asset>> getAllByRemoteId(Iterable<String> ids);
|
|
||||||
Future<List<Asset>> getByAlbum(Album album, {User? notOwnedBy});
|
Future<Asset?> getByOwnerIdChecksum(int ownerId, String checksum);
|
||||||
Future<void> deleteById(List<int> ids);
|
|
||||||
|
Future<List<Asset>> getAllByRemoteId(
|
||||||
|
Iterable<String> ids, {
|
||||||
|
AssetState? state,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<List<Asset?>> getAllByOwnerIdChecksum(
|
||||||
|
List<int> ids,
|
||||||
|
List<String> checksums,
|
||||||
|
);
|
||||||
|
|
||||||
Future<List<Asset>> getAll({
|
Future<List<Asset>> getAll({
|
||||||
required int ownerId,
|
required int ownerId,
|
||||||
bool? remote,
|
AssetState? state,
|
||||||
int limit = 100,
|
AssetSort? sortBy,
|
||||||
|
int? limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Future<List<Asset>> getAllLocal();
|
||||||
|
|
||||||
|
Future<List<Asset>> getByAlbum(
|
||||||
|
Album album, {
|
||||||
|
Iterable<int> notOwnedBy = const [],
|
||||||
|
int? ownerId,
|
||||||
|
AssetState? state,
|
||||||
|
AssetSort? sortBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<Asset> update(Asset asset);
|
||||||
|
|
||||||
Future<List<Asset>> updateAll(List<Asset> assets);
|
Future<List<Asset>> updateAll(List<Asset> assets);
|
||||||
|
|
||||||
|
Future<void> deleteAllByRemoteId(List<String> ids, {AssetState? state});
|
||||||
|
|
||||||
|
Future<void> deleteById(List<int> ids);
|
||||||
|
|
||||||
Future<List<Asset>> getMatches({
|
Future<List<Asset>> getMatches({
|
||||||
required List<Asset> assets,
|
required List<Asset> assets,
|
||||||
required int ownerId,
|
required int ownerId,
|
||||||
bool? remote,
|
AssetState? state,
|
||||||
int limit = 100,
|
int limit = 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<List<DeviceAsset?>> getDeviceAssetsById(List<Object> ids);
|
Future<List<DeviceAsset?>> getDeviceAssetsById(List<Object> ids);
|
||||||
|
|
||||||
Future<void> upsertDeviceAssets(List<DeviceAsset> deviceAssets);
|
Future<void> upsertDeviceAssets(List<DeviceAsset> deviceAssets);
|
||||||
|
|
||||||
|
Future<void> upsertDuplicatedAssets(Iterable<String> duplicatedAssets);
|
||||||
|
|
||||||
|
Future<List<String>> getAllDuplicatedAssetIds();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AssetSort { checksum, ownerIdChecksum }
|
||||||
|
@ -1,5 +1,16 @@
|
|||||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/database.interface.dart';
|
||||||
|
|
||||||
|
abstract interface class IBackupRepository implements IDatabaseRepository {
|
||||||
|
Future<List<BackupAlbum>> getAll({BackupAlbumSort? sort});
|
||||||
|
|
||||||
abstract interface class IBackupRepository {
|
|
||||||
Future<List<String>> getIdsBySelection(BackupSelection backup);
|
Future<List<String>> getIdsBySelection(BackupSelection backup);
|
||||||
|
|
||||||
|
Future<List<BackupAlbum>> getAllBySelection(BackupSelection backup);
|
||||||
|
|
||||||
|
Future<void> updateAll(List<BackupAlbum> backupAlbums);
|
||||||
|
|
||||||
|
Future<void> deleteAll(List<int> ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum BackupAlbumSort { id }
|
||||||
|
3
mobile/lib/interfaces/database.interface.dart
Normal file
3
mobile/lib/interfaces/database.interface.dart
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
abstract interface class IDatabaseRepository {
|
||||||
|
Future<T> transaction<T>(Future<T> Function() callback);
|
||||||
|
}
|
14
mobile/lib/interfaces/download.interface.dart
Normal file
14
mobile/lib/interfaces/download.interface.dart
Normal file
@ -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<List<TaskRecord>> getLiveVideoTasks();
|
||||||
|
Future<bool> download(DownloadTask task);
|
||||||
|
Future<bool> cancel(String id);
|
||||||
|
Future<void> deleteAllTrackingRecords();
|
||||||
|
Future<void> deleteRecordsWithIds(List<String> id);
|
||||||
|
}
|
14
mobile/lib/interfaces/etag.interface.dart
Normal file
14
mobile/lib/interfaces/etag.interface.dart
Normal file
@ -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<ETag?> get(int id);
|
||||||
|
|
||||||
|
Future<ETag?> getById(String id);
|
||||||
|
|
||||||
|
Future<List<String>> getAllIds();
|
||||||
|
|
||||||
|
Future<void> upsertAll(List<ETag> etags);
|
||||||
|
|
||||||
|
Future<void> deleteByIds(List<String> ids);
|
||||||
|
}
|
@ -1,9 +1,12 @@
|
|||||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
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<ExifInfo?> get(int id);
|
Future<ExifInfo?> get(int id);
|
||||||
|
|
||||||
Future<ExifInfo> update(ExifInfo exifInfo);
|
Future<ExifInfo> update(ExifInfo exifInfo);
|
||||||
|
|
||||||
|
Future<List<ExifInfo>> updateAll(List<ExifInfo> exifInfos);
|
||||||
|
|
||||||
Future<void> delete(int id);
|
Future<void> delete(int id);
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,23 @@
|
|||||||
import 'package:immich_mobile/entities/user.entity.dart';
|
import 'package:immich_mobile/entities/user.entity.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/database.interface.dart';
|
||||||
|
|
||||||
abstract interface class IUserRepository {
|
abstract interface class IUserRepository implements IDatabaseRepository {
|
||||||
Future<List<User>> getByIds(List<String> ids);
|
|
||||||
Future<User?> get(String id);
|
Future<User?> get(String id);
|
||||||
Future<List<User>> getAll({bool self = true});
|
|
||||||
|
Future<List<User>> getByIds(List<String> ids);
|
||||||
|
|
||||||
|
Future<List<User>> getAll({bool self = true, UserSort? sortBy});
|
||||||
|
|
||||||
|
/// Returns all users whose assets can be accessed (self+partners)
|
||||||
|
Future<List<User>> getAllAccessible();
|
||||||
|
|
||||||
|
Future<List<User>> upsertAll(List<User> users);
|
||||||
|
|
||||||
Future<User> update(User user);
|
Future<User> update(User user);
|
||||||
|
|
||||||
|
Future<void> deleteById(List<int> ids);
|
||||||
|
|
||||||
|
Future<User> me();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum UserSort { id }
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:background_downloader/background_downloader.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
@ -9,6 +10,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_displaymode/flutter_displaymode.dart';
|
import 'package:flutter_displaymode/flutter_displaymode.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.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:timezone/data/latest.dart';
|
||||||
import 'package:immich_mobile/constants/locales.dart';
|
import 'package:immich_mobile/constants/locales.dart';
|
||||||
import 'package:immich_mobile/services/background.service.dart';
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
@ -72,7 +74,6 @@ Future<void> initApp() async {
|
|||||||
var log = Logger("ImmichErrorLogger");
|
var log = Logger("ImmichErrorLogger");
|
||||||
|
|
||||||
FlutterError.onError = (details) {
|
FlutterError.onError = (details) {
|
||||||
debugPrint("FlutterError - Catch all: $details");
|
|
||||||
FlutterError.presentError(details);
|
FlutterError.presentError(details);
|
||||||
log.severe(
|
log.severe(
|
||||||
'FlutterError - Catch all',
|
'FlutterError - Catch all',
|
||||||
@ -82,11 +83,29 @@ Future<void> initApp() async {
|
|||||||
};
|
};
|
||||||
|
|
||||||
PlatformDispatcher.instance.onError = (error, stack) {
|
PlatformDispatcher.instance.onError = (error, stack) {
|
||||||
|
debugPrint("FlutterError - Catch all: $error");
|
||||||
log.severe('PlatformDispatcher - Catch all', error, stack);
|
log.severe('PlatformDispatcher - Catch all', error, stack);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
initializeTimeZones();
|
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<Isar> loadDb() async {
|
Future<Isar> loadDb() async {
|
||||||
@ -188,8 +207,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var router = ref.watch(appRouterProvider);
|
final router = ref.watch(appRouterProvider);
|
||||||
var immichTheme = ref.watch(immichThemeProvider);
|
final immichTheme = ref.watch(immichThemeProvider);
|
||||||
|
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
localizationsDelegates: context.localizationDelegates,
|
localizationsDelegates: context.localizationDelegates,
|
||||||
|
@ -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<String, dynamic> toMap() {
|
|
||||||
final result = <String, dynamic>{};
|
|
||||||
|
|
||||||
result.addAll({'downloadAssetStatus': downloadAssetStatus.index});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
factory AssetViewerPageState.fromMap(Map<String, dynamic> 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;
|
|
||||||
}
|
|
109
mobile/lib/models/download/download_state.model.dart
Normal file
109
mobile/lib/models/download/download_state.model.dart
Normal file
@ -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<String, dynamic> toMap() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'fileName': fileName,
|
||||||
|
'progress': progress,
|
||||||
|
'status': status.index,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory DownloadInfo.fromMap(Map<String, dynamic> 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<String, dynamic>);
|
||||||
|
|
||||||
|
@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<String, DownloadInfo> taskProgress;
|
||||||
|
final bool showProgress;
|
||||||
|
DownloadState({
|
||||||
|
required this.downloadStatus,
|
||||||
|
required this.taskProgress,
|
||||||
|
required this.showProgress,
|
||||||
|
});
|
||||||
|
|
||||||
|
DownloadState copyWith({
|
||||||
|
TaskStatus? downloadStatus,
|
||||||
|
Map<String, DownloadInfo>? 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;
|
||||||
|
}
|
60
mobile/lib/models/download/livephotos_medatada.model.dart
Normal file
60
mobile/lib/models/download/livephotos_medatada.model.dart
Normal file
@ -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<String, dynamic> toMap() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'part': part.index,
|
||||||
|
'id': id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory LivePhotosMetadata.fromMap(Map<String, dynamic> 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<String, dynamic>);
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
150
mobile/lib/pages/common/download_panel.dart
Normal file
150
mobile/lib/pages/common/download_panel.dart
Normal file
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -11,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.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/pages/common/video_viewer.page.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
||||||
@ -421,6 +422,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const DownloadPanel(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -275,28 +275,14 @@ class AssetNotifier extends StateNotifier<bool> {
|
|||||||
return isSuccess ? remote.toList() : [];
|
return isSuccess ? remote.toList() : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleFavorite(List<Asset> assets, [bool? status]) async {
|
Future<void> toggleFavorite(List<Asset> assets, [bool? status]) {
|
||||||
status ??= !assets.every((a) => a.isFavorite);
|
status ??= !assets.every((a) => a.isFavorite);
|
||||||
final newAssets = await _assetService.changeFavoriteStatus(assets, status);
|
return _assetService.changeFavoriteStatus(assets, status);
|
||||||
for (Asset? newAsset in newAssets) {
|
|
||||||
if (newAsset == null) {
|
|
||||||
log.severe("Change favorite status failed for asset");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleArchive(List<Asset> assets, [bool? status]) async {
|
Future<void> toggleArchive(List<Asset> assets, [bool? status]) {
|
||||||
status ??= !assets.every((a) => a.isArchived);
|
status ??= !assets.every((a) => a.isArchived);
|
||||||
final newAssets = await _assetService.changeArchiveStatus(assets, status);
|
return _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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
191
mobile/lib/providers/asset_viewer/download.provider.dart
Normal file
191
mobile/lib/providers/asset_viewer/download.provider.dart
Normal file
@ -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<DownloadState> {
|
||||||
|
final DownloadService _downloadService;
|
||||||
|
final ShareService _shareService;
|
||||||
|
|
||||||
|
DownloadStateNotifier(
|
||||||
|
this._downloadService,
|
||||||
|
this._shareService,
|
||||||
|
) : super(
|
||||||
|
DownloadState(
|
||||||
|
downloadStatus: TaskStatus.complete,
|
||||||
|
showProgress: false,
|
||||||
|
taskProgress: <String, DownloadInfo>{},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
_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: <String, DownloadInfo>{}
|
||||||
|
..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: <String, DownloadInfo>{}
|
||||||
|
..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: <String, DownloadInfo>{}
|
||||||
|
..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: <String, DownloadInfo>{}
|
||||||
|
..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<DownloadStateNotifier, DownloadState>(
|
||||||
|
((ref) => DownloadStateNotifier(
|
||||||
|
ref.watch(downloadServiceProvider),
|
||||||
|
ref.watch(shareServiceProvider),
|
||||||
|
)),
|
||||||
|
);
|
@ -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<AssetViewerPageState> {
|
|
||||||
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<ImageViewerStateNotifier, AssetViewerPageState>(
|
|
||||||
((ref) => ImageViewerStateNotifier(
|
|
||||||
ref.watch(imageViewerServiceProvider),
|
|
||||||
ref.watch(shareServiceProvider),
|
|
||||||
ref.watch(albumServiceProvider),
|
|
||||||
)),
|
|
||||||
);
|
|
@ -7,6 +7,7 @@ import 'package:flutter/widgets.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
import 'package:immich_mobile/interfaces/album_media.interface.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/interfaces/file_media.interface.dart';
|
||||||
import 'package:immich_mobile/models/backup/available_album.model.dart';
|
import 'package:immich_mobile/models/backup/available_album.model.dart';
|
||||||
import 'package:immich_mobile/entities/backup_album.entity.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/models/backup/success_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/providers/backup/error_backup_list.provider.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/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/repositories/file_media.repository.dart';
|
||||||
import 'package:immich_mobile/services/background.service.dart';
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
import 'package:immich_mobile/services/backup.service.dart';
|
import 'package:immich_mobile/services/backup.service.dart';
|
||||||
@ -45,6 +47,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
this._db,
|
this._db,
|
||||||
this._albumMediaRepository,
|
this._albumMediaRepository,
|
||||||
this._fileMediaRepository,
|
this._fileMediaRepository,
|
||||||
|
this._backupRepository,
|
||||||
this.ref,
|
this.ref,
|
||||||
) : super(
|
) : super(
|
||||||
BackUpState(
|
BackUpState(
|
||||||
@ -95,6 +98,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
final Isar _db;
|
final Isar _db;
|
||||||
final IAlbumMediaRepository _albumMediaRepository;
|
final IAlbumMediaRepository _albumMediaRepository;
|
||||||
final IFileMediaRepository _fileMediaRepository;
|
final IFileMediaRepository _fileMediaRepository;
|
||||||
|
final IBackupRepository _backupRepository;
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
|
||||||
///
|
///
|
||||||
@ -255,9 +259,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
state = state.copyWith(availableAlbums: availableAlbums);
|
state = state.copyWith(availableAlbums: availableAlbums);
|
||||||
|
|
||||||
final List<BackupAlbum> excludedBackupAlbums =
|
final List<BackupAlbum> excludedBackupAlbums =
|
||||||
await _backupService.excludedAlbumsQuery().findAll();
|
await _backupRepository.getAllBySelection(BackupSelection.exclude);
|
||||||
final List<BackupAlbum> selectedBackupAlbums =
|
final List<BackupAlbum> selectedBackupAlbums =
|
||||||
await _backupService.selectedAlbumsQuery().findAll();
|
await _backupRepository.getAllBySelection(BackupSelection.select);
|
||||||
|
|
||||||
final Set<AvailableAlbum> selectedAlbums = {};
|
final Set<AvailableAlbum> selectedAlbums = {};
|
||||||
for (final BackupAlbum ba in selectedBackupAlbums) {
|
for (final BackupAlbum ba in selectedBackupAlbums) {
|
||||||
@ -767,6 +771,7 @@ final backupProvider =
|
|||||||
ref.watch(dbProvider),
|
ref.watch(dbProvider),
|
||||||
ref.watch(albumMediaRepositoryProvider),
|
ref.watch(albumMediaRepositoryProvider),
|
||||||
ref.watch(fileMediaRepositoryProvider),
|
ref.watch(fileMediaRepositoryProvider),
|
||||||
|
ref.watch(backupRepositoryProvider),
|
||||||
ref,
|
ref,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -6,8 +6,10 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/backup_candidate.model.dart';
|
||||||
import 'package:immich_mobile/models/backup/success_upload_asset.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/repositories/file_media.repository.dart';
|
||||||
import 'package:immich_mobile/services/background.service.dart';
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
import 'package:immich_mobile/models/backup/backup_state.model.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/services/local_notification.service.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
import 'package:immich_mobile/utils/backup_progress.dart';
|
import 'package:immich_mobile/utils/backup_progress.dart';
|
||||||
import 'package:isar/isar.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||||
@ -36,6 +37,7 @@ final manualUploadProvider =
|
|||||||
ref.watch(localNotificationService),
|
ref.watch(localNotificationService),
|
||||||
ref.watch(backupProvider.notifier),
|
ref.watch(backupProvider.notifier),
|
||||||
ref.watch(backupServiceProvider),
|
ref.watch(backupServiceProvider),
|
||||||
|
ref.watch(backupRepositoryProvider),
|
||||||
ref,
|
ref,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -45,12 +47,14 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
|
|||||||
final LocalNotificationService _localNotificationService;
|
final LocalNotificationService _localNotificationService;
|
||||||
final BackupNotifier _backupProvider;
|
final BackupNotifier _backupProvider;
|
||||||
final BackupService _backupService;
|
final BackupService _backupService;
|
||||||
|
final BackupRepository _backupRepository;
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
|
||||||
ManualUploadNotifier(
|
ManualUploadNotifier(
|
||||||
this._localNotificationService,
|
this._localNotificationService,
|
||||||
this._backupProvider,
|
this._backupProvider,
|
||||||
this._backupService,
|
this._backupService,
|
||||||
|
this._backupRepository,
|
||||||
this.ref,
|
this.ref,
|
||||||
) : super(
|
) : super(
|
||||||
ManualUploadState(
|
ManualUploadState(
|
||||||
@ -206,9 +210,9 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final selectedBackupAlbums =
|
final selectedBackupAlbums =
|
||||||
_backupService.selectedAlbumsQuery().findAllSync();
|
await _backupRepository.getAllBySelection(BackupSelection.select);
|
||||||
final excludedBackupAlbums =
|
final excludedBackupAlbums =
|
||||||
_backupService.excludedAlbumsQuery().findAllSync();
|
await _backupRepository.getAllBySelection(BackupSelection.exclude);
|
||||||
|
|
||||||
// Get candidates from selected albums and excluded albums
|
// Get candidates from selected albums and excluded albums
|
||||||
Set<BackupCandidate> candidates =
|
Set<BackupCandidate> candidates =
|
||||||
|
@ -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/interfaces/activity_api.interface.dart';
|
||||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.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';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
final activityApiRepositoryProvider = Provider(
|
final activityApiRepositoryProvider = Provider(
|
||||||
(ref) => ActivityApiRepository(ref.watch(apiServiceProvider).activitiesApi),
|
(ref) => ActivityApiRepository(ref.watch(apiServiceProvider).activitiesApi),
|
||||||
);
|
);
|
||||||
|
|
||||||
class ActivityApiRepository extends BaseApiRepository
|
class ActivityApiRepository extends ApiRepository
|
||||||
implements IActivityApiRepository {
|
implements IActivityApiRepository {
|
||||||
final ActivitiesApi _api;
|
final ActivitiesApi _api;
|
||||||
|
|
||||||
|
@ -4,32 +4,36 @@ import 'package:immich_mobile/entities/asset.entity.dart';
|
|||||||
import 'package:immich_mobile/entities/user.entity.dart';
|
import 'package:immich_mobile/entities/user.entity.dart';
|
||||||
import 'package:immich_mobile/interfaces/album.interface.dart';
|
import 'package:immich_mobile/interfaces/album.interface.dart';
|
||||||
import 'package:immich_mobile/providers/db.provider.dart';
|
import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/database.repository.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
final albumRepositoryProvider =
|
final albumRepositoryProvider =
|
||||||
Provider((ref) => AlbumRepository(ref.watch(dbProvider)));
|
Provider((ref) => AlbumRepository(ref.watch(dbProvider)));
|
||||||
|
|
||||||
class AlbumRepository implements IAlbumRepository {
|
class AlbumRepository extends DatabaseRepository implements IAlbumRepository {
|
||||||
final Isar _db;
|
AlbumRepository(super.db);
|
||||||
|
|
||||||
AlbumRepository(
|
|
||||||
this._db,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<int> count({bool? local}) {
|
Future<int> count({bool? local}) {
|
||||||
if (local == true) return _db.albums.where().localIdIsNotNull().count();
|
final baseQuery = db.albums.where();
|
||||||
if (local == false) return _db.albums.where().remoteIdIsNotNull().count();
|
final QueryBuilder<Album, Album, QAfterWhereClause> query;
|
||||||
return _db.albums.count();
|
switch (local) {
|
||||||
|
case null:
|
||||||
|
query = baseQuery.noOp();
|
||||||
|
case true:
|
||||||
|
query = baseQuery.localIdIsNotNull();
|
||||||
|
case false:
|
||||||
|
query = baseQuery.remoteIdIsNotNull();
|
||||||
|
}
|
||||||
|
return query.count();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Album> create(Album album) =>
|
Future<Album> create(Album album) => txn(() => db.albums.store(album));
|
||||||
_db.writeTxn(() => _db.albums.store(album));
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Album?> getByName(String name, {bool? shared, bool? remote}) {
|
Future<Album?> getByName(String name, {bool? shared, bool? remote}) {
|
||||||
var query = _db.albums.filter().nameEqualTo(name);
|
var query = db.albums.filter().nameEqualTo(name);
|
||||||
if (shared != null) {
|
if (shared != null) {
|
||||||
query = query.sharedEqualTo(shared);
|
query = query.sharedEqualTo(shared);
|
||||||
}
|
}
|
||||||
@ -42,37 +46,61 @@ class AlbumRepository implements IAlbumRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Album> update(Album album) =>
|
Future<Album> update(Album album) => txn(() => db.albums.store(album));
|
||||||
_db.writeTxn(() => _db.albums.store(album));
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> delete(int albumId) =>
|
Future<void> delete(int albumId) => txn(() => db.albums.delete(albumId));
|
||||||
_db.writeTxn(() => _db.albums.delete(albumId));
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Album>> getAll({bool? shared}) {
|
Future<List<Album>> getAll({
|
||||||
final baseQuery = _db.albums.filter();
|
bool? shared,
|
||||||
QueryBuilder<Album, Album, QAfterFilterCondition>? query;
|
bool? remote,
|
||||||
if (shared != null) {
|
int? ownerId,
|
||||||
query = baseQuery.sharedEqualTo(true);
|
AlbumSort? sortBy,
|
||||||
|
}) {
|
||||||
|
final baseQuery = db.albums.where();
|
||||||
|
final QueryBuilder<Album, Album, QAfterWhereClause> 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<Album, Album, QAfterFilterCondition> filterQuery =
|
||||||
|
afterWhere.filter().noOp();
|
||||||
|
if (shared != null) {
|
||||||
|
filterQuery = filterQuery.sharedEqualTo(true);
|
||||||
|
}
|
||||||
|
if (ownerId != null) {
|
||||||
|
filterQuery = filterQuery.owner((q) => q.isarIdEqualTo(ownerId));
|
||||||
|
}
|
||||||
|
final QueryBuilder<Album, Album, QAfterSortBy> query;
|
||||||
|
switch (sortBy) {
|
||||||
|
case null:
|
||||||
|
query = filterQuery.noOp();
|
||||||
|
case AlbumSort.remoteId:
|
||||||
|
query = filterQuery.sortByRemoteId();
|
||||||
|
case AlbumSort.localId:
|
||||||
|
query = filterQuery.sortByLocalId();
|
||||||
|
}
|
||||||
|
return query.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Album?> getById(int id) => _db.albums.get(id);
|
Future<Album?> get(int id) => db.albums.get(id);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> removeUsers(Album album, List<User> users) =>
|
Future<void> removeUsers(Album album, List<User> users) =>
|
||||||
_db.writeTxn(() => album.sharedUsers.update(unlink: users));
|
txn(() => album.sharedUsers.update(unlink: users));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> addAssets(Album album, List<Asset> assets) =>
|
Future<void> addAssets(Album album, List<Asset> assets) =>
|
||||||
_db.writeTxn(() => album.assets.update(link: assets));
|
txn(() => album.assets.update(link: assets));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> removeAssets(Album album, List<Asset> assets) =>
|
Future<void> removeAssets(Album album, List<Asset> assets) =>
|
||||||
_db.writeTxn(() => album.assets.update(unlink: assets));
|
txn(() => album.assets.update(unlink: assets));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Album> recalculateMetadata(Album album) async {
|
Future<Album> recalculateMetadata(Album album) async {
|
||||||
@ -82,4 +110,12 @@ class AlbumRepository implements IAlbumRepository {
|
|||||||
await album.assets.filter().updatedAtProperty().max();
|
await album.assets.filter().updatedAtProperty().max();
|
||||||
return album;
|
return album;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> addUsers(Album album, List<User> users) =>
|
||||||
|
txn(() => album.sharedUsers.update(link: users));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteAllLocal() =>
|
||||||
|
txn(() => db.albums.where().localIdIsNotNull().deleteAll());
|
||||||
}
|
}
|
||||||
|
@ -4,15 +4,14 @@ import 'package:immich_mobile/entities/asset.entity.dart';
|
|||||||
import 'package:immich_mobile/entities/user.entity.dart';
|
import 'package:immich_mobile/entities/user.entity.dart';
|
||||||
import 'package:immich_mobile/interfaces/album_api.interface.dart';
|
import 'package:immich_mobile/interfaces/album_api.interface.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.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';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
final albumApiRepositoryProvider = Provider(
|
final albumApiRepositoryProvider = Provider(
|
||||||
(ref) => AlbumApiRepository(ref.watch(apiServiceProvider).albumsApi),
|
(ref) => AlbumApiRepository(ref.watch(apiServiceProvider).albumsApi),
|
||||||
);
|
);
|
||||||
|
|
||||||
class AlbumApiRepository extends BaseApiRepository
|
class AlbumApiRepository extends ApiRepository implements IAlbumApiRepository {
|
||||||
implements IAlbumApiRepository {
|
|
||||||
final AlbumsApi _api;
|
final AlbumsApi _api;
|
||||||
|
|
||||||
AlbumApiRepository(this._api);
|
AlbumApiRepository(this._api);
|
||||||
@ -26,7 +25,7 @@ class AlbumApiRepository extends BaseApiRepository
|
|||||||
@override
|
@override
|
||||||
Future<List<Album>> getAll({bool? shared}) async {
|
Future<List<Album>> getAll({bool? shared}) async {
|
||||||
final dtos = await checkNull(_api.getAllAlbums(shared: shared));
|
final dtos = await checkNull(_api.getAllAlbums(shared: shared));
|
||||||
return dtos.map(_toAlbum).toList().cast();
|
return dtos.map(_toAlbum).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:immich_mobile/constants/errors.dart';
|
import 'package:immich_mobile/constants/errors.dart';
|
||||||
|
|
||||||
abstract class BaseApiRepository {
|
abstract class ApiRepository {
|
||||||
@protected
|
|
||||||
Future<T> checkNull<T>(Future<T?> future) async {
|
Future<T> checkNull<T>(Future<T?> future) async {
|
||||||
final response = await future;
|
final response = await future;
|
||||||
if (response == null) throw NoResponseDtoError();
|
if (response == null) throw NoResponseDtoError();
|
@ -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/android_device_asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/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/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/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/interfaces/asset.interface.dart';
|
||||||
import 'package:immich_mobile/providers/db.provider.dart';
|
import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/database.repository.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
final assetRepositoryProvider =
|
final assetRepositoryProvider =
|
||||||
Provider((ref) => AssetRepository(ref.watch(dbProvider)));
|
Provider((ref) => AssetRepository(ref.watch(dbProvider)));
|
||||||
|
|
||||||
class AssetRepository implements IAssetRepository {
|
class AssetRepository extends DatabaseRepository implements IAssetRepository {
|
||||||
final Isar _db;
|
AssetRepository(super.db);
|
||||||
|
|
||||||
AssetRepository(
|
|
||||||
this._db,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Asset>> getByAlbum(Album album, {User? notOwnedBy}) {
|
Future<List<Asset>> getByAlbum(
|
||||||
|
Album album, {
|
||||||
|
Iterable<int> notOwnedBy = const [],
|
||||||
|
int? ownerId,
|
||||||
|
AssetState? state,
|
||||||
|
AssetSort? sortBy,
|
||||||
|
}) {
|
||||||
var query = album.assets.filter();
|
var query = album.assets.filter();
|
||||||
if (notOwnedBy != null) {
|
if (notOwnedBy.length == 1) {
|
||||||
query = query.not().ownerIdEqualTo(notOwnedBy.isarId);
|
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<Asset, Asset, QAfterSortBy> 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
|
@override
|
||||||
Future<void> deleteById(List<int> ids) =>
|
Future<void> deleteById(List<int> ids) => txn(() async {
|
||||||
_db.writeTxn(() => _db.assets.deleteAll(ids));
|
await db.assets.deleteAll(ids);
|
||||||
|
await db.exifInfos.deleteAll(ids);
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Asset?> getByRemoteId(String id) => _db.assets.getByRemoteId(id);
|
Future<Asset?> getByRemoteId(String id) => db.assets.getByRemoteId(id);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Asset>> getAllByRemoteId(Iterable<String> ids) =>
|
Future<List<Asset>> getAllByRemoteId(
|
||||||
_db.assets.getAllByRemoteId(ids);
|
Iterable<String> ids, {
|
||||||
|
AssetState? state,
|
||||||
|
}) =>
|
||||||
|
_getAllByRemoteIdImpl(ids, state).findAll();
|
||||||
|
|
||||||
|
QueryBuilder<Asset, Asset, QAfterFilterCondition> _getAllByRemoteIdImpl(
|
||||||
|
Iterable<String> 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
|
@override
|
||||||
Future<List<Asset>> getAll({
|
Future<List<Asset>> getAll({
|
||||||
required int ownerId,
|
required int ownerId,
|
||||||
bool? remote,
|
AssetState? state,
|
||||||
int limit = 100,
|
AssetSort? sortBy,
|
||||||
|
int? limit,
|
||||||
}) {
|
}) {
|
||||||
if (remote == null) {
|
final baseQuery = db.assets.where();
|
||||||
return _db.assets
|
final QueryBuilder<Asset, Asset, QAfterFilterCondition> filteredQuery;
|
||||||
.where()
|
switch (state) {
|
||||||
.ownerIdEqualToAnyChecksum(ownerId)
|
case null:
|
||||||
.limit(limit)
|
filteredQuery = baseQuery.ownerIdEqualToAnyChecksum(ownerId).noOp();
|
||||||
.findAll();
|
case AssetState.local:
|
||||||
}
|
filteredQuery = baseQuery
|
||||||
final QueryBuilder<Asset, Asset, QAfterFilterCondition> query;
|
.remoteIdIsNull()
|
||||||
if (remote) {
|
.filter()
|
||||||
query = _db.assets
|
.localIdIsNotNull()
|
||||||
.where()
|
.ownerIdEqualTo(ownerId);
|
||||||
.localIdIsNull()
|
case AssetState.remote:
|
||||||
.filter()
|
filteredQuery = baseQuery
|
||||||
.remoteIdIsNotNull()
|
.localIdIsNull()
|
||||||
.ownerIdEqualTo(ownerId);
|
.filter()
|
||||||
} else {
|
.remoteIdIsNotNull()
|
||||||
query = _db.assets
|
.ownerIdEqualTo(ownerId);
|
||||||
.where()
|
case AssetState.merged:
|
||||||
.remoteIdIsNull()
|
filteredQuery = baseQuery
|
||||||
.filter()
|
.ownerIdEqualToAnyChecksum(ownerId)
|
||||||
.localIdIsNotNull()
|
.filter()
|
||||||
.ownerIdEqualTo(ownerId);
|
.remoteIdIsNotNull()
|
||||||
|
.localIdIsNotNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
return query.limit(limit).findAll();
|
final QueryBuilder<Asset, Asset, QAfterSortBy> 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
|
@override
|
||||||
Future<List<Asset>> updateAll(List<Asset> assets) async {
|
Future<List<Asset>> updateAll(List<Asset> assets) async {
|
||||||
await _db.writeTxn(() => _db.assets.putAll(assets));
|
await txn(() => db.assets.putAll(assets));
|
||||||
return assets;
|
return assets;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,16 +151,20 @@ class AssetRepository implements IAssetRepository {
|
|||||||
Future<List<Asset>> getMatches({
|
Future<List<Asset>> getMatches({
|
||||||
required List<Asset> assets,
|
required List<Asset> assets,
|
||||||
required int ownerId,
|
required int ownerId,
|
||||||
bool? remote,
|
AssetState? state,
|
||||||
int limit = 100,
|
int limit = 100,
|
||||||
}) {
|
}) {
|
||||||
|
final baseQuery = db.assets.where();
|
||||||
final QueryBuilder<Asset, Asset, QAfterFilterCondition> query;
|
final QueryBuilder<Asset, Asset, QAfterFilterCondition> query;
|
||||||
if (remote == null) {
|
switch (state) {
|
||||||
query = _db.assets.filter().remoteIdIsNotNull().or().localIdIsNotNull();
|
case null:
|
||||||
} else if (remote) {
|
query = baseQuery.noOp();
|
||||||
query = _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull();
|
case AssetState.local:
|
||||||
} else {
|
query = baseQuery.remoteIdIsNull().filter().localIdIsNotNull();
|
||||||
query = _db.assets.where().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);
|
return _getMatchesImpl(query, ownerId, assets, limit);
|
||||||
}
|
}
|
||||||
@ -101,16 +172,50 @@ class AssetRepository implements IAssetRepository {
|
|||||||
@override
|
@override
|
||||||
Future<List<DeviceAsset?>> getDeviceAssetsById(List<Object> ids) =>
|
Future<List<DeviceAsset?>> getDeviceAssetsById(List<Object> ids) =>
|
||||||
Platform.isAndroid
|
Platform.isAndroid
|
||||||
? _db.androidDeviceAssets.getAll(ids.cast())
|
? db.androidDeviceAssets.getAll(ids.cast())
|
||||||
: _db.iOSDeviceAssets.getAllById(ids.cast());
|
: db.iOSDeviceAssets.getAllById(ids.cast());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> upsertDeviceAssets(List<DeviceAsset> deviceAssets) =>
|
Future<void> upsertDeviceAssets(List<DeviceAsset> deviceAssets) => txn(
|
||||||
_db.writeTxn(
|
|
||||||
() => Platform.isAndroid
|
() => Platform.isAndroid
|
||||||
? _db.androidDeviceAssets.putAll(deviceAssets.cast())
|
? db.androidDeviceAssets.putAll(deviceAssets.cast())
|
||||||
: _db.iOSDeviceAssets.putAll(deviceAssets.cast()),
|
: db.iOSDeviceAssets.putAll(deviceAssets.cast()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Asset> update(Asset asset) async {
|
||||||
|
await txn(() => asset.put(db));
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> upsertDuplicatedAssets(Iterable<String> duplicatedAssets) => txn(
|
||||||
|
() => db.duplicatedAssets
|
||||||
|
.putAll(duplicatedAssets.map(DuplicatedAsset.new).toList()),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> getAllDuplicatedAssetIds() =>
|
||||||
|
db.duplicatedAssets.where().idProperty().findAll();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Asset?> getByOwnerIdChecksum(int ownerId, String checksum) =>
|
||||||
|
db.assets.getByOwnerIdChecksum(ownerId, checksum);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Asset?>> getAllByOwnerIdChecksum(
|
||||||
|
List<int> ids,
|
||||||
|
List<String> checksums,
|
||||||
|
) =>
|
||||||
|
db.assets.getAllByOwnerIdChecksum(ids, checksums);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Asset>> getAllLocal() =>
|
||||||
|
db.assets.where().localIdIsNotNull().findAll();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteAllByRemoteId(List<String> ids, {AssetState? state}) =>
|
||||||
|
txn(() => _getAllByRemoteIdImpl(ids, state).deleteAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Asset>> _getMatchesImpl(
|
Future<List<Asset>> _getMatchesImpl(
|
||||||
|
@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/interfaces/asset_api.interface.dart';
|
import 'package:immich_mobile/interfaces/asset_api.interface.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.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';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
final assetApiRepositoryProvider = Provider(
|
final assetApiRepositoryProvider = Provider(
|
||||||
@ -12,8 +12,7 @@ final assetApiRepositoryProvider = Provider(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
class AssetApiRepository extends BaseApiRepository
|
class AssetApiRepository extends ApiRepository implements IAssetApiRepository {
|
||||||
implements IAssetApiRepository {
|
|
||||||
final AssetsApi _api;
|
final AssetsApi _api;
|
||||||
final SearchApi _searchApi;
|
final SearchApi _searchApi;
|
||||||
|
|
||||||
|
@ -2,19 +2,41 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||||
import 'package:immich_mobile/interfaces/backup.interface.dart';
|
import 'package:immich_mobile/interfaces/backup.interface.dart';
|
||||||
import 'package:immich_mobile/providers/db.provider.dart';
|
import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/database.repository.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
final backupRepositoryProvider =
|
final backupRepositoryProvider =
|
||||||
Provider((ref) => BackupRepository(ref.watch(dbProvider)));
|
Provider((ref) => BackupRepository(ref.watch(dbProvider)));
|
||||||
|
|
||||||
class BackupRepository implements IBackupRepository {
|
class BackupRepository extends DatabaseRepository implements IBackupRepository {
|
||||||
final Isar _db;
|
BackupRepository(super.db);
|
||||||
|
|
||||||
BackupRepository(
|
@override
|
||||||
this._db,
|
Future<List<BackupAlbum>> getAll({BackupAlbumSort? sort}) {
|
||||||
);
|
final baseQuery = db.backupAlbums.where();
|
||||||
|
final QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> query;
|
||||||
|
switch (sort) {
|
||||||
|
case null:
|
||||||
|
query = baseQuery.noOp();
|
||||||
|
case BackupAlbumSort.id:
|
||||||
|
query = baseQuery.sortById();
|
||||||
|
}
|
||||||
|
return query.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<String>> getIdsBySelection(BackupSelection backup) =>
|
Future<List<String>> getIdsBySelection(BackupSelection backup) =>
|
||||||
_db.backupAlbums.filter().selectionEqualTo(backup).idProperty().findAll();
|
db.backupAlbums.filter().selectionEqualTo(backup).idProperty().findAll();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<BackupAlbum>> getAllBySelection(BackupSelection backup) =>
|
||||||
|
db.backupAlbums.filter().selectionEqualTo(backup).findAll();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteAll(List<int> ids) =>
|
||||||
|
txn(() => db.backupAlbums.deleteAll(ids));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateAll(List<BackupAlbum> backupAlbums) =>
|
||||||
|
txn(() => db.backupAlbums.putAll(backupAlbums));
|
||||||
}
|
}
|
||||||
|
28
mobile/lib/repositories/database.repository.dart
Normal file
28
mobile/lib/repositories/database.repository.dart
Normal file
@ -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<T> txn<T>(Future<T> Function() callback) =>
|
||||||
|
inTxn ? callback() : transaction(callback);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T> transaction<T>(Future<T> Function() callback) =>
|
||||||
|
db.writeTxn(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Asd<T> on QueryBuilder<T, dynamic, dynamic> {
|
||||||
|
QueryBuilder<T, T, O> noOp<O>() {
|
||||||
|
// ignore: invalid_use_of_protected_member
|
||||||
|
return QueryBuilder.apply(this, (query) => query);
|
||||||
|
}
|
||||||
|
}
|
68
mobile/lib/repositories/download.repository.dart
Normal file
68
mobile/lib/repositories/download.repository.dart
Normal file
@ -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<bool> download(DownloadTask task) {
|
||||||
|
return FileDownloader().enqueue(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteAllTrackingRecords() {
|
||||||
|
return FileDownloader().database.deleteAllRecords();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> cancel(String id) {
|
||||||
|
return FileDownloader().cancelTaskWithId(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<TaskRecord>> getLiveVideoTasks() {
|
||||||
|
return FileDownloader().database.allRecordsWithStatus(
|
||||||
|
TaskStatus.complete,
|
||||||
|
group: downloadGroupLivePhoto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteRecordsWithIds(List<String> ids) {
|
||||||
|
return FileDownloader().database.deleteRecordsWithIds(ids);
|
||||||
|
}
|
||||||
|
}
|
29
mobile/lib/repositories/etag.repository.dart
Normal file
29
mobile/lib/repositories/etag.repository.dart
Normal file
@ -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<List<String>> getAllIds() => db.eTags.where().idProperty().findAll();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ETag?> get(int id) => db.eTags.get(id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> upsertAll(List<ETag> etags) => txn(() => db.eTags.putAll(etags));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteByIds(List<String> ids) =>
|
||||||
|
txn(() => db.eTags.deleteAllById(ids));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ETag?> getById(String id) => db.eTags.getById(id);
|
||||||
|
}
|
@ -2,27 +2,30 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||||
import 'package:immich_mobile/interfaces/exif_info.interface.dart';
|
import 'package:immich_mobile/interfaces/exif_info.interface.dart';
|
||||||
import 'package:immich_mobile/providers/db.provider.dart';
|
import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:immich_mobile/repositories/database.repository.dart';
|
||||||
|
|
||||||
final exifInfoRepositoryProvider =
|
final exifInfoRepositoryProvider =
|
||||||
Provider((ref) => ExifInfoRepository(ref.watch(dbProvider)));
|
Provider((ref) => ExifInfoRepository(ref.watch(dbProvider)));
|
||||||
|
|
||||||
class ExifInfoRepository implements IExifInfoRepository {
|
class ExifInfoRepository extends DatabaseRepository
|
||||||
final Isar _db;
|
implements IExifInfoRepository {
|
||||||
|
ExifInfoRepository(super.db);
|
||||||
ExifInfoRepository(
|
|
||||||
this._db,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> delete(int id) => _db.exifInfos.delete(id);
|
Future<void> delete(int id) => txn(() => db.exifInfos.delete(id));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ExifInfo?> get(int id) => _db.exifInfos.get(id);
|
Future<ExifInfo?> get(int id) => db.exifInfos.get(id);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ExifInfo> update(ExifInfo exifInfo) async {
|
Future<ExifInfo> update(ExifInfo exifInfo) async {
|
||||||
await _db.writeTxn(() => _db.exifInfos.put(exifInfo));
|
await txn(() => db.exifInfos.put(exifInfo));
|
||||||
return exifInfo;
|
return exifInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ExifInfo>> updateAll(List<ExifInfo> exifInfos) async {
|
||||||
|
await txn(() => db.exifInfos.putAll(exifInfos));
|
||||||
|
return exifInfos;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/entities/user.entity.dart';
|
import 'package:immich_mobile/entities/user.entity.dart';
|
||||||
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
|
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.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';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
final partnerApiRepositoryProvider = Provider(
|
final partnerApiRepositoryProvider = Provider(
|
||||||
@ -11,7 +11,7 @@ final partnerApiRepositoryProvider = Provider(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
class PartnerApiRepository extends BaseApiRepository
|
class PartnerApiRepository extends ApiRepository
|
||||||
implements IPartnerApiRepository {
|
implements IPartnerApiRepository {
|
||||||
final PartnersApi _api;
|
final PartnersApi _api;
|
||||||
|
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/interfaces/person_api.interface.dart';
|
import 'package:immich_mobile/interfaces/person_api.interface.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.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';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
final personApiRepositoryProvider = Provider(
|
final personApiRepositoryProvider = Provider(
|
||||||
(ref) => PersonApiRepository(ref.watch(apiServiceProvider).peopleApi),
|
(ref) => PersonApiRepository(ref.watch(apiServiceProvider).peopleApi),
|
||||||
);
|
);
|
||||||
|
|
||||||
class PersonApiRepository extends BaseApiRepository
|
class PersonApiRepository extends ApiRepository
|
||||||
implements IPersonApiRepository {
|
implements IPersonApiRepository {
|
||||||
final PeopleApi _api;
|
final PeopleApi _api;
|
||||||
|
|
||||||
|
@ -3,37 +3,61 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/entities/user.entity.dart';
|
import 'package:immich_mobile/entities/user.entity.dart';
|
||||||
import 'package:immich_mobile/interfaces/user.interface.dart';
|
import 'package:immich_mobile/interfaces/user.interface.dart';
|
||||||
import 'package:immich_mobile/providers/db.provider.dart';
|
import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/database.repository.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
final userRepositoryProvider =
|
final userRepositoryProvider =
|
||||||
Provider((ref) => UserRepository(ref.watch(dbProvider)));
|
Provider((ref) => UserRepository(ref.watch(dbProvider)));
|
||||||
|
|
||||||
class UserRepository implements IUserRepository {
|
class UserRepository extends DatabaseRepository implements IUserRepository {
|
||||||
final Isar _db;
|
UserRepository(super.db);
|
||||||
|
|
||||||
UserRepository(
|
|
||||||
this._db,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<User>> getByIds(List<String> ids) async =>
|
Future<List<User>> getByIds(List<String> ids) async =>
|
||||||
(await _db.users.getAllById(ids)).cast();
|
(await db.users.getAllById(ids)).nonNulls.toList();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<User?> get(String id) => _db.users.getById(id);
|
Future<User?> get(String id) => db.users.getById(id);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<User>> getAll({bool self = true}) {
|
Future<List<User>> getAll({bool self = true, UserSort? sortBy}) {
|
||||||
if (self) {
|
final baseQuery = db.users.where();
|
||||||
return _db.users.where().findAll();
|
|
||||||
}
|
|
||||||
final int userId = Store.get(StoreKey.currentUser).isarId;
|
final int userId = Store.get(StoreKey.currentUser).isarId;
|
||||||
return _db.users.where().isarIdNotEqualTo(userId).findAll();
|
final QueryBuilder<User, User, QAfterWhereClause> afterWhere =
|
||||||
|
self ? baseQuery.noOp() : baseQuery.isarIdNotEqualTo(userId);
|
||||||
|
final QueryBuilder<User, User, QAfterSortBy> query;
|
||||||
|
switch (sortBy) {
|
||||||
|
case null:
|
||||||
|
query = afterWhere.noOp();
|
||||||
|
case UserSort.id:
|
||||||
|
query = afterWhere.sortById();
|
||||||
|
}
|
||||||
|
return query.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<User> update(User user) async {
|
Future<User> update(User user) async {
|
||||||
await _db.writeTxn(() => _db.users.put(user));
|
await txn(() => db.users.put(user));
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<User> me() => Future.value(Store.get(StoreKey.currentUser));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteById(List<int> ids) => txn(() => db.users.deleteAll(ids));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<User>> upsertAll(List<User> users) async {
|
||||||
|
await txn(() => db.users.putAll(users));
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<User>> getAllAccessible() => db.users
|
||||||
|
.filter()
|
||||||
|
.isPartnerSharedWithEqualTo(true)
|
||||||
|
.or()
|
||||||
|
.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId)
|
||||||
|
.findAll();
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ import 'package:http/http.dart';
|
|||||||
import 'package:immich_mobile/entities/user.entity.dart';
|
import 'package:immich_mobile/entities/user.entity.dart';
|
||||||
import 'package:immich_mobile/interfaces/user_api.interface.dart';
|
import 'package:immich_mobile/interfaces/user_api.interface.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.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';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
final userApiRepositoryProvider = Provider(
|
final userApiRepositoryProvider = Provider(
|
||||||
@ -14,8 +14,7 @@ final userApiRepositoryProvider = Provider(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
class UserApiRepository extends BaseApiRepository
|
class UserApiRepository extends ApiRepository implements IUserApiRepository {
|
||||||
implements IUserApiRepository {
|
|
||||||
final UsersApi _api;
|
final UsersApi _api;
|
||||||
|
|
||||||
UserApiRepository(this._api);
|
UserApiRepository(this._api);
|
||||||
|
@ -243,14 +243,15 @@ class AlbumService {
|
|||||||
int albumId, {
|
int albumId, {
|
||||||
List<Asset> add = const [],
|
List<Asset> add = const [],
|
||||||
List<Asset> remove = const [],
|
List<Asset> remove = const [],
|
||||||
}) async {
|
}) =>
|
||||||
final album = await _albumRepository.getById(albumId);
|
_albumRepository.transaction(() async {
|
||||||
if (album == null) return;
|
final album = await _albumRepository.get(albumId);
|
||||||
await _albumRepository.addAssets(album, add);
|
if (album == null) return;
|
||||||
await _albumRepository.removeAssets(album, remove);
|
await _albumRepository.addAssets(album, add);
|
||||||
await _albumRepository.recalculateMetadata(album);
|
await _albumRepository.removeAssets(album, remove);
|
||||||
await _albumRepository.update(album);
|
await _albumRepository.recalculateMetadata(album);
|
||||||
}
|
await _albumRepository.update(album);
|
||||||
|
});
|
||||||
|
|
||||||
Future<bool> addAdditionalUserToAlbum(
|
Future<bool> addAdditionalUserToAlbum(
|
||||||
List<String> sharedUserIds,
|
List<String> sharedUserIds,
|
||||||
@ -285,20 +286,20 @@ class AlbumService {
|
|||||||
|
|
||||||
Future<bool> deleteAlbum(Album album) async {
|
Future<bool> deleteAlbum(Album album) async {
|
||||||
try {
|
try {
|
||||||
final user = Store.get(StoreKey.currentUser);
|
final userId = Store.get(StoreKey.currentUser).isarId;
|
||||||
if (album.owner.value?.isarId == user.isarId) {
|
if (album.owner.value?.isarId == userId) {
|
||||||
await _albumApiRepository.delete(album.remoteId!);
|
await _albumApiRepository.delete(album.remoteId!);
|
||||||
}
|
}
|
||||||
if (album.shared) {
|
if (album.shared) {
|
||||||
final foreignAssets =
|
final foreignAssets =
|
||||||
await _assetRepository.getByAlbum(album, notOwnedBy: user);
|
await _assetRepository.getByAlbum(album, notOwnedBy: [userId]);
|
||||||
await _albumRepository.delete(album.id);
|
await _albumRepository.delete(album.id);
|
||||||
|
|
||||||
final List<Album> albums = await _albumRepository.getAll(shared: true);
|
final List<Album> albums = await _albumRepository.getAll(shared: true);
|
||||||
final List<Asset> existing = [];
|
final List<Asset> existing = [];
|
||||||
for (Album album in albums) {
|
for (Album album in albums) {
|
||||||
existing.addAll(
|
existing.addAll(
|
||||||
await _assetRepository.getByAlbum(album, notOwnedBy: user),
|
await _assetRepository.getByAlbum(album, notOwnedBy: [userId]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final List<int> idsToRemove =
|
final List<int> idsToRemove =
|
||||||
@ -357,7 +358,7 @@ class AlbumService {
|
|||||||
|
|
||||||
album.sharedUsers.remove(user);
|
album.sharedUsers.remove(user);
|
||||||
await _albumRepository.removeUsers(album, [user]);
|
await _albumRepository.removeUsers(album, [user]);
|
||||||
final a = await _albumRepository.getById(album.id);
|
final a = await _albumRepository.get(album.id);
|
||||||
// trigger watcher
|
// trigger watcher
|
||||||
await _albumRepository.update(a!);
|
await _albumRepository.update(a!);
|
||||||
|
|
||||||
|
@ -1,27 +1,30 @@
|
|||||||
// ignore_for_file: null_argument_to_non_null_type
|
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/etag.entity.dart';
|
import 'package:immich_mobile/entities/backup_album.entity.dart';
|
||||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
|
||||||
import 'package:immich_mobile/entities/user.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/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/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/models/backup/backup_candidate.model.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.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/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/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/album.service.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/services/backup.service.dart';
|
import 'package:immich_mobile/services/backup.service.dart';
|
||||||
import 'package:immich_mobile/services/sync.service.dart';
|
import 'package:immich_mobile/services/sync.service.dart';
|
||||||
import 'package:immich_mobile/services/user.service.dart';
|
import 'package:immich_mobile/services/user.service.dart';
|
||||||
import 'package:isar/isar.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
@ -29,48 +32,54 @@ import 'package:openapi/api.dart';
|
|||||||
final assetServiceProvider = Provider(
|
final assetServiceProvider = Provider(
|
||||||
(ref) => AssetService(
|
(ref) => AssetService(
|
||||||
ref.watch(assetApiRepositoryProvider),
|
ref.watch(assetApiRepositoryProvider),
|
||||||
|
ref.watch(assetRepositoryProvider),
|
||||||
ref.watch(exifInfoRepositoryProvider),
|
ref.watch(exifInfoRepositoryProvider),
|
||||||
|
ref.watch(userRepositoryProvider),
|
||||||
|
ref.watch(etagRepositoryProvider),
|
||||||
|
ref.watch(backupRepositoryProvider),
|
||||||
ref.watch(apiServiceProvider),
|
ref.watch(apiServiceProvider),
|
||||||
ref.watch(syncServiceProvider),
|
ref.watch(syncServiceProvider),
|
||||||
ref.watch(userServiceProvider),
|
ref.watch(userServiceProvider),
|
||||||
ref.watch(backupServiceProvider),
|
ref.watch(backupServiceProvider),
|
||||||
ref.watch(albumServiceProvider),
|
ref.watch(albumServiceProvider),
|
||||||
ref.watch(dbProvider),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
class AssetService {
|
class AssetService {
|
||||||
final IAssetApiRepository _assetApiRepository;
|
final IAssetApiRepository _assetApiRepository;
|
||||||
|
final IAssetRepository _assetRepository;
|
||||||
final IExifInfoRepository _exifInfoRepository;
|
final IExifInfoRepository _exifInfoRepository;
|
||||||
|
final IUserRepository _userRepository;
|
||||||
|
final IETagRepository _etagRepository;
|
||||||
|
final IBackupRepository _backupRepository;
|
||||||
final ApiService _apiService;
|
final ApiService _apiService;
|
||||||
final SyncService _syncService;
|
final SyncService _syncService;
|
||||||
final UserService _userService;
|
final UserService _userService;
|
||||||
final BackupService _backupService;
|
final BackupService _backupService;
|
||||||
final AlbumService _albumService;
|
final AlbumService _albumService;
|
||||||
final log = Logger('AssetService');
|
final log = Logger('AssetService');
|
||||||
final Isar _db;
|
|
||||||
|
|
||||||
AssetService(
|
AssetService(
|
||||||
this._assetApiRepository,
|
this._assetApiRepository,
|
||||||
|
this._assetRepository,
|
||||||
this._exifInfoRepository,
|
this._exifInfoRepository,
|
||||||
|
this._userRepository,
|
||||||
|
this._etagRepository,
|
||||||
|
this._backupRepository,
|
||||||
this._apiService,
|
this._apiService,
|
||||||
this._syncService,
|
this._syncService,
|
||||||
this._userService,
|
this._userService,
|
||||||
this._backupService,
|
this._backupService,
|
||||||
this._albumService,
|
this._albumService,
|
||||||
this._db,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Checks the server for updated assets and updates the local database if
|
/// Checks the server for updated assets and updates the local database if
|
||||||
/// required. Returns `true` if there were any changes.
|
/// required. Returns `true` if there were any changes.
|
||||||
Future<bool> refreshRemoteAssets() async {
|
Future<bool> refreshRemoteAssets() async {
|
||||||
final syncedUserIds = await _db.eTags.where().idProperty().findAll();
|
final syncedUserIds = await _etagRepository.getAllIds();
|
||||||
final List<User> syncedUsers = syncedUserIds.isEmpty
|
final List<User> syncedUsers = syncedUserIds.isEmpty
|
||||||
? []
|
? []
|
||||||
: await _db.users
|
: await _userRepository.getByIds(syncedUserIds);
|
||||||
.where()
|
|
||||||
.anyOf(syncedUserIds, (q, id) => q.idEqualTo(id))
|
|
||||||
.findAll();
|
|
||||||
final Stopwatch sw = Stopwatch()..start();
|
final Stopwatch sw = Stopwatch()..start();
|
||||||
final bool changes = await _syncService.syncRemoteAssetsToDb(
|
final bool changes = await _syncService.syncRemoteAssetsToDb(
|
||||||
users: syncedUsers,
|
users: syncedUsers,
|
||||||
@ -175,7 +184,7 @@ class AssetService {
|
|||||||
/// Loads the exif information from the database. If there is none, loads
|
/// Loads the exif information from the database. If there is none, loads
|
||||||
/// the exif info from the server (remote assets only)
|
/// the exif info from the server (remote assets only)
|
||||||
Future<Asset> loadExif(Asset a) async {
|
Future<Asset> 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
|
// fileSize is always filled on the server but not set on client
|
||||||
if (a.exifInfo?.fileSize == null) {
|
if (a.exifInfo?.fileSize == null) {
|
||||||
if (a.isRemote) {
|
if (a.isRemote) {
|
||||||
@ -185,7 +194,7 @@ class AssetService {
|
|||||||
a.exifInfo = newExif;
|
a.exifInfo = newExif;
|
||||||
if (newExif != a.exifInfo) {
|
if (newExif != a.exifInfo) {
|
||||||
if (a.isInDb) {
|
if (a.isInDb) {
|
||||||
_db.writeTxn(() => a.put(_db));
|
_assetRepository.transaction(() => _assetRepository.update(a));
|
||||||
} else {
|
} else {
|
||||||
debugPrint("[loadExif] parameter Asset is not from DB!");
|
debugPrint("[loadExif] parameter Asset is not from DB!");
|
||||||
}
|
}
|
||||||
@ -214,7 +223,7 @@ class AssetService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Asset?>> changeFavoriteStatus(
|
Future<List<Asset>> changeFavoriteStatus(
|
||||||
List<Asset> assets,
|
List<Asset> assets,
|
||||||
bool isFavorite,
|
bool isFavorite,
|
||||||
) async {
|
) async {
|
||||||
@ -230,11 +239,11 @@ class AssetService {
|
|||||||
return assets;
|
return assets;
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
log.severe("Error while changing favorite status", error, stack);
|
log.severe("Error while changing favorite status", error, stack);
|
||||||
return Future.value(null);
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Asset?>> changeArchiveStatus(
|
Future<List<Asset>> changeArchiveStatus(
|
||||||
List<Asset> assets,
|
List<Asset> assets,
|
||||||
bool isArchived,
|
bool isArchived,
|
||||||
) async {
|
) async {
|
||||||
@ -250,11 +259,11 @@ class AssetService {
|
|||||||
return assets;
|
return assets;
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
log.severe("Error while changing archive status", error, stack);
|
log.severe("Error while changing archive status", error, stack);
|
||||||
return Future.value(null);
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Asset?>> changeDateTime(
|
Future<List<Asset>?> changeDateTime(
|
||||||
List<Asset> assets,
|
List<Asset> assets,
|
||||||
String updatedDt,
|
String updatedDt,
|
||||||
) async {
|
) async {
|
||||||
@ -278,7 +287,7 @@ class AssetService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Asset?>> changeLocation(
|
Future<List<Asset>?> changeLocation(
|
||||||
List<Asset> assets,
|
List<Asset> assets,
|
||||||
LatLng location,
|
LatLng location,
|
||||||
) async {
|
) async {
|
||||||
@ -307,10 +316,10 @@ class AssetService {
|
|||||||
|
|
||||||
Future<void> syncUploadedAssetToAlbums() async {
|
Future<void> syncUploadedAssetToAlbums() async {
|
||||||
try {
|
try {
|
||||||
final [selectedAlbums, excludedAlbums] = await Future.wait([
|
final selectedAlbums =
|
||||||
_backupService.selectedAlbumsQuery().findAll(),
|
await _backupRepository.getAllBySelection(BackupSelection.select);
|
||||||
_backupService.excludedAlbumsQuery().findAll(),
|
final excludedAlbums =
|
||||||
]);
|
await _backupRepository.getAllBySelection(BackupSelection.exclude);
|
||||||
|
|
||||||
final candidates = await _backupService.buildUploadCandidates(
|
final candidates = await _backupService.buildUploadCandidates(
|
||||||
selectedAlbums,
|
selectedAlbums,
|
||||||
@ -319,12 +328,11 @@ class AssetService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await refreshRemoteAssets();
|
await refreshRemoteAssets();
|
||||||
final remoteAssets = await _db.assets
|
final owner = await _userRepository.me();
|
||||||
.where()
|
final remoteAssets = await _assetRepository.getAll(
|
||||||
.localIdIsNotNull()
|
ownerId: owner.isarId,
|
||||||
.filter()
|
state: AssetState.merged,
|
||||||
.remoteIdIsNotNull()
|
);
|
||||||
.findAll();
|
|
||||||
|
|
||||||
/// Map<AlbumName, [AssetId]>
|
/// Map<AlbumName, [AssetId]>
|
||||||
Map<String, List<String>> assetToAlbums = {};
|
Map<String, List<String>> assetToAlbums = {};
|
||||||
|
@ -9,6 +9,7 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/main.dart';
|
||||||
import 'package:immich_mobile/models/backup/backup_candidate.model.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/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/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/backup.repository.dart';
|
import 'package:immich_mobile/repositories/backup.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/album_media.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/file_media.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/partner_api.repository.dart';
|
import 'package:immich_mobile/repositories/partner_api.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/user.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/backup_progress.dart';
|
||||||
import 'package:immich_mobile/utils/diff.dart';
|
import 'package:immich_mobile/utils/diff.dart';
|
||||||
import 'package:immich_mobile/utils/http_ssl_cert_override.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:path_provider_ios/path_provider_ios.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||||
|
|
||||||
@ -357,7 +359,7 @@ class BackgroundService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _onAssetsChanged() async {
|
Future<bool> _onAssetsChanged() async {
|
||||||
final Isar db = await loadDb();
|
final db = await loadDb();
|
||||||
|
|
||||||
HttpOverrides.global = HttpSSLCertOverride();
|
HttpOverrides.global = HttpSSLCertOverride();
|
||||||
ApiService apiService = ApiService();
|
ApiService apiService = ApiService();
|
||||||
@ -366,7 +368,9 @@ class BackgroundService {
|
|||||||
AppSettingsService settingsService = AppSettingsService();
|
AppSettingsService settingsService = AppSettingsService();
|
||||||
AlbumRepository albumRepository = AlbumRepository(db);
|
AlbumRepository albumRepository = AlbumRepository(db);
|
||||||
AssetRepository assetRepository = AssetRepository(db);
|
AssetRepository assetRepository = AssetRepository(db);
|
||||||
BackupRepository backupAlbumRepository = BackupRepository(db);
|
BackupRepository backupRepository = BackupRepository(db);
|
||||||
|
ExifInfoRepository exifInfoRepository = ExifInfoRepository(db);
|
||||||
|
ETagRepository eTagRepository = ETagRepository(db);
|
||||||
AlbumMediaRepository albumMediaRepository = AlbumMediaRepository();
|
AlbumMediaRepository albumMediaRepository = AlbumMediaRepository();
|
||||||
FileMediaRepository fileMediaRepository = FileMediaRepository();
|
FileMediaRepository fileMediaRepository = FileMediaRepository();
|
||||||
AssetMediaRepository assetMediaRepository = AssetMediaRepository();
|
AssetMediaRepository assetMediaRepository = AssetMediaRepository();
|
||||||
@ -382,11 +386,15 @@ class BackgroundService {
|
|||||||
EntityService entityService =
|
EntityService entityService =
|
||||||
EntityService(assetRepository, userRepository);
|
EntityService(assetRepository, userRepository);
|
||||||
SyncService syncSerive = SyncService(
|
SyncService syncSerive = SyncService(
|
||||||
db,
|
|
||||||
hashService,
|
hashService,
|
||||||
entityService,
|
entityService,
|
||||||
albumMediaRepository,
|
albumMediaRepository,
|
||||||
albumApiRepository,
|
albumApiRepository,
|
||||||
|
albumRepository,
|
||||||
|
assetRepository,
|
||||||
|
exifInfoRepository,
|
||||||
|
userRepository,
|
||||||
|
eTagRepository,
|
||||||
);
|
);
|
||||||
UserService userService = UserService(
|
UserService userService = UserService(
|
||||||
partnerApiRepository,
|
partnerApiRepository,
|
||||||
@ -400,22 +408,24 @@ class BackgroundService {
|
|||||||
entityService,
|
entityService,
|
||||||
albumRepository,
|
albumRepository,
|
||||||
assetRepository,
|
assetRepository,
|
||||||
backupAlbumRepository,
|
backupRepository,
|
||||||
albumMediaRepository,
|
albumMediaRepository,
|
||||||
albumApiRepository,
|
albumApiRepository,
|
||||||
);
|
);
|
||||||
BackupService backupService = BackupService(
|
BackupService backupService = BackupService(
|
||||||
apiService,
|
apiService,
|
||||||
db,
|
|
||||||
settingService,
|
settingService,
|
||||||
albumService,
|
albumService,
|
||||||
albumMediaRepository,
|
albumMediaRepository,
|
||||||
fileMediaRepository,
|
fileMediaRepository,
|
||||||
|
assetRepository,
|
||||||
assetMediaRepository,
|
assetMediaRepository,
|
||||||
);
|
);
|
||||||
|
|
||||||
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
|
final selectedAlbums =
|
||||||
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
|
await backupRepository.getAllBySelection(BackupSelection.select);
|
||||||
|
final excludedAlbums =
|
||||||
|
await backupRepository.getAllBySelection(BackupSelection.exclude);
|
||||||
if (selectedAlbums.isEmpty) {
|
if (selectedAlbums.isEmpty) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -433,28 +443,28 @@ class BackgroundService {
|
|||||||
await Store.delete(StoreKey.backupFailedSince);
|
await Store.delete(StoreKey.backupFailedSince);
|
||||||
final backupAlbums = [...selectedAlbums, ...excludedAlbums];
|
final backupAlbums = [...selectedAlbums, ...excludedAlbums];
|
||||||
backupAlbums.sortBy((e) => e.id);
|
backupAlbums.sortBy((e) => e.id);
|
||||||
db.writeTxnSync(() {
|
|
||||||
final dbAlbums = db.backupAlbums.where().sortById().findAllSync();
|
final dbAlbums =
|
||||||
final List<int> toDelete = [];
|
await backupRepository.getAll(sort: BackupAlbumSort.id);
|
||||||
final List<BackupAlbum> toUpsert = [];
|
final List<int> toDelete = [];
|
||||||
// stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state
|
final List<BackupAlbum> toUpsert = [];
|
||||||
diffSortedListsSync(
|
// stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state
|
||||||
dbAlbums,
|
diffSortedListsSync(
|
||||||
backupAlbums,
|
dbAlbums,
|
||||||
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
|
backupAlbums,
|
||||||
both: (BackupAlbum a, BackupAlbum b) {
|
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
|
||||||
a.lastBackup = a.lastBackup.isAfter(b.lastBackup)
|
both: (BackupAlbum a, BackupAlbum b) {
|
||||||
? a.lastBackup
|
a.lastBackup = a.lastBackup.isAfter(b.lastBackup)
|
||||||
: b.lastBackup;
|
? a.lastBackup
|
||||||
toUpsert.add(a);
|
: b.lastBackup;
|
||||||
return true;
|
toUpsert.add(a);
|
||||||
},
|
return true;
|
||||||
onlyFirst: (BackupAlbum a) => toUpsert.add(a),
|
},
|
||||||
onlySecond: (BackupAlbum b) => toDelete.add(b.isarId),
|
onlyFirst: (BackupAlbum a) => toUpsert.add(a),
|
||||||
);
|
onlySecond: (BackupAlbum b) => toDelete.add(b.isarId),
|
||||||
db.backupAlbums.deleteAllSync(toDelete);
|
);
|
||||||
db.backupAlbums.putAllSync(toUpsert);
|
await backupRepository.deleteAll(toDelete);
|
||||||
});
|
await backupRepository.updateAll(toUpsert);
|
||||||
} else if (Store.tryGet(StoreKey.backupFailedSince) == null) {
|
} else if (Store.tryGet(StoreKey.backupFailedSince) == null) {
|
||||||
Store.put(StoreKey.backupFailedSince, DateTime.now());
|
Store.put(StoreKey.backupFailedSince, DateTime.now());
|
||||||
return false;
|
return false;
|
||||||
|
@ -9,9 +9,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/backup_album.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/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/interfaces/album_media.interface.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/asset_media.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/file_media.interface.dart';
|
import 'package:immich_mobile/interfaces/file_media.interface.dart';
|
||||||
import 'package:immich_mobile/models/backup/backup_candidate.model.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/models/backup/success_upload_asset.model.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.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/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/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/file_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/album.service.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:isar/isar.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
@ -37,11 +36,11 @@ import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
|||||||
final backupServiceProvider = Provider(
|
final backupServiceProvider = Provider(
|
||||||
(ref) => BackupService(
|
(ref) => BackupService(
|
||||||
ref.watch(apiServiceProvider),
|
ref.watch(apiServiceProvider),
|
||||||
ref.watch(dbProvider),
|
|
||||||
ref.watch(appSettingsServiceProvider),
|
ref.watch(appSettingsServiceProvider),
|
||||||
ref.watch(albumServiceProvider),
|
ref.watch(albumServiceProvider),
|
||||||
ref.watch(albumMediaRepositoryProvider),
|
ref.watch(albumMediaRepositoryProvider),
|
||||||
ref.watch(fileMediaRepositoryProvider),
|
ref.watch(fileMediaRepositoryProvider),
|
||||||
|
ref.watch(assetRepositoryProvider),
|
||||||
ref.watch(assetMediaRepositoryProvider),
|
ref.watch(assetMediaRepositoryProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -49,21 +48,21 @@ final backupServiceProvider = Provider(
|
|||||||
class BackupService {
|
class BackupService {
|
||||||
final httpClient = http.Client();
|
final httpClient = http.Client();
|
||||||
final ApiService _apiService;
|
final ApiService _apiService;
|
||||||
final Isar _db;
|
|
||||||
final Logger _log = Logger("BackupService");
|
final Logger _log = Logger("BackupService");
|
||||||
final AppSettingsService _appSetting;
|
final AppSettingsService _appSetting;
|
||||||
final AlbumService _albumService;
|
final AlbumService _albumService;
|
||||||
final IAlbumMediaRepository _albumMediaRepository;
|
final IAlbumMediaRepository _albumMediaRepository;
|
||||||
final IFileMediaRepository _fileMediaRepository;
|
final IFileMediaRepository _fileMediaRepository;
|
||||||
|
final IAssetRepository _assetRepository;
|
||||||
final IAssetMediaRepository _assetMediaRepository;
|
final IAssetMediaRepository _assetMediaRepository;
|
||||||
|
|
||||||
BackupService(
|
BackupService(
|
||||||
this._apiService,
|
this._apiService,
|
||||||
this._db,
|
|
||||||
this._appSetting,
|
this._appSetting,
|
||||||
this._albumService,
|
this._albumService,
|
||||||
this._albumMediaRepository,
|
this._albumMediaRepository,
|
||||||
this._fileMediaRepository,
|
this._fileMediaRepository,
|
||||||
|
this._assetRepository,
|
||||||
this._assetMediaRepository,
|
this._assetMediaRepository,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -78,24 +77,17 @@ class BackupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveDuplicatedAssetIds(List<String> deviceAssetIds) {
|
Future<void> _saveDuplicatedAssetIds(List<String> deviceAssetIds) =>
|
||||||
final duplicates = deviceAssetIds.map((id) => DuplicatedAsset(id)).toList();
|
_assetRepository.transaction(
|
||||||
return _db.writeTxn(() => _db.duplicatedAssets.putAll(duplicates));
|
() => _assetRepository.upsertDuplicatedAssets(deviceAssetIds),
|
||||||
}
|
);
|
||||||
|
|
||||||
/// Get duplicated asset id from database
|
/// Get duplicated asset id from database
|
||||||
Future<Set<String>> getDuplicatedAssetIds() async {
|
Future<Set<String>> getDuplicatedAssetIds() async {
|
||||||
final duplicates = await _db.duplicatedAssets.where().findAll();
|
final duplicates = await _assetRepository.getAllDuplicatedAssetIds();
|
||||||
return duplicates.map((e) => e.id).toSet();
|
return duplicates.toSet();
|
||||||
}
|
}
|
||||||
|
|
||||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
|
||||||
selectedAlbumsQuery() =>
|
|
||||||
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.select);
|
|
||||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
|
||||||
excludedAlbumsQuery() =>
|
|
||||||
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude);
|
|
||||||
|
|
||||||
/// Returns all assets newer than the last successful backup per album
|
/// Returns all assets newer than the last successful backup per album
|
||||||
/// if `useTimeFilter` is set to true, all assets will be returned
|
/// if `useTimeFilter` is set to true, all assets will be returned
|
||||||
Future<Set<BackupCandidate>> buildUploadCandidates(
|
Future<Set<BackupCandidate>> buildUploadCandidates(
|
||||||
|
@ -34,19 +34,19 @@ class BackupVerificationService {
|
|||||||
final owner = Store.get(StoreKey.currentUser).isarId;
|
final owner = Store.get(StoreKey.currentUser).isarId;
|
||||||
final List<Asset> onlyLocal = await _assetRepository.getAll(
|
final List<Asset> onlyLocal = await _assetRepository.getAll(
|
||||||
ownerId: owner,
|
ownerId: owner,
|
||||||
remote: false,
|
state: AssetState.local,
|
||||||
limit: limit,
|
limit: limit,
|
||||||
);
|
);
|
||||||
final List<Asset> remoteMatches = await _assetRepository.getMatches(
|
final List<Asset> remoteMatches = await _assetRepository.getMatches(
|
||||||
assets: onlyLocal,
|
assets: onlyLocal,
|
||||||
ownerId: owner,
|
ownerId: owner,
|
||||||
remote: true,
|
state: AssetState.remote,
|
||||||
limit: limit,
|
limit: limit,
|
||||||
);
|
);
|
||||||
final List<Asset> localMatches = await _assetRepository.getMatches(
|
final List<Asset> localMatches = await _assetRepository.getMatches(
|
||||||
assets: remoteMatches,
|
assets: remoteMatches,
|
||||||
ownerId: owner,
|
ownerId: owner,
|
||||||
remote: false,
|
state: AssetState.local,
|
||||||
limit: limit,
|
limit: limit,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
193
mobile/lib/services/download.service.dart
Normal file
193
mobile/lib/services/download.service.dart
Normal file
@ -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<bool> 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<bool> 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<bool> 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<bool> cancelDownload(String id) async {
|
||||||
|
return await FileDownloader().cancelTaskWithId(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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 ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -130,7 +130,9 @@ class HashService {
|
|||||||
final validHashes = anyNull
|
final validHashes = anyNull
|
||||||
? toAdd.where((e) => e.hash.length == 20).toList(growable: false)
|
? toAdd.where((e) => e.hash.length == 20).toList(growable: false)
|
||||||
: toAdd;
|
: toAdd;
|
||||||
await _assetRepository.upsertDeviceAssets(validHashes);
|
|
||||||
|
await _assetRepository
|
||||||
|
.transaction(() => _assetRepository.upsertDeviceAssets(validHashes));
|
||||||
_log.fine("Hashed ${validHashes.length}/${toHash.length} assets");
|
_log.fine("Hashed ${validHashes.length}/${toHash.length} assets");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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<bool> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -61,7 +61,8 @@ class StackService {
|
|||||||
|
|
||||||
removeAssets.add(asset);
|
removeAssets.add(asset);
|
||||||
}
|
}
|
||||||
await _assetRepository.updateAll(removeAssets);
|
await _assetRepository
|
||||||
|
.transaction(() => _assetRepository.updateAll(removeAssets));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint("Error while deleting stack: $error");
|
debugPrint("Error while deleting stack: $error");
|
||||||
}
|
}
|
||||||
|
@ -5,48 +5,66 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/entities/etag.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/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_api.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/album_media.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_api.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/album_media.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/entity.service.dart';
|
||||||
import 'package:immich_mobile/services/hash.service.dart';
|
import 'package:immich_mobile/services/hash.service.dart';
|
||||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||||
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
||||||
import 'package:immich_mobile/utils/datetime_comparison.dart';
|
import 'package:immich_mobile/utils/datetime_comparison.dart';
|
||||||
import 'package:immich_mobile/utils/diff.dart';
|
import 'package:immich_mobile/utils/diff.dart';
|
||||||
import 'package:isar/isar.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
final syncServiceProvider = Provider(
|
final syncServiceProvider = Provider(
|
||||||
(ref) => SyncService(
|
(ref) => SyncService(
|
||||||
ref.watch(dbProvider),
|
|
||||||
ref.watch(hashServiceProvider),
|
ref.watch(hashServiceProvider),
|
||||||
ref.watch(entityServiceProvider),
|
ref.watch(entityServiceProvider),
|
||||||
ref.watch(albumMediaRepositoryProvider),
|
ref.watch(albumMediaRepositoryProvider),
|
||||||
ref.watch(albumApiRepositoryProvider),
|
ref.watch(albumApiRepositoryProvider),
|
||||||
|
ref.watch(albumRepositoryProvider),
|
||||||
|
ref.watch(assetRepositoryProvider),
|
||||||
|
ref.watch(exifInfoRepositoryProvider),
|
||||||
|
ref.watch(userRepositoryProvider),
|
||||||
|
ref.watch(etagRepositoryProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
class SyncService {
|
class SyncService {
|
||||||
final Isar _db;
|
|
||||||
final HashService _hashService;
|
final HashService _hashService;
|
||||||
final EntityService _entityService;
|
final EntityService _entityService;
|
||||||
final IAlbumMediaRepository _albumMediaRepository;
|
final IAlbumMediaRepository _albumMediaRepository;
|
||||||
final IAlbumApiRepository _albumApiRepository;
|
final IAlbumApiRepository _albumApiRepository;
|
||||||
|
final IAlbumRepository _albumRepository;
|
||||||
|
final IAssetRepository _assetRepository;
|
||||||
|
final IExifInfoRepository _exifInfoRepository;
|
||||||
|
final IUserRepository _userRepository;
|
||||||
|
final IETagRepository _eTagRepository;
|
||||||
final AsyncMutex _lock = AsyncMutex();
|
final AsyncMutex _lock = AsyncMutex();
|
||||||
final Logger _log = Logger('SyncService');
|
final Logger _log = Logger('SyncService');
|
||||||
|
|
||||||
SyncService(
|
SyncService(
|
||||||
this._db,
|
|
||||||
this._hashService,
|
this._hashService,
|
||||||
this._entityService,
|
this._entityService,
|
||||||
this._albumMediaRepository,
|
this._albumMediaRepository,
|
||||||
this._albumApiRepository,
|
this._albumApiRepository,
|
||||||
|
this._albumRepository,
|
||||||
|
this._assetRepository,
|
||||||
|
this._exifInfoRepository,
|
||||||
|
this._userRepository,
|
||||||
|
this._eTagRepository,
|
||||||
);
|
);
|
||||||
|
|
||||||
// public methods:
|
// public methods:
|
||||||
@ -119,7 +137,7 @@ class SyncService {
|
|||||||
/// Returns `true`if there were any changes
|
/// Returns `true`if there were any changes
|
||||||
Future<bool> _syncUsersFromServer(List<User> users) async {
|
Future<bool> _syncUsersFromServer(List<User> users) async {
|
||||||
users.sortBy((u) => u.id);
|
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!");
|
assert(dbUsers.isSortedBy((u) => u.id), "dbUsers not sorted!");
|
||||||
final List<int> toDelete = [];
|
final List<int> toDelete = [];
|
||||||
final List<User> toUpsert = [];
|
final List<User> toUpsert = [];
|
||||||
@ -141,9 +159,9 @@ class SyncService {
|
|||||||
onlySecond: (User b) => toDelete.add(b.isarId),
|
onlySecond: (User b) => toDelete.add(b.isarId),
|
||||||
);
|
);
|
||||||
if (changes) {
|
if (changes) {
|
||||||
await _db.writeTxn(() async {
|
await _userRepository.transaction(() async {
|
||||||
await _db.users.deleteAll(toDelete);
|
await _userRepository.deleteById(toDelete);
|
||||||
await _db.users.putAll(toUpsert);
|
await _userRepository.upsertAll(toUpsert);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return changes;
|
return changes;
|
||||||
@ -152,15 +170,15 @@ class SyncService {
|
|||||||
/// Syncs a new asset to the db. Returns `true` if successful
|
/// Syncs a new asset to the db. Returns `true` if successful
|
||||||
Future<bool> _syncNewAssetToDb(Asset a) async {
|
Future<bool> _syncNewAssetToDb(Asset a) async {
|
||||||
final Asset? inDb =
|
final Asset? inDb =
|
||||||
await _db.assets.getByOwnerIdChecksum(a.ownerId, a.checksum);
|
await _assetRepository.getByOwnerIdChecksum(a.ownerId, a.checksum);
|
||||||
if (inDb != null) {
|
if (inDb != null) {
|
||||||
// unify local/remote assets by replacing the
|
// unify local/remote assets by replacing the
|
||||||
// local-only asset in the DB with a local&remote asset
|
// local-only asset in the DB with a local&remote asset
|
||||||
a = inDb.updatedCopy(a);
|
a = inDb.updatedCopy(a);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await _db.writeTxn(() => a.put(_db));
|
await _assetRepository.update(a);
|
||||||
} on IsarError catch (e) {
|
} catch (e) {
|
||||||
_log.severe("Failed to put new asset into db", e);
|
_log.severe("Failed to put new asset into db", e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -175,9 +193,9 @@ class SyncService {
|
|||||||
DateTime since,
|
DateTime since,
|
||||||
) getChangedAssets,
|
) getChangedAssets,
|
||||||
) async {
|
) async {
|
||||||
final currentUser = Store.get(StoreKey.currentUser);
|
final currentUser = await _userRepository.me();
|
||||||
final DateTime? since =
|
final DateTime? since =
|
||||||
_db.eTags.getSync(currentUser.isarId)?.time?.toUtc();
|
(await _eTagRepository.get(currentUser.isarId))?.time?.toUtc();
|
||||||
if (since == null) return null;
|
if (since == null) return null;
|
||||||
final DateTime now = DateTime.now();
|
final DateTime now = DateTime.now();
|
||||||
final (toUpsert, toDelete) = await getChangedAssets(users, since);
|
final (toUpsert, toDelete) = await getChangedAssets(users, since);
|
||||||
@ -198,7 +216,7 @@ class SyncService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
} on IsarError catch (e) {
|
} catch (e) {
|
||||||
_log.severe("Failed to sync remote assets to db", e);
|
_log.severe("Failed to sync remote assets to db", e);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -206,23 +224,21 @@ class SyncService {
|
|||||||
|
|
||||||
/// Deletes remote-only assets, updates merged assets to be local-only
|
/// Deletes remote-only assets, updates merged assets to be local-only
|
||||||
Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) {
|
Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) {
|
||||||
return _db.writeTxn(() async {
|
return _assetRepository.transaction(() async {
|
||||||
final idsToRemove = await _db.assets
|
await _assetRepository.deleteAllByRemoteId(
|
||||||
.remote(idsToDelete)
|
idsToDelete,
|
||||||
.filter()
|
state: AssetState.remote,
|
||||||
.localIdIsNull()
|
);
|
||||||
.idProperty()
|
final merged = await _assetRepository.getAllByRemoteId(
|
||||||
.findAll();
|
idsToDelete,
|
||||||
await _db.assets.deleteAll(idsToRemove);
|
state: AssetState.merged,
|
||||||
await _db.exifInfos.deleteAll(idsToRemove);
|
);
|
||||||
final onlyLocal = await _db.assets.remote(idsToDelete).findAll();
|
if (merged.isEmpty) return;
|
||||||
if (onlyLocal.isNotEmpty) {
|
for (final Asset asset in merged) {
|
||||||
for (final Asset a in onlyLocal) {
|
asset.remoteId = null;
|
||||||
a.remoteId = null;
|
asset.isTrashed = false;
|
||||||
a.isTrashed = false;
|
|
||||||
}
|
|
||||||
await _db.assets.putAll(onlyLocal);
|
|
||||||
}
|
}
|
||||||
|
await _assetRepository.updateAll(merged);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,12 +253,7 @@ class SyncService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await _syncUsersFromServer(serverUsers);
|
await _syncUsersFromServer(serverUsers);
|
||||||
final List<User> users = await _db.users
|
final List<User> users = await _userRepository.getAllAccessible();
|
||||||
.filter()
|
|
||||||
.isPartnerSharedWithEqualTo(true)
|
|
||||||
.or()
|
|
||||||
.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId)
|
|
||||||
.findAll();
|
|
||||||
bool changes = false;
|
bool changes = false;
|
||||||
for (User u in users) {
|
for (User u in users) {
|
||||||
changes |= await _syncRemoteAssetsForUser(u, loadAssets);
|
changes |= await _syncRemoteAssetsForUser(u, loadAssets);
|
||||||
@ -259,11 +270,10 @@ class SyncService {
|
|||||||
if (remote == null) {
|
if (remote == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final List<Asset> inDb = await _db.assets
|
final List<Asset> inDb = await _assetRepository.getAll(
|
||||||
.where()
|
ownerId: user.isarId,
|
||||||
.ownerIdEqualToAnyChecksum(user.isarId)
|
sortBy: AssetSort.checksum,
|
||||||
.sortByChecksum()
|
);
|
||||||
.findAll();
|
|
||||||
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
|
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
|
||||||
|
|
||||||
remote.sort(Asset.compareByChecksum);
|
remote.sort(Asset.compareByChecksum);
|
||||||
@ -278,9 +288,9 @@ class SyncService {
|
|||||||
}
|
}
|
||||||
final idsToDelete = toRemove.map((e) => e.id).toList();
|
final idsToDelete = toRemove.map((e) => e.id).toList();
|
||||||
try {
|
try {
|
||||||
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
|
await _assetRepository.deleteById(idsToDelete);
|
||||||
await upsertAssetsWithExif(toAdd + toUpdate);
|
await upsertAssetsWithExif(toAdd + toUpdate);
|
||||||
} on IsarError catch (e) {
|
} catch (e) {
|
||||||
_log.severe("Failed to sync remote assets to db", e);
|
_log.severe("Failed to sync remote assets to db", e);
|
||||||
}
|
}
|
||||||
await _updateUserAssetsETag([user], now);
|
await _updateUserAssetsETag([user], now);
|
||||||
@ -289,12 +299,12 @@ class SyncService {
|
|||||||
|
|
||||||
Future<void> _updateUserAssetsETag(List<User> users, DateTime time) {
|
Future<void> _updateUserAssetsETag(List<User> users, DateTime time) {
|
||||||
final etags = users.map((u) => ETag(id: u.id, time: time)).toList();
|
final etags = users.map((u) => ETag(id: u.id, time: time)).toList();
|
||||||
return _db.writeTxn(() => _db.eTags.putAll(etags));
|
return _eTagRepository.upsertAll(etags);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _clearUserAssetsETag(List<User> users) {
|
Future<void> _clearUserAssetsETag(List<User> users) {
|
||||||
final ids = users.map((u) => u.id).toList();
|
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
|
/// Syncs remote albums to the database
|
||||||
@ -305,15 +315,13 @@ class SyncService {
|
|||||||
) async {
|
) async {
|
||||||
remoteAlbums.sortBy((e) => e.remoteId!);
|
remoteAlbums.sortBy((e) => e.remoteId!);
|
||||||
|
|
||||||
final baseQuery = _db.albums.where().remoteIdIsNotNull().filter();
|
final User me = await _userRepository.me();
|
||||||
final QueryBuilder<Album, Album, QAfterFilterCondition> query;
|
final List<Album> dbAlbums = await _albumRepository.getAll(
|
||||||
if (isShared) {
|
remote: true,
|
||||||
query = baseQuery.sharedEqualTo(true);
|
shared: isShared ? true : null,
|
||||||
} else {
|
ownerId: isShared ? null : me.isarId,
|
||||||
final User me = Store.get(StoreKey.currentUser);
|
sortBy: AlbumSort.remoteId,
|
||||||
query = baseQuery.owner((q) => q.isarIdEqualTo(me.isarId));
|
);
|
||||||
}
|
|
||||||
final List<Album> dbAlbums = await query.sortByRemoteId().findAll();
|
|
||||||
assert(dbAlbums.isSortedBy((e) => e.remoteId!), "dbAlbums not sorted!");
|
assert(dbAlbums.isSortedBy((e) => e.remoteId!), "dbAlbums not sorted!");
|
||||||
|
|
||||||
final List<Asset> toDelete = [];
|
final List<Asset> toDelete = [];
|
||||||
@ -333,10 +341,7 @@ class SyncService {
|
|||||||
if (isShared && toDelete.isNotEmpty) {
|
if (isShared && toDelete.isNotEmpty) {
|
||||||
final List<int> idsToRemove = sharedAssetsToRemove(toDelete, existing);
|
final List<int> idsToRemove = sharedAssetsToRemove(toDelete, existing);
|
||||||
if (idsToRemove.isNotEmpty) {
|
if (idsToRemove.isNotEmpty) {
|
||||||
await _db.writeTxn(() async {
|
await _assetRepository.deleteById(idsToRemove);
|
||||||
await _db.assets.deleteAll(idsToRemove);
|
|
||||||
await _db.exifInfos.deleteAll(idsToRemove);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
assert(toDelete.isEmpty);
|
assert(toDelete.isEmpty);
|
||||||
@ -360,8 +365,11 @@ class SyncService {
|
|||||||
// i.e. it will always be null. Save it here.
|
// i.e. it will always be null. Save it here.
|
||||||
final originalDto = dto;
|
final originalDto = dto;
|
||||||
dto = await _albumApiRepository.get(dto.remoteId!);
|
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!");
|
assert(assetsInDb.isSorted(Asset.compareByOwnerChecksum), "inDb unsorted!");
|
||||||
final List<Asset> assetsOnRemote = dto.remoteAssets.toList();
|
final List<Asset> assetsOnRemote = dto.remoteAssets.toList();
|
||||||
assetsOnRemote.sort(Asset.compareByOwnerChecksum);
|
assetsOnRemote.sort(Asset.compareByOwnerChecksum);
|
||||||
@ -391,7 +399,7 @@ class SyncService {
|
|||||||
final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd);
|
final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd);
|
||||||
await upsertAssetsWithExif(updated);
|
await upsertAssetsWithExif(updated);
|
||||||
final assetsToLink = existingInDb + updated;
|
final assetsToLink = existingInDb + updated;
|
||||||
final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast<User>();
|
final usersToLink = await _userRepository.getByIds(userIdsToAdd);
|
||||||
|
|
||||||
album.name = dto.name;
|
album.name = dto.name;
|
||||||
album.shared = dto.shared;
|
album.shared = dto.shared;
|
||||||
@ -402,32 +410,33 @@ class SyncService {
|
|||||||
album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp;
|
album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp;
|
||||||
album.shared = dto.shared;
|
album.shared = dto.shared;
|
||||||
album.activityEnabled = dto.activityEnabled;
|
album.activityEnabled = dto.activityEnabled;
|
||||||
if (album.thumbnail.value?.remoteId != dto.remoteThumbnailAssetId) {
|
final remoteThumbnailAssetId = dto.remoteThumbnailAssetId;
|
||||||
album.thumbnail.value = await _db.assets
|
if (remoteThumbnailAssetId != null &&
|
||||||
.where()
|
album.thumbnail.value?.remoteId != remoteThumbnailAssetId) {
|
||||||
.remoteIdEqualTo(dto.remoteThumbnailAssetId)
|
album.thumbnail.value =
|
||||||
.findFirst();
|
await _assetRepository.getByRemoteId(remoteThumbnailAssetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// write & commit all changes to DB
|
// write & commit all changes to DB
|
||||||
try {
|
try {
|
||||||
await _db.writeTxn(() async {
|
await _assetRepository.transaction(() async {
|
||||||
await _db.assets.putAll(toUpdate);
|
await _assetRepository.updateAll(toUpdate);
|
||||||
await album.thumbnail.save();
|
await _albumRepository.addUsers(album, usersToLink);
|
||||||
await album.sharedUsers
|
await _albumRepository.removeUsers(album, usersToUnlink);
|
||||||
.update(link: usersToLink, unlink: usersToUnlink);
|
await _albumRepository.addAssets(album, assetsToLink);
|
||||||
await album.assets.update(link: assetsToLink, unlink: toUnlink.cast());
|
await _albumRepository.removeAssets(album, toUnlink);
|
||||||
await _db.albums.put(album);
|
await _albumRepository.recalculateMetadata(album);
|
||||||
|
await _albumRepository.update(album);
|
||||||
});
|
});
|
||||||
_log.info("Synced changes of remote album ${album.name} to DB");
|
_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);
|
_log.severe("Failed to sync remote album to database", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (album.shared || dto.shared) {
|
if (album.shared || dto.shared) {
|
||||||
final userId = Store.get(StoreKey.currentUser).isarId;
|
final userId = (await _userRepository.me()).isarId;
|
||||||
final foreign =
|
final foreign =
|
||||||
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
|
await _assetRepository.getByAlbum(album, notOwnedBy: [userId]);
|
||||||
existing.addAll(foreign);
|
existing.addAll(foreign);
|
||||||
|
|
||||||
// delete assets in DB unless they belong to this user or part of some other shared album
|
// 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 upsertAssetsWithExif(updated);
|
||||||
|
|
||||||
await _entityService.fillAlbumWithDatabaseEntities(album);
|
await _entityService.fillAlbumWithDatabaseEntities(album);
|
||||||
await _db.writeTxn(() => _db.albums.store(album));
|
await _albumRepository.create(album);
|
||||||
} else {
|
} else {
|
||||||
_log.warning(
|
_log.warning(
|
||||||
"Failed to add album from server: assetCount ${album.remoteAssetCount} != "
|
"Failed to add album from server: assetCount ${album.remoteAssetCount} != "
|
||||||
@ -474,27 +483,18 @@ class SyncService {
|
|||||||
_log.info("Removing local album $album from DB");
|
_log.info("Removing local album $album from DB");
|
||||||
// delete assets in DB unless they are remote or part of some other album
|
// delete assets in DB unless they are remote or part of some other album
|
||||||
deleteCandidates.addAll(
|
deleteCandidates.addAll(
|
||||||
await album.assets.filter().remoteIdIsNull().findAll(),
|
await _assetRepository.getByAlbum(album, state: AssetState.local),
|
||||||
);
|
);
|
||||||
} else if (album.shared) {
|
} 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
|
// 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
|
final userIds =
|
||||||
.filter()
|
(await _userRepository.getAllAccessible()).map((user) => user.isarId);
|
||||||
.isPartnerSharedWithEqualTo(true)
|
final orphanedAssets =
|
||||||
.isarIdProperty()
|
await _assetRepository.getByAlbum(album, notOwnedBy: userIds);
|
||||||
.findAll();
|
|
||||||
userIds.add(user.isarId);
|
|
||||||
final orphanedAssets = await album.assets
|
|
||||||
.filter()
|
|
||||||
.not()
|
|
||||||
.anyOf(userIds, (q, int id) => q.ownerIdEqualTo(id))
|
|
||||||
.findAll();
|
|
||||||
deleteCandidates.addAll(orphanedAssets);
|
deleteCandidates.addAll(orphanedAssets);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final bool ok = await _db.writeTxn(() => _db.albums.delete(album.id));
|
await _albumRepository.delete(album.id);
|
||||||
assert(ok);
|
|
||||||
_log.info("Removed local album $album from DB");
|
_log.info("Removed local album $album from DB");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.severe("Failed to remove local album $album from DB", e);
|
_log.severe("Failed to remove local album $album from DB", e);
|
||||||
@ -509,7 +509,7 @@ class SyncService {
|
|||||||
]) async {
|
]) async {
|
||||||
onDevice.sort((a, b) => a.id.compareTo(b.id));
|
onDevice.sort((a, b) => a.id.compareTo(b.id));
|
||||||
final inDb =
|
final inDb =
|
||||||
await _db.albums.where().localIdIsNotNull().sortByLocalId().findAll();
|
await _albumRepository.getAll(remote: false, sortBy: AlbumSort.localId);
|
||||||
final List<Asset> deleteCandidates = [];
|
final List<Asset> deleteCandidates = [];
|
||||||
final List<Asset> existing = [];
|
final List<Asset> existing = [];
|
||||||
assert(inDb.isSorted((a, b) => a.localId!.compareTo(b.localId!)), "sort!");
|
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",
|
"${toDelete.length} assets to delete, ${toUpdate.length} to update",
|
||||||
);
|
);
|
||||||
if (toDelete.isNotEmpty || toUpdate.isNotEmpty) {
|
if (toDelete.isNotEmpty || toUpdate.isNotEmpty) {
|
||||||
await _db.writeTxn(() async {
|
await _assetRepository.transaction(() async {
|
||||||
await _db.assets.deleteAll(toDelete);
|
await _assetRepository.deleteById(toDelete);
|
||||||
await _db.exifInfos.deleteAll(toDelete);
|
await _assetRepository.updateAll(toUpdate);
|
||||||
await _db.assets.putAll(toUpdate);
|
|
||||||
});
|
});
|
||||||
_log.info(
|
_log.info(
|
||||||
"Removed ${toDelete.length} and updated ${toUpdate.length} local assets from DB",
|
"Removed ${toDelete.length} and updated ${toUpdate.length} local assets from DB",
|
||||||
@ -570,13 +569,13 @@ class SyncService {
|
|||||||
await _syncDeviceAlbumFast(deviceAlbum, dbAlbum)) {
|
await _syncDeviceAlbumFast(deviceAlbum, dbAlbum)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
|
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
|
||||||
final inDb = await dbAlbum.assets
|
final inDb = await _assetRepository.getByAlbum(
|
||||||
.filter()
|
dbAlbum,
|
||||||
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
|
ownerId: (await _userRepository.me()).isarId,
|
||||||
.sortByChecksum()
|
sortBy: AssetSort.checksum,
|
||||||
.findAll();
|
);
|
||||||
|
|
||||||
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
|
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
|
||||||
final int assetCountOnDevice =
|
final int assetCountOnDevice =
|
||||||
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!);
|
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!);
|
||||||
@ -597,15 +596,14 @@ class SyncService {
|
|||||||
"Only excluded assets in local album ${deviceAlbum.name} changed. Stopping sync.",
|
"Only excluded assets in local album ${deviceAlbum.name} changed. Stopping sync.",
|
||||||
);
|
);
|
||||||
if (assetCountOnDevice !=
|
if (assetCountOnDevice !=
|
||||||
_db.eTags.getByIdSync(deviceAlbum.eTagKeyAssetCount)?.assetCount) {
|
(await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount))
|
||||||
await _db.writeTxn(
|
?.assetCount) {
|
||||||
() => _db.eTags.put(
|
await _eTagRepository.upsertAll([
|
||||||
ETag(
|
ETag(
|
||||||
id: deviceAlbum.eTagKeyAssetCount,
|
id: deviceAlbum.eTagKeyAssetCount,
|
||||||
assetCount: assetCountOnDevice,
|
assetCount: assetCountOnDevice,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
]);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -625,23 +623,21 @@ class SyncService {
|
|||||||
dbAlbum.thumbnail.value = null;
|
dbAlbum.thumbnail.value = null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await _db.writeTxn(() async {
|
await _assetRepository.transaction(() async {
|
||||||
await _db.assets.putAll(updated);
|
await _assetRepository.updateAll(updated + toUpdate);
|
||||||
await _db.assets.putAll(toUpdate);
|
await _albumRepository.addAssets(dbAlbum, existingInDb + updated);
|
||||||
await dbAlbum.assets
|
await _albumRepository.removeAssets(dbAlbum, toDelete);
|
||||||
.update(link: existingInDb + updated, unlink: toDelete);
|
await _albumRepository.recalculateMetadata(dbAlbum);
|
||||||
await _db.albums.put(dbAlbum);
|
await _albumRepository.update(dbAlbum);
|
||||||
dbAlbum.thumbnail.value ??= await dbAlbum.assets.filter().findFirst();
|
await _eTagRepository.upsertAll([
|
||||||
await dbAlbum.thumbnail.save();
|
|
||||||
await _db.eTags.put(
|
|
||||||
ETag(
|
ETag(
|
||||||
id: deviceAlbum.eTagKeyAssetCount,
|
id: deviceAlbum.eTagKeyAssetCount,
|
||||||
assetCount: assetCountOnDevice,
|
assetCount: assetCountOnDevice,
|
||||||
),
|
),
|
||||||
);
|
]);
|
||||||
});
|
});
|
||||||
_log.info("Synced changes of local album ${deviceAlbum.name} to DB");
|
_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);
|
_log.severe("Failed to update synced album ${deviceAlbum.name} in DB", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -657,7 +653,8 @@ class SyncService {
|
|||||||
final int totalOnDevice =
|
final int totalOnDevice =
|
||||||
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!);
|
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!);
|
||||||
final int lastKnownTotal =
|
final int lastKnownTotal =
|
||||||
(await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount))?.assetCount ??
|
(await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount))
|
||||||
|
?.assetCount ??
|
||||||
0;
|
0;
|
||||||
if (totalOnDevice <= lastKnownTotal) {
|
if (totalOnDevice <= lastKnownTotal) {
|
||||||
return false;
|
return false;
|
||||||
@ -675,16 +672,17 @@ class SyncService {
|
|||||||
_removeDuplicates(newAssets);
|
_removeDuplicates(newAssets);
|
||||||
final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets);
|
final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets);
|
||||||
try {
|
try {
|
||||||
await _db.writeTxn(() async {
|
await _assetRepository.transaction(() async {
|
||||||
await _db.assets.putAll(updated);
|
await _assetRepository.updateAll(updated);
|
||||||
await dbAlbum.assets.update(link: existingInDb + updated);
|
await _albumRepository.addAssets(dbAlbum, existingInDb + updated);
|
||||||
await _db.albums.put(dbAlbum);
|
await _albumRepository.recalculateMetadata(dbAlbum);
|
||||||
await _db.eTags.put(
|
await _albumRepository.update(dbAlbum);
|
||||||
ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice),
|
await _eTagRepository.upsertAll(
|
||||||
|
[ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice)],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
_log.info("Fast synced local album ${deviceAlbum.name} to DB");
|
_log.info("Fast synced local album ${deviceAlbum.name} to DB");
|
||||||
} on IsarError catch (e) {
|
} catch (e) {
|
||||||
_log.severe(
|
_log.severe(
|
||||||
"Failed to fast sync local album ${deviceAlbum.name} to DB",
|
"Failed to fast sync local album ${deviceAlbum.name} to DB",
|
||||||
e,
|
e,
|
||||||
@ -719,9 +717,9 @@ class SyncService {
|
|||||||
final thumb = existingInDb.firstOrNull ?? updated.firstOrNull;
|
final thumb = existingInDb.firstOrNull ?? updated.firstOrNull;
|
||||||
album.thumbnail.value = thumb;
|
album.thumbnail.value = thumb;
|
||||||
try {
|
try {
|
||||||
await _db.writeTxn(() => _db.albums.store(album));
|
await _albumRepository.create(album);
|
||||||
_log.info("Added a new local album to DB: ${album.name}");
|
_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);
|
_log.severe("Failed to add new local album ${album.name} to DB", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -732,7 +730,7 @@ class SyncService {
|
|||||||
) async {
|
) async {
|
||||||
if (assets.isEmpty) return ([].cast<Asset>(), [].cast<Asset>());
|
if (assets.isEmpty) return ([].cast<Asset>(), [].cast<Asset>());
|
||||||
|
|
||||||
final List<Asset?> inDb = await _db.assets.getAllByOwnerIdChecksum(
|
final List<Asset?> inDb = await _assetRepository.getAllByOwnerIdChecksum(
|
||||||
assets.map((a) => a.ownerId).toInt64List(),
|
assets.map((a) => a.ownerId).toInt64List(),
|
||||||
assets.map((a) => a.checksum).toList(growable: false),
|
assets.map((a) => a.checksum).toList(growable: false),
|
||||||
);
|
);
|
||||||
@ -746,7 +744,7 @@ class SyncService {
|
|||||||
}
|
}
|
||||||
if (b.canUpdate(assets[i])) {
|
if (b.canUpdate(assets[i])) {
|
||||||
final updated = b.updatedCopy(assets[i]);
|
final updated = b.updatedCopy(assets[i]);
|
||||||
assert(updated.id != Isar.autoIncrement);
|
assert(updated.isInDb);
|
||||||
toUpsert.add(updated);
|
toUpsert.add(updated);
|
||||||
} else {
|
} else {
|
||||||
existing.add(b);
|
existing.add(b);
|
||||||
@ -758,24 +756,22 @@ class SyncService {
|
|||||||
|
|
||||||
/// Inserts or updates the assets in the database with their ExifInfo (if any)
|
/// Inserts or updates the assets in the database with their ExifInfo (if any)
|
||||||
Future<void> upsertAssetsWithExif(List<Asset> assets) async {
|
Future<void> upsertAssetsWithExif(List<Asset> assets) async {
|
||||||
if (assets.isEmpty) {
|
if (assets.isEmpty) return;
|
||||||
return;
|
final exifInfos = assets.map((e) => e.exifInfo).nonNulls.toList();
|
||||||
}
|
|
||||||
final exifInfos = assets.map((e) => e.exifInfo).whereNotNull().toList();
|
|
||||||
try {
|
try {
|
||||||
await _db.writeTxn(() async {
|
await _assetRepository.transaction(() async {
|
||||||
await _db.assets.putAll(assets);
|
await _assetRepository.updateAll(assets);
|
||||||
for (final Asset added in assets) {
|
for (final Asset added in assets) {
|
||||||
added.exifInfo?.id = added.id;
|
added.exifInfo?.id = added.id;
|
||||||
}
|
}
|
||||||
await _db.exifInfos.putAll(exifInfos);
|
await _exifInfoRepository.updateAll(exifInfos);
|
||||||
});
|
});
|
||||||
_log.info("Upserted ${assets.length} assets into the DB");
|
_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);
|
_log.severe("Failed to upsert ${assets.length} assets into the DB", e);
|
||||||
// give details on the errors
|
// give details on the errors
|
||||||
assets.sort(Asset.compareByOwnerChecksum);
|
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.ownerId).toInt64List(),
|
||||||
assets.map((e) => e.checksum).toList(growable: false),
|
assets.map((e) => e.checksum).toList(growable: false),
|
||||||
);
|
);
|
||||||
@ -783,7 +779,7 @@ class SyncService {
|
|||||||
final Asset a = assets[i];
|
final Asset a = assets[i];
|
||||||
final Asset? b = inDb[i];
|
final Asset? b = inDb[i];
|
||||||
if (b == null) {
|
if (b == null) {
|
||||||
if (a.id != Isar.autoIncrement) {
|
if (!a.isInDb) {
|
||||||
_log.warning(
|
_log.warning(
|
||||||
"Trying to update an asset that does not exist in DB:\n$a",
|
"Trying to update an asset that does not exist in DB:\n$a",
|
||||||
);
|
);
|
||||||
@ -827,19 +823,19 @@ class SyncService {
|
|||||||
return deviceAlbum.name != dbAlbum.name ||
|
return deviceAlbum.name != dbAlbum.name ||
|
||||||
!deviceAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) ||
|
!deviceAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) ||
|
||||||
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!) !=
|
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!) !=
|
||||||
(await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount))
|
(await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount))
|
||||||
?.assetCount;
|
?.assetCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _removeAllLocalAlbumsAndAssets() async {
|
Future<bool> _removeAllLocalAlbumsAndAssets() async {
|
||||||
try {
|
try {
|
||||||
final assets = await _db.assets.where().localIdIsNotNull().findAll();
|
final assets = await _assetRepository.getAllLocal();
|
||||||
final (toDelete, toUpdate) =
|
final (toDelete, toUpdate) =
|
||||||
_handleAssetRemoval(assets, [], remote: false);
|
_handleAssetRemoval(assets, [], remote: false);
|
||||||
await _db.writeTxn(() async {
|
await _assetRepository.transaction(() async {
|
||||||
await _db.assets.deleteAll(toDelete);
|
await _assetRepository.deleteById(toDelete);
|
||||||
await _db.assets.putAll(toUpdate);
|
await _assetRepository.updateAll(toUpdate);
|
||||||
await _db.albums.where().localIdIsNotNull().deleteAll();
|
await _albumRepository.deleteAllLocal();
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
3
mobile/lib/utils/download.dart
Normal file
3
mobile/lib/utils/download.dart
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const downloadGroupImage = 'group_image';
|
||||||
|
const downloadGroupVideo = 'group_video';
|
||||||
|
const downloadGroupLivePhoto = 'group_livephoto';
|
@ -131,11 +131,7 @@ class MultiselectGrid extends HookConsumerWidget {
|
|||||||
processing.value = true;
|
processing.value = true;
|
||||||
if (shareLocal) {
|
if (shareLocal) {
|
||||||
// Share = Download + Send to OS specific share sheet
|
// Share = Download + Send to OS specific share sheet
|
||||||
// Filter offline assets since we cannot fetch their original file
|
handleShareAssets(ref, context, selection.value);
|
||||||
final liveAssets = selection.value.nonOfflineOnly(
|
|
||||||
errorCallback: errorBuilder('asset_action_share_err_offline'.tr()),
|
|
||||||
);
|
|
||||||
handleShareAssets(ref, context, liveAssets);
|
|
||||||
} else {
|
} else {
|
||||||
final ids =
|
final ids =
|
||||||
remoteSelection(errorMessage: "home_page_share_err_local".tr())
|
remoteSelection(errorMessage: "home_page_share_err_local".tr())
|
||||||
|
@ -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/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/album/shared_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/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/providers/asset_viewer/show_controls.provider.dart';
|
||||||
import 'package:immich_mobile/services/stack.service.dart';
|
import 'package:immich_mobile/services/stack.service.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
||||||
@ -172,7 +172,16 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shareAsset() {
|
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 {
|
void handleEdit() async {
|
||||||
@ -202,7 +211,17 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||||||
if (asset.isLocal) {
|
if (asset.isLocal) {
|
||||||
return;
|
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,
|
asset,
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
|
@ -5,7 +5,7 @@ import 'package:fluttertoast/fluttertoast.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/providers/album/current_album.provider.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/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/providers/asset_viewer/show_controls.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/top_control_app_bar.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/top_control_app_bar.dart';
|
||||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||||
@ -94,7 +94,7 @@ class GalleryAppBar extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleDownloadAsset() {
|
handleDownloadAsset() {
|
||||||
ref.read(imageViewerStateProvider.notifier).downloadAsset(asset, context);
|
ref.read(downloadStateProvider.notifier).downloadAsset(asset, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
|
@ -176,7 +176,7 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
populateTestLoginInfo1() {
|
populateTestLoginInfo1() {
|
||||||
usernameController.text = 'testuser@email.com';
|
usernameController.text = 'testuser@email.com';
|
||||||
passwordController.text = 'password';
|
passwordController.text = 'password';
|
||||||
serverEndpointController.text = 'http://10.1.15.216:2283/api';
|
serverEndpointController.text = 'http://192.168.1.16:2283/api';
|
||||||
}
|
}
|
||||||
|
|
||||||
login() async {
|
login() async {
|
||||||
|
9
mobile/openapi/lib/api/search_api.dart
generated
9
mobile/openapi/lib/api/search_api.dart
generated
@ -383,7 +383,7 @@ class SearchApi {
|
|||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
/// * [RandomSearchDto] randomSearchDto (required):
|
/// * [RandomSearchDto] randomSearchDto (required):
|
||||||
Future<SearchResponseDto?> searchRandom(RandomSearchDto randomSearchDto,) async {
|
Future<List<AssetResponseDto>?> searchRandom(RandomSearchDto randomSearchDto,) async {
|
||||||
final response = await searchRandomWithHttpInfo(randomSearchDto,);
|
final response = await searchRandomWithHttpInfo(randomSearchDto,);
|
||||||
if (response.statusCode >= HttpStatus.badRequest) {
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
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"
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
// FormatException when trying to decode an empty string.
|
// FormatException when trying to decode an empty string.
|
||||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
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<AssetResponseDto>') as List)
|
||||||
|
.cast<AssetResponseDto>()
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
20
mobile/openapi/lib/model/random_search_dto.dart
generated
20
mobile/openapi/lib/model/random_search_dto.dart
generated
@ -29,7 +29,6 @@ class RandomSearchDto {
|
|||||||
this.libraryId,
|
this.libraryId,
|
||||||
this.make,
|
this.make,
|
||||||
this.model,
|
this.model,
|
||||||
this.page,
|
|
||||||
this.personIds = const [],
|
this.personIds = const [],
|
||||||
this.size,
|
this.size,
|
||||||
this.state,
|
this.state,
|
||||||
@ -145,15 +144,6 @@ class RandomSearchDto {
|
|||||||
|
|
||||||
String? model;
|
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<String> personIds;
|
List<String> personIds;
|
||||||
|
|
||||||
/// Minimum value: 1
|
/// Minimum value: 1
|
||||||
@ -276,7 +266,6 @@ class RandomSearchDto {
|
|||||||
other.libraryId == libraryId &&
|
other.libraryId == libraryId &&
|
||||||
other.make == make &&
|
other.make == make &&
|
||||||
other.model == model &&
|
other.model == model &&
|
||||||
other.page == page &&
|
|
||||||
_deepEquality.equals(other.personIds, personIds) &&
|
_deepEquality.equals(other.personIds, personIds) &&
|
||||||
other.size == size &&
|
other.size == size &&
|
||||||
other.state == state &&
|
other.state == state &&
|
||||||
@ -312,7 +301,6 @@ class RandomSearchDto {
|
|||||||
(libraryId == null ? 0 : libraryId!.hashCode) +
|
(libraryId == null ? 0 : libraryId!.hashCode) +
|
||||||
(make == null ? 0 : make!.hashCode) +
|
(make == null ? 0 : make!.hashCode) +
|
||||||
(model == null ? 0 : model!.hashCode) +
|
(model == null ? 0 : model!.hashCode) +
|
||||||
(page == null ? 0 : page!.hashCode) +
|
|
||||||
(personIds.hashCode) +
|
(personIds.hashCode) +
|
||||||
(size == null ? 0 : size!.hashCode) +
|
(size == null ? 0 : size!.hashCode) +
|
||||||
(state == null ? 0 : state!.hashCode) +
|
(state == null ? 0 : state!.hashCode) +
|
||||||
@ -330,7 +318,7 @@ class RandomSearchDto {
|
|||||||
(withStacked == null ? 0 : withStacked!.hashCode);
|
(withStacked == null ? 0 : withStacked!.hashCode);
|
||||||
|
|
||||||
@override
|
@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<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@ -413,11 +401,6 @@ class RandomSearchDto {
|
|||||||
json[r'model'] = this.model;
|
json[r'model'] = this.model;
|
||||||
} else {
|
} else {
|
||||||
// json[r'model'] = null;
|
// json[r'model'] = null;
|
||||||
}
|
|
||||||
if (this.page != null) {
|
|
||||||
json[r'page'] = this.page;
|
|
||||||
} else {
|
|
||||||
// json[r'page'] = null;
|
|
||||||
}
|
}
|
||||||
json[r'personIds'] = this.personIds;
|
json[r'personIds'] = this.personIds;
|
||||||
if (this.size != null) {
|
if (this.size != null) {
|
||||||
@ -514,7 +497,6 @@ class RandomSearchDto {
|
|||||||
libraryId: mapValueOfType<String>(json, r'libraryId'),
|
libraryId: mapValueOfType<String>(json, r'libraryId'),
|
||||||
make: mapValueOfType<String>(json, r'make'),
|
make: mapValueOfType<String>(json, r'make'),
|
||||||
model: mapValueOfType<String>(json, r'model'),
|
model: mapValueOfType<String>(json, r'model'),
|
||||||
page: num.parse('${json[r'page']}'),
|
|
||||||
personIds: json[r'personIds'] is Iterable
|
personIds: json[r'personIds'] is Iterable
|
||||||
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
|
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
|
||||||
: const [],
|
: const [],
|
||||||
|
@ -78,6 +78,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.0.0"
|
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:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -744,10 +752,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2"
|
sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.6"
|
version: "1.2.2"
|
||||||
http_multi_server:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -32,7 +32,7 @@ dependencies:
|
|||||||
flutter_svg: ^2.0.9
|
flutter_svg: ^2.0.9
|
||||||
package_info_plus: ^8.0.1
|
package_info_plus: ^8.0.1
|
||||||
url_launcher: ^6.2.4
|
url_launcher: ^6.2.4
|
||||||
http: ^0.13.6
|
http: ^1.1.0
|
||||||
cancellation_token_http: ^2.0.0
|
cancellation_token_http: ^2.0.0
|
||||||
easy_localization: ^3.0.3
|
easy_localization: ^3.0.3
|
||||||
share_plus: ^10.0.0
|
share_plus: ^10.0.0
|
||||||
@ -56,6 +56,7 @@ dependencies:
|
|||||||
thumbhash: 0.1.0+1
|
thumbhash: 0.1.0+1
|
||||||
async: ^2.11.0
|
async: ^2.11.0
|
||||||
dynamic_color: ^1.7.0 #package to apply system theme
|
dynamic_color: ^1.7.0 #package to apply system theme
|
||||||
|
background_downloader: ^8.5.5
|
||||||
|
|
||||||
#image editing packages
|
#image editing packages
|
||||||
crop_image: ^1.0.13
|
crop_image: ^1.0.13
|
||||||
|
@ -1,17 +1,21 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.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/store.entity.dart';
|
||||||
import 'package:immich_mobile/entities/user.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/immich_logger.service.dart';
|
||||||
import 'package:immich_mobile/services/sync.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 '../../repository.mocks.dart';
|
||||||
import '../../service.mocks.dart';
|
import '../../service.mocks.dart';
|
||||||
import '../../test_utils.dart';
|
import '../../test_utils.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
int assetIdCounter = 0;
|
||||||
Asset makeAsset({
|
Asset makeAsset({
|
||||||
required String checksum,
|
required String checksum,
|
||||||
String? localId,
|
String? localId,
|
||||||
@ -20,6 +24,7 @@ void main() {
|
|||||||
}) {
|
}) {
|
||||||
final DateTime date = DateTime(2000);
|
final DateTime date = DateTime(2000);
|
||||||
return Asset(
|
return Asset(
|
||||||
|
id: assetIdCounter++,
|
||||||
checksum: checksum,
|
checksum: checksum,
|
||||||
localId: localId,
|
localId: localId,
|
||||||
remoteId: remoteId,
|
remoteId: remoteId,
|
||||||
@ -37,9 +42,13 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group('Test SyncService grouped', () {
|
group('Test SyncService grouped', () {
|
||||||
late final Isar db;
|
|
||||||
final MockHashService hs = MockHashService();
|
final MockHashService hs = MockHashService();
|
||||||
final MockEntityService entityService = MockEntityService();
|
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 =
|
final MockAlbumMediaRepository albumMediaRepository =
|
||||||
MockAlbumMediaRepository();
|
MockAlbumMediaRepository();
|
||||||
final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository();
|
final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository();
|
||||||
@ -53,7 +62,7 @@ void main() {
|
|||||||
late SyncService s;
|
late SyncService s;
|
||||||
setUpAll(() async {
|
setUpAll(() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
db = await TestUtils.initIsar();
|
final db = await TestUtils.initIsar();
|
||||||
ImmichLogger();
|
ImmichLogger();
|
||||||
db.writeTxnSync(() => db.clearSync());
|
db.writeTxnSync(() => db.clearSync());
|
||||||
Store.init(db);
|
Store.init(db);
|
||||||
@ -67,16 +76,43 @@ void main() {
|
|||||||
makeAsset(checksum: "e", localId: "3"),
|
makeAsset(checksum: "e", localId: "3"),
|
||||||
];
|
];
|
||||||
setUp(() {
|
setUp(() {
|
||||||
db.writeTxnSync(() {
|
|
||||||
db.assets.clearSync();
|
|
||||||
db.assets.putAllSync(initialAssets);
|
|
||||||
});
|
|
||||||
s = SyncService(
|
s = SyncService(
|
||||||
db,
|
|
||||||
hs,
|
hs,
|
||||||
entityService,
|
entityService,
|
||||||
albumMediaRepository,
|
albumMediaRepository,
|
||||||
albumApiRepository,
|
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<void>(any())).thenAnswer(
|
||||||
|
(call) => (call.positionalArguments.first as Function).call(),
|
||||||
|
);
|
||||||
|
when(() => assetRepository.transaction<Null>(any())).thenAnswer(
|
||||||
|
(call) => (call.positionalArguments.first as Function).call(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
test('test inserting existing assets', () async {
|
test('test inserting existing assets', () async {
|
||||||
@ -85,7 +121,6 @@ void main() {
|
|||||||
makeAsset(checksum: "b", remoteId: "2-1"),
|
makeAsset(checksum: "b", remoteId: "2-1"),
|
||||||
makeAsset(checksum: "c", remoteId: "1-1"),
|
makeAsset(checksum: "c", remoteId: "1-1"),
|
||||||
];
|
];
|
||||||
expect(db.assets.countSync(), 5);
|
|
||||||
final bool c1 = await s.syncRemoteAssetsToDb(
|
final bool c1 = await s.syncRemoteAssetsToDb(
|
||||||
users: [owner],
|
users: [owner],
|
||||||
getChangedAssets: _failDiff,
|
getChangedAssets: _failDiff,
|
||||||
@ -93,7 +128,7 @@ void main() {
|
|||||||
refreshUsers: () => [owner],
|
refreshUsers: () => [owner],
|
||||||
);
|
);
|
||||||
expect(c1, isFalse);
|
expect(c1, isFalse);
|
||||||
expect(db.assets.countSync(), 5);
|
verifyNever(() => assetRepository.updateAll(any()));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test inserting new assets', () async {
|
test('test inserting new assets', () async {
|
||||||
@ -105,7 +140,6 @@ void main() {
|
|||||||
makeAsset(checksum: "f", remoteId: "1-4"),
|
makeAsset(checksum: "f", remoteId: "1-4"),
|
||||||
makeAsset(checksum: "g", remoteId: "3-1"),
|
makeAsset(checksum: "g", remoteId: "3-1"),
|
||||||
];
|
];
|
||||||
expect(db.assets.countSync(), 5);
|
|
||||||
final bool c1 = await s.syncRemoteAssetsToDb(
|
final bool c1 = await s.syncRemoteAssetsToDb(
|
||||||
users: [owner],
|
users: [owner],
|
||||||
getChangedAssets: _failDiff,
|
getChangedAssets: _failDiff,
|
||||||
@ -113,7 +147,11 @@ void main() {
|
|||||||
refreshUsers: () => [owner],
|
refreshUsers: () => [owner],
|
||||||
);
|
);
|
||||||
expect(c1, isTrue);
|
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 {
|
test('test syncing duplicate assets', () async {
|
||||||
@ -125,7 +163,6 @@ void main() {
|
|||||||
makeAsset(checksum: "i", remoteId: "2-1c"),
|
makeAsset(checksum: "i", remoteId: "2-1c"),
|
||||||
makeAsset(checksum: "j", remoteId: "2-1d"),
|
makeAsset(checksum: "j", remoteId: "2-1d"),
|
||||||
];
|
];
|
||||||
expect(db.assets.countSync(), 5);
|
|
||||||
final bool c1 = await s.syncRemoteAssetsToDb(
|
final bool c1 = await s.syncRemoteAssetsToDb(
|
||||||
users: [owner],
|
users: [owner],
|
||||||
getChangedAssets: _failDiff,
|
getChangedAssets: _failDiff,
|
||||||
@ -133,7 +170,12 @@ void main() {
|
|||||||
refreshUsers: () => [owner],
|
refreshUsers: () => [owner],
|
||||||
);
|
);
|
||||||
expect(c1, isTrue);
|
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(
|
final bool c2 = await s.syncRemoteAssetsToDb(
|
||||||
users: [owner],
|
users: [owner],
|
||||||
getChangedAssets: _failDiff,
|
getChangedAssets: _failDiff,
|
||||||
@ -141,7 +183,13 @@ void main() {
|
|||||||
refreshUsers: () => [owner],
|
refreshUsers: () => [owner],
|
||||||
);
|
);
|
||||||
expect(c2, isFalse);
|
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);
|
remoteAssets.removeAt(4);
|
||||||
final bool c3 = await s.syncRemoteAssetsToDb(
|
final bool c3 = await s.syncRemoteAssetsToDb(
|
||||||
users: [owner],
|
users: [owner],
|
||||||
@ -150,7 +198,6 @@ void main() {
|
|||||||
refreshUsers: () => [owner],
|
refreshUsers: () => [owner],
|
||||||
);
|
);
|
||||||
expect(c3, isTrue);
|
expect(c3, isTrue);
|
||||||
expect(db.assets.countSync(), 7);
|
|
||||||
remoteAssets.add(makeAsset(checksum: "k", remoteId: "2-1e"));
|
remoteAssets.add(makeAsset(checksum: "k", remoteId: "2-1e"));
|
||||||
remoteAssets.add(makeAsset(checksum: "l", remoteId: "2-2"));
|
remoteAssets.add(makeAsset(checksum: "l", remoteId: "2-2"));
|
||||||
final bool c4 = await s.syncRemoteAssetsToDb(
|
final bool c4 = await s.syncRemoteAssetsToDb(
|
||||||
@ -160,10 +207,21 @@ void main() {
|
|||||||
refreshUsers: () => [owner],
|
refreshUsers: () => [owner],
|
||||||
);
|
);
|
||||||
expect(c4, isTrue);
|
expect(c4, isTrue);
|
||||||
expect(db.assets.countSync(), 9);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test efficient sync', () async {
|
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<Asset> toUpsert = [
|
final List<Asset> toUpsert = [
|
||||||
makeAsset(checksum: "a", remoteId: "0-1"), // changed
|
makeAsset(checksum: "a", remoteId: "0-1"), // changed
|
||||||
makeAsset(checksum: "f", remoteId: "0-2"), // new
|
makeAsset(checksum: "f", remoteId: "0-2"), // new
|
||||||
@ -171,6 +229,8 @@ void main() {
|
|||||||
];
|
];
|
||||||
toUpsert[0].isFavorite = true;
|
toUpsert[0].isFavorite = true;
|
||||||
final List<String> toDelete = ["2-1", "1-1"];
|
final List<String> toDelete = ["2-1", "1-1"];
|
||||||
|
final expected = [...toUpsert];
|
||||||
|
expected[0].id = initialAssets[0].id;
|
||||||
final bool c = await s.syncRemoteAssetsToDb(
|
final bool c = await s.syncRemoteAssetsToDb(
|
||||||
users: [owner],
|
users: [owner],
|
||||||
getChangedAssets: (user, since) async => (toUpsert, toDelete),
|
getChangedAssets: (user, since) async => (toUpsert, toDelete),
|
||||||
@ -178,7 +238,7 @@ void main() {
|
|||||||
refreshUsers: () => throw Exception(),
|
refreshUsers: () => throw Exception(),
|
||||||
);
|
);
|
||||||
expect(c, isTrue);
|
expect(c, isTrue);
|
||||||
expect(db.assets.countSync(), 6);
|
verify(() => assetRepository.updateAll(expected));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -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.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/asset_media.interface.dart';
|
import 'package:immich_mobile/interfaces/asset_media.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/backup.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/file_media.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/user.interface.dart';
|
import 'package:immich_mobile/interfaces/user.interface.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
@ -16,6 +18,10 @@ class MockUserRepository extends Mock implements IUserRepository {}
|
|||||||
|
|
||||||
class MockBackupRepository extends Mock implements IBackupRepository {}
|
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 MockAlbumMediaRepository extends Mock implements IAlbumMediaRepository {}
|
||||||
|
|
||||||
class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {}
|
class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {}
|
||||||
|
@ -29,6 +29,13 @@ void main() {
|
|||||||
albumMediaRepository = MockAlbumMediaRepository();
|
albumMediaRepository = MockAlbumMediaRepository();
|
||||||
albumApiRepository = MockAlbumApiRepository();
|
albumApiRepository = MockAlbumApiRepository();
|
||||||
|
|
||||||
|
when(() => albumRepository.transaction<void>(any())).thenAnswer(
|
||||||
|
(call) => (call.positionalArguments.first as Function).call(),
|
||||||
|
);
|
||||||
|
when(() => assetRepository.transaction<Null>(any())).thenAnswer(
|
||||||
|
(call) => (call.positionalArguments.first as Function).call(),
|
||||||
|
);
|
||||||
|
|
||||||
sut = AlbumService(
|
sut = AlbumService(
|
||||||
userService,
|
userService,
|
||||||
syncService,
|
syncService,
|
||||||
@ -144,7 +151,7 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
when(
|
when(
|
||||||
() => albumRepository.getById(AlbumStub.oneAsset.id),
|
() => albumRepository.get(AlbumStub.oneAsset.id),
|
||||||
).thenAnswer((_) async => AlbumStub.oneAsset);
|
).thenAnswer((_) async => AlbumStub.oneAsset);
|
||||||
when(
|
when(
|
||||||
() => albumRepository.addAssets(AlbumStub.oneAsset, [AssetStub.image2]),
|
() => albumRepository.addAssets(AlbumStub.oneAsset, [AssetStub.image2]),
|
||||||
|
@ -4615,7 +4615,10 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/SearchResponseDto"
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/AssetResponseDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -10463,10 +10466,6 @@
|
|||||||
"nullable": true,
|
"nullable": true,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"page": {
|
|
||||||
"minimum": 1,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"personIds": {
|
"personIds": {
|
||||||
"items": {
|
"items": {
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
|
@ -852,7 +852,6 @@ export type RandomSearchDto = {
|
|||||||
libraryId?: string | null;
|
libraryId?: string | null;
|
||||||
make?: string;
|
make?: string;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
page?: number;
|
|
||||||
personIds?: string[];
|
personIds?: string[];
|
||||||
size?: number;
|
size?: number;
|
||||||
state?: string | null;
|
state?: string | null;
|
||||||
@ -2523,7 +2522,7 @@ export function searchRandom({ randomSearchDto }: {
|
|||||||
}, opts?: Oazapfts.RequestOpts) {
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
return oazapfts.ok(oazapfts.fetchJson<{
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
status: 200;
|
status: 200;
|
||||||
data: SearchResponseDto;
|
data: AssetResponseDto[];
|
||||||
}>("/search/random", oazapfts.json({
|
}>("/search/random", oazapfts.json({
|
||||||
...opts,
|
...opts,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -2,7 +2,6 @@ import { BullModule } from '@nestjs/bullmq';
|
|||||||
import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common';
|
import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@nestjs/core';
|
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 { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
@ -42,7 +41,6 @@ const imports = [
|
|||||||
BullModule.registerQueue(...bullQueues),
|
BullModule.registerQueue(...bullQueues),
|
||||||
ClsModule.forRoot(clsConfig),
|
ClsModule.forRoot(clsConfig),
|
||||||
ConfigModule.forRoot(immichAppConfig),
|
ConfigModule.forRoot(immichAppConfig),
|
||||||
EventEmitterModule.forRoot(),
|
|
||||||
OpenTelemetryModule.forRoot(otelConfig),
|
OpenTelemetryModule.forRoot(otelConfig),
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
inject: [ModuleRef],
|
inject: [ModuleRef],
|
||||||
@ -114,16 +112,3 @@ export class MicroservicesModule implements OnModuleInit, OnModuleDestroy {
|
|||||||
providers: [...common, ...commands, SchedulerRegistry],
|
providers: [...common, ...commands, SchedulerRegistry],
|
||||||
})
|
})
|
||||||
export class ImmichAdminModule {}
|
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 {}
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
|
||||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
@ -85,7 +84,6 @@ class SqlGenerator {
|
|||||||
logger: this.sqlLogger,
|
logger: this.sqlLogger,
|
||||||
}),
|
}),
|
||||||
TypeOrmModule.forFeature(entities),
|
TypeOrmModule.forFeature(entities),
|
||||||
EventEmitterModule.forRoot(),
|
|
||||||
OpenTelemetryModule.forRoot(otelConfig),
|
OpenTelemetryModule.forRoot(otelConfig),
|
||||||
],
|
],
|
||||||
providers: [...repositories, AuthService, SchedulerRegistry],
|
providers: [...repositories, AuthService, SchedulerRegistry],
|
||||||
|
@ -20,7 +20,7 @@ import {
|
|||||||
VideoContainer,
|
VideoContainer,
|
||||||
} from 'src/enum';
|
} from 'src/enum';
|
||||||
import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface';
|
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 {
|
export interface SystemConfig {
|
||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
@ -110,8 +110,8 @@ export interface SystemConfig {
|
|||||||
template: string;
|
template: string;
|
||||||
};
|
};
|
||||||
image: {
|
image: {
|
||||||
thumbnail: ImageOutputConfig;
|
thumbnail: ImageOptions;
|
||||||
preview: ImageOutputConfig;
|
preview: ImageOptions;
|
||||||
colorspace: Colorspace;
|
colorspace: Colorspace;
|
||||||
extractEmbedded: boolean;
|
extractEmbedded: boolean;
|
||||||
};
|
};
|
||||||
|
@ -32,7 +32,7 @@ export class SearchController {
|
|||||||
@Post('random')
|
@Post('random')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Authenticated()
|
@Authenticated()
|
||||||
searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise<SearchResponseDto> {
|
searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise<AssetResponseDto[]> {
|
||||||
return this.service.searchRandom(auth, dto);
|
return this.service.searchRandom(auth, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,7 +4,6 @@ import { plainToInstance } from 'class-transformer';
|
|||||||
import { validate } from 'class-validator';
|
import { validate } from 'class-validator';
|
||||||
import { load as loadYaml } from 'js-yaml';
|
import { load as loadYaml } from 'js-yaml';
|
||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
import { Subject } from 'rxjs';
|
|
||||||
import { SystemConfig, defaults } from 'src/config';
|
import { SystemConfig, defaults } from 'src/config';
|
||||||
import { SystemConfigDto } from 'src/dtos/system-config.dto';
|
import { SystemConfigDto } from 'src/dtos/system-config.dto';
|
||||||
import { SystemMetadataKey } from 'src/enum';
|
import { SystemMetadataKey } from 'src/enum';
|
||||||
@ -24,8 +23,6 @@ export class SystemConfigCore {
|
|||||||
private config: SystemConfig | null = null;
|
private config: SystemConfig | null = null;
|
||||||
private lastUpdated: number | null = null;
|
private lastUpdated: number | null = null;
|
||||||
|
|
||||||
config$ = new Subject<SystemConfig>();
|
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
private repository: ISystemMetadataRepository,
|
private repository: ISystemMetadataRepository,
|
||||||
private logger: ILoggerRepository,
|
private logger: ILoggerRepository,
|
||||||
@ -42,6 +39,11 @@ export class SystemConfigCore {
|
|||||||
instance = null;
|
instance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateCache() {
|
||||||
|
this.config = null;
|
||||||
|
this.lastUpdated = null;
|
||||||
|
}
|
||||||
|
|
||||||
async getConfig({ withCache }: { withCache: boolean }): Promise<SystemConfig> {
|
async getConfig({ withCache }: { withCache: boolean }): Promise<SystemConfig> {
|
||||||
if (!withCache || !this.config) {
|
if (!withCache || !this.config) {
|
||||||
const lastUpdated = this.lastUpdated;
|
const lastUpdated = this.lastUpdated;
|
||||||
@ -74,14 +76,7 @@ export class SystemConfigCore {
|
|||||||
|
|
||||||
await this.repository.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig);
|
await this.repository.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig);
|
||||||
|
|
||||||
const config = await this.getConfig({ withCache: false });
|
return this.getConfig({ withCache: false });
|
||||||
this.config$.next(config);
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
async refreshConfig() {
|
|
||||||
const newConfig = await this.getConfig({ withCache: false });
|
|
||||||
this.config$.next(newConfig);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isUsingConfigFile() {
|
isUsingConfigFile() {
|
||||||
|
@ -1,11 +1,9 @@
|
|||||||
import { SetMetadata, applyDecorators } from '@nestjs/common';
|
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 { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants';
|
import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants';
|
||||||
import { MetadataKey } from 'src/enum';
|
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';
|
import { setUnion } from 'src/utils/set';
|
||||||
|
|
||||||
// PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the
|
// 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 */
|
/** Decorator to enable versioning/tracking of generated Sql */
|
||||||
export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options);
|
export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options);
|
||||||
|
|
||||||
export const OnServerEvent = (event: ServerEvent, options?: OnEventOptions) =>
|
export type EventConfig = {
|
||||||
OnEvent(event, { suppressErrors: false, ...options });
|
name: EmitEvent;
|
||||||
|
/** handle socket.io server events as well */
|
||||||
export type EmitConfig = {
|
server?: boolean;
|
||||||
event: EmitEvent;
|
|
||||||
/** lower value has higher priority, defaults to 0 */
|
/** lower value has higher priority, defaults to 0 */
|
||||||
priority?: number;
|
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 LifecycleRelease = 'NEXT_RELEASE' | string;
|
||||||
type LifecycleMetadata = {
|
type LifecycleMetadata = {
|
||||||
|
@ -99,12 +99,6 @@ class BaseSearchDto {
|
|||||||
@Optional({ nullable: true, emptyToNull: true })
|
@Optional({ nullable: true, emptyToNull: true })
|
||||||
lensModel?: string | null;
|
lensModel?: string | null;
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@Type(() => Number)
|
|
||||||
@Optional()
|
|
||||||
page?: number;
|
|
||||||
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(1000)
|
@Max(1000)
|
||||||
@ -170,12 +164,24 @@ export class MetadataSearchDto extends RandomSearchDto {
|
|||||||
@Optional()
|
@Optional()
|
||||||
@ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder })
|
@ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder })
|
||||||
order?: AssetOrder;
|
order?: AssetOrder;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Type(() => Number)
|
||||||
|
@Optional()
|
||||||
|
page?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SmartSearchDto extends BaseSearchDto {
|
export class SmartSearchDto extends BaseSearchDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
query!: string;
|
query!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Type(() => Number)
|
||||||
|
@Optional()
|
||||||
|
page?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SearchPlacesDto {
|
export class SearchPlacesDto {
|
||||||
|
@ -492,7 +492,7 @@ class SystemConfigGeneratedImageDto {
|
|||||||
size!: number;
|
size!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class SystemConfigImageDto {
|
export class SystemConfigImageDto {
|
||||||
@Type(() => SystemConfigGeneratedImageDto)
|
@Type(() => SystemConfigGeneratedImageDto)
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
|
@ -310,7 +310,7 @@ export enum MetadataKey {
|
|||||||
ADMIN_ROUTE = 'admin_route',
|
ADMIN_ROUTE = 'admin_route',
|
||||||
SHARED_ROUTE = 'shared_route',
|
SHARED_ROUTE = 'shared_route',
|
||||||
API_KEY_SECURITY = 'api_key',
|
API_KEY_SECURITY = 'api_key',
|
||||||
ON_EMIT_CONFIG = 'on_emit_config',
|
EVENT_CONFIG = 'event_config',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum RouteKey {
|
export enum RouteKey {
|
||||||
|
@ -141,6 +141,12 @@ export interface AssetUpdateDuplicateOptions {
|
|||||||
duplicateIds: string[];
|
duplicateIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpsertFileOptions {
|
||||||
|
assetId: string;
|
||||||
|
type: AssetFileType;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type AssetPathEntity = Pick<AssetEntity, 'id' | 'originalPath' | 'isOffline'>;
|
export type AssetPathEntity = Pick<AssetEntity, 'id' | 'originalPath' | 'isOffline'>;
|
||||||
|
|
||||||
export const IAssetRepository = 'IAssetRepository';
|
export const IAssetRepository = 'IAssetRepository';
|
||||||
@ -194,5 +200,6 @@ export interface IAssetRepository {
|
|||||||
getDuplicates(options: AssetBuilderOptions): Promise<AssetEntity[]>;
|
getDuplicates(options: AssetBuilderOptions): Promise<AssetEntity[]>;
|
||||||
getAllForUserFullSync(options: AssetFullSyncOptions): Promise<AssetEntity[]>;
|
getAllForUserFullSync(options: AssetFullSyncOptions): Promise<AssetEntity[]>;
|
||||||
getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise<AssetEntity[]>;
|
getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise<AssetEntity[]>;
|
||||||
upsertFile(options: { assetId: string; type: AssetFileType; path: string }): Promise<void>;
|
upsertFile(file: UpsertFileOptions): Promise<void>;
|
||||||
|
upsertFiles(files: UpsertFileOptions[]): Promise<void>;
|
||||||
}
|
}
|
||||||
|
@ -4,13 +4,19 @@ import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.d
|
|||||||
|
|
||||||
export const IEventRepository = 'IEventRepository';
|
export const IEventRepository = 'IEventRepository';
|
||||||
|
|
||||||
type EmitEventMap = {
|
type EventMap = {
|
||||||
// app events
|
// app events
|
||||||
'app.bootstrap': ['api' | 'microservices'];
|
'app.bootstrap': ['api' | 'microservices'];
|
||||||
'app.shutdown': [];
|
'app.shutdown': [];
|
||||||
|
|
||||||
// config events
|
// 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 }];
|
'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
|
||||||
|
|
||||||
// album events
|
// album events
|
||||||
@ -43,12 +49,18 @@ type EmitEventMap = {
|
|||||||
|
|
||||||
// user events
|
// user events
|
||||||
'user.signup': [{ notify: boolean; id: string; tempPassword?: string }];
|
'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<T extends EmitEvent> = (...args: ArgsOf<T>) => Promise<void> | void;
|
export type EmitHandler<T extends EmitEvent> = (...args: ArgsOf<T>) => Promise<void> | void;
|
||||||
export type ArgOf<T extends EmitEvent> = EmitEventMap[T][0];
|
export type ArgOf<T extends EmitEvent> = EventMap[T][0];
|
||||||
export type ArgsOf<T extends EmitEvent> = EmitEventMap[T];
|
export type ArgsOf<T extends EmitEvent> = EventMap[T];
|
||||||
|
|
||||||
export enum ClientEvent {
|
export enum ClientEvent {
|
||||||
UPLOAD_SUCCESS = 'on_upload_success',
|
UPLOAD_SUCCESS = 'on_upload_success',
|
||||||
@ -82,19 +94,15 @@ export interface ClientEventMap {
|
|||||||
[ClientEvent.SESSION_DELETE]: string;
|
[ClientEvent.SESSION_DELETE]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ServerEvent {
|
export type EventItem<T extends EmitEvent> = {
|
||||||
CONFIG_UPDATE = 'config.update',
|
event: T;
|
||||||
WEBSOCKET_CONNECT = 'websocket.connect',
|
handler: EmitHandler<T>;
|
||||||
}
|
server: boolean;
|
||||||
|
};
|
||||||
export interface ServerEventMap {
|
|
||||||
[ServerEvent.CONFIG_UPDATE]: null;
|
|
||||||
[ServerEvent.WEBSOCKET_CONNECT]: { userId: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IEventRepository {
|
export interface IEventRepository {
|
||||||
on<T extends keyof EmitEventMap>(event: T, handler: EmitHandler<T>): void;
|
on<T extends keyof EventMap>(item: EventItem<T>): void;
|
||||||
emit<T extends keyof EmitEventMap>(event: T, ...args: ArgsOf<T>): Promise<void>;
|
emit<T extends keyof EventMap>(event: T, ...args: ArgsOf<T>): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send to connected clients for a specific user
|
* Send to connected clients for a specific user
|
||||||
@ -105,7 +113,7 @@ export interface IEventRepository {
|
|||||||
*/
|
*/
|
||||||
clientBroadcast<E extends keyof ClientEventMap>(event: E, data: ClientEventMap[E]): void;
|
clientBroadcast<E extends keyof ClientEventMap>(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<E extends keyof ServerEventMap>(event: E, data: ServerEventMap[E]): boolean;
|
serverSend<T extends ServerEvents>(event: T, ...args: ArgsOf<T>): void;
|
||||||
}
|
}
|
||||||
|
@ -37,9 +37,7 @@ export enum JobName {
|
|||||||
|
|
||||||
// thumbnails
|
// thumbnails
|
||||||
QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails',
|
QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails',
|
||||||
GENERATE_PREVIEW = 'generate-preview',
|
GENERATE_THUMBNAILS = 'generate-thumbnails',
|
||||||
GENERATE_THUMBNAIL = 'generate-thumbnail',
|
|
||||||
GENERATE_THUMBHASH = 'generate-thumbhash',
|
|
||||||
GENERATE_PERSON_THUMBNAIL = 'generate-person-thumbnail',
|
GENERATE_PERSON_THUMBNAIL = 'generate-person-thumbnail',
|
||||||
|
|
||||||
// metadata
|
// metadata
|
||||||
@ -212,9 +210,7 @@ export type JobItem =
|
|||||||
|
|
||||||
// Thumbnails
|
// Thumbnails
|
||||||
| { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob }
|
| { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob }
|
||||||
| { name: JobName.GENERATE_PREVIEW; data: IEntityJob }
|
| { name: JobName.GENERATE_THUMBNAILS; data: IEntityJob }
|
||||||
| { name: JobName.GENERATE_THUMBNAIL; data: IEntityJob }
|
|
||||||
| { name: JobName.GENERATE_THUMBHASH; data: IEntityJob }
|
|
||||||
|
|
||||||
// User
|
// User
|
||||||
| { name: JobName.USER_DELETE_CHECK; data?: IBaseJob }
|
| { name: JobName.USER_DELETE_CHECK; data?: IBaseJob }
|
||||||
|
@ -10,16 +10,44 @@ export interface CropOptions {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageOutputConfig {
|
export interface ImageOptions {
|
||||||
format: ImageFormat;
|
format: ImageFormat;
|
||||||
quality: number;
|
quality: number;
|
||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThumbnailOptions extends ImageOutputConfig {
|
export interface RawImageInfo {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
channels: 1 | 2 | 3 | 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecodeImageOptions {
|
||||||
colorspace: string;
|
colorspace: string;
|
||||||
crop?: CropOptions;
|
crop?: CropOptions;
|
||||||
processInvalidImages: boolean;
|
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 {
|
export interface VideoStreamInfo {
|
||||||
@ -78,6 +106,11 @@ export interface BitrateDistribution {
|
|||||||
unit: string;
|
unit: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImageBuffer {
|
||||||
|
data: Buffer;
|
||||||
|
info: RawImageInfo;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VideoCodecSWConfig {
|
export interface VideoCodecSWConfig {
|
||||||
getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeCommand;
|
getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeCommand;
|
||||||
}
|
}
|
||||||
@ -93,8 +126,11 @@ export interface ProbeOptions {
|
|||||||
export interface IMediaRepository {
|
export interface IMediaRepository {
|
||||||
// image
|
// image
|
||||||
extract(input: string, output: string): Promise<boolean>;
|
extract(input: string, output: string): Promise<boolean>;
|
||||||
generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise<void>;
|
decodeImage(input: string, options: DecodeToBufferOptions): Promise<ImageBuffer>;
|
||||||
generateThumbhash(imagePath: string): Promise<Buffer>;
|
generateThumbnail(input: string, options: GenerateThumbnailOptions, outputFile: string): Promise<void>;
|
||||||
|
generateThumbnail(input: Buffer, options: GenerateThumbnailFromBufferOptions, outputFile: string): Promise<void>;
|
||||||
|
generateThumbhash(input: string, options: GenerateThumbhashOptions): Promise<Buffer>;
|
||||||
|
generateThumbhash(input: Buffer, options: GenerateThumbhashFromBufferOptions): Promise<Buffer>;
|
||||||
getImageDimensions(input: string): Promise<ImageDimensions>;
|
getImageDimensions(input: string): Promise<ImageDimensions>;
|
||||||
|
|
||||||
// video
|
// video
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { PersonEntity } from 'src/entities/person.entity';
|
import { PersonEntity } from 'src/entities/person.entity';
|
||||||
|
import { SourceType } from 'src/enum';
|
||||||
import { Paginated, PaginationOptions } from 'src/utils/pagination';
|
import { Paginated, PaginationOptions } from 'src/utils/pagination';
|
||||||
import { FindManyOptions, FindOptionsRelations, FindOptionsSelect } from 'typeorm';
|
import { FindManyOptions, FindOptionsRelations, FindOptionsSelect } from 'typeorm';
|
||||||
|
|
||||||
@ -40,10 +41,12 @@ export interface PeopleStatistics {
|
|||||||
hidden: number;
|
hidden: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteAllFacesOptions {
|
export interface DeleteFacesOptions {
|
||||||
sourceType?: string;
|
sourceType: SourceType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UnassignFacesOptions = DeleteFacesOptions;
|
||||||
|
|
||||||
export interface IPersonRepository {
|
export interface IPersonRepository {
|
||||||
getAll(pagination: PaginationOptions, options?: FindManyOptions<PersonEntity>): Paginated<PersonEntity>;
|
getAll(pagination: PaginationOptions, options?: FindManyOptions<PersonEntity>): Paginated<PersonEntity>;
|
||||||
getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated<PersonEntity>;
|
getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated<PersonEntity>;
|
||||||
@ -59,7 +62,7 @@ export interface IPersonRepository {
|
|||||||
createFaces(entities: Partial<AssetFaceEntity>[]): Promise<string[]>;
|
createFaces(entities: Partial<AssetFaceEntity>[]): Promise<string[]>;
|
||||||
delete(entities: PersonEntity[]): Promise<void>;
|
delete(entities: PersonEntity[]): Promise<void>;
|
||||||
deleteAll(): Promise<void>;
|
deleteAll(): Promise<void>;
|
||||||
deleteAllFaces(options: DeleteAllFacesOptions): Promise<void>;
|
deleteFaces(options: DeleteFacesOptions): Promise<void>;
|
||||||
replaceFaces(assetId: string, entities: Partial<AssetFaceEntity>[], sourceType?: string): Promise<string[]>;
|
replaceFaces(assetId: string, entities: Partial<AssetFaceEntity>[], sourceType?: string): Promise<string[]>;
|
||||||
getAllFaces(pagination: PaginationOptions, options?: FindManyOptions<AssetFaceEntity>): Paginated<AssetFaceEntity>;
|
getAllFaces(pagination: PaginationOptions, options?: FindManyOptions<AssetFaceEntity>): Paginated<AssetFaceEntity>;
|
||||||
getFaceById(id: string): Promise<AssetFaceEntity>;
|
getFaceById(id: string): Promise<AssetFaceEntity>;
|
||||||
@ -75,6 +78,7 @@ export interface IPersonRepository {
|
|||||||
reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
|
reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
|
||||||
getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
|
getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
|
||||||
reassignFaces(data: UpdateFacesData): Promise<number>;
|
reassignFaces(data: UpdateFacesData): Promise<number>;
|
||||||
|
unassignFaces(options: UnassignFacesOptions): Promise<void>;
|
||||||
update(person: Partial<PersonEntity>): Promise<PersonEntity>;
|
update(person: Partial<PersonEntity>): Promise<PersonEntity>;
|
||||||
updateAll(people: Partial<PersonEntity>[]): Promise<void>;
|
updateAll(people: Partial<PersonEntity>[]): Promise<void>;
|
||||||
getLatestFaceDate(): Promise<string | undefined>;
|
getLatestFaceDate(): Promise<string | undefined>;
|
||||||
|
@ -116,7 +116,6 @@ export interface SearchPeopleOptions {
|
|||||||
|
|
||||||
export interface SearchOrderOptions {
|
export interface SearchOrderOptions {
|
||||||
orderDirection?: 'ASC' | 'DESC';
|
orderDirection?: 'ASC' | 'DESC';
|
||||||
random?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchPaginationOptions {
|
export interface SearchPaginationOptions {
|
||||||
@ -177,6 +176,7 @@ export interface ISearchRepository {
|
|||||||
searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated<AssetEntity>;
|
searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated<AssetEntity>;
|
||||||
searchDuplicates(options: AssetDuplicateSearch): Promise<AssetDuplicateResult[]>;
|
searchDuplicates(options: AssetDuplicateSearch): Promise<AssetDuplicateResult[]>;
|
||||||
searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>;
|
searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>;
|
||||||
|
searchRandom(size: number, options: AssetSearchOptions): Promise<AssetEntity[]>;
|
||||||
upsert(assetId: string, embedding: number[]): Promise<void>;
|
upsert(assetId: string, embedding: number[]): Promise<void>;
|
||||||
searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]>;
|
searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]>;
|
||||||
getAssetsByCity(userIds: string[]): Promise<AssetEntity[]>;
|
getAssetsByCity(userIds: string[]): Promise<AssetEntity[]>;
|
||||||
|
@ -1132,3 +1132,27 @@ RETURNING
|
|||||||
"id",
|
"id",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt"
|
"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"
|
||||||
|
@ -77,10 +77,11 @@ FROM
|
|||||||
"asset"."fileCreatedAt" >= $1
|
"asset"."fileCreatedAt" >= $1
|
||||||
AND "exifInfo"."lensModel" = $2
|
AND "exifInfo"."lensModel" = $2
|
||||||
AND 1 = 1
|
AND 1 = 1
|
||||||
|
AND "asset"."ownerId" IN ($3)
|
||||||
AND 1 = 1
|
AND 1 = 1
|
||||||
AND (
|
AND (
|
||||||
"asset"."isFavorite" = $3
|
"asset"."isFavorite" = $4
|
||||||
AND "asset"."isArchived" = $4
|
AND "asset"."isArchived" = $5
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
AND ("asset"."deletedAt" IS NULL)
|
AND ("asset"."deletedAt" IS NULL)
|
||||||
@ -91,6 +92,190 @@ ORDER BY
|
|||||||
LIMIT
|
LIMIT
|
||||||
101
|
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
|
-- SearchRepository.searchSmart
|
||||||
START TRANSACTION
|
START TRANSACTION
|
||||||
SET
|
SET
|
||||||
|
@ -801,7 +801,12 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] })
|
@GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] })
|
||||||
async upsertFile({ assetId, type, path }: { assetId: string; type: AssetFileType; path: string }): Promise<void> {
|
async upsertFile(file: { assetId: string; type: AssetFileType; path: string }): Promise<void> {
|
||||||
await this.fileRepository.upsert({ assetId, type, path }, { conflictPaths: ['assetId', 'type'] });
|
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<void> {
|
||||||
|
await this.fileRepository.upsert(files, { conflictPaths: ['assetId', 'type'] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
||||||
import {
|
import {
|
||||||
OnGatewayConnection,
|
OnGatewayConnection,
|
||||||
OnGatewayDisconnect,
|
OnGatewayDisconnect,
|
||||||
@ -13,16 +12,17 @@ import {
|
|||||||
ArgsOf,
|
ArgsOf,
|
||||||
ClientEventMap,
|
ClientEventMap,
|
||||||
EmitEvent,
|
EmitEvent,
|
||||||
EmitHandler,
|
EventItem,
|
||||||
IEventRepository,
|
IEventRepository,
|
||||||
ServerEvent,
|
serverEvents,
|
||||||
ServerEventMap,
|
ServerEvents,
|
||||||
} from 'src/interfaces/event.interface';
|
} from 'src/interfaces/event.interface';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { AuthService } from 'src/services/auth.service';
|
import { AuthService } from 'src/services/auth.service';
|
||||||
import { Instrumentation } from 'src/utils/instrumentation';
|
import { Instrumentation } from 'src/utils/instrumentation';
|
||||||
|
import { handlePromiseError } from 'src/utils/misc';
|
||||||
|
|
||||||
type EmitHandlers = Partial<{ [T in EmitEvent]: EmitHandler<T>[] }>;
|
type EmitHandlers = Partial<{ [T in EmitEvent]: Array<EventItem<T>> }>;
|
||||||
|
|
||||||
@Instrumentation()
|
@Instrumentation()
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
@ -39,7 +39,6 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private moduleRef: ModuleRef,
|
private moduleRef: ModuleRef,
|
||||||
private eventEmitter: EventEmitter2,
|
|
||||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(EventRepository.name);
|
this.logger.setContext(EventRepository.name);
|
||||||
@ -48,14 +47,10 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
|
|||||||
afterInit(server: Server) {
|
afterInit(server: Server) {
|
||||||
this.logger.log('Initialized websocket server');
|
this.logger.log('Initialized websocket server');
|
||||||
|
|
||||||
for (const event of Object.values(ServerEvent)) {
|
for (const event of serverEvents) {
|
||||||
if (event === ServerEvent.WEBSOCKET_CONNECT) {
|
server.on(event, (...args: ArgsOf<any>) => {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
server.on(event, (data: unknown) => {
|
|
||||||
this.logger.debug(`Server event: ${event} (receive)`);
|
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) {
|
if (auth.session) {
|
||||||
await client.join(auth.session.id);
|
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) {
|
} catch (error: Error | any) {
|
||||||
this.logger.error(`Websocket connection error: ${error}`, error?.stack);
|
this.logger.error(`Websocket connection error: ${error}`, error?.stack);
|
||||||
client.emit('error', 'unauthorized');
|
client.emit('error', 'unauthorized');
|
||||||
@ -85,18 +80,29 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
|
|||||||
await client.leave(client.nsp.name);
|
await client.leave(client.nsp.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
on<T extends EmitEvent>(event: T, handler: EmitHandler<T>): void {
|
on<T extends EmitEvent>(item: EventItem<T>): void {
|
||||||
|
const event = item.event;
|
||||||
|
|
||||||
if (!this.emitHandlers[event]) {
|
if (!this.emitHandlers[event]) {
|
||||||
this.emitHandlers[event] = [];
|
this.emitHandlers[event] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emitHandlers[event].push(handler);
|
this.emitHandlers[event].push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
async emit<T extends EmitEvent>(event: T, ...args: ArgsOf<T>): Promise<void> {
|
async emit<T extends EmitEvent>(event: T, ...args: ArgsOf<T>): Promise<void> {
|
||||||
const handlers = this.emitHandlers[event] || [];
|
return this.onEvent({ name: event, args, server: false });
|
||||||
for (const handler of handlers) {
|
}
|
||||||
await handler(...args);
|
|
||||||
|
private async onEvent<T extends EmitEvent>(event: { name: T; args: ArgsOf<T>; server: boolean }): Promise<void> {
|
||||||
|
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);
|
this.server?.emit(event, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
serverSend<E extends keyof ServerEventMap>(event: E, data: ServerEventMap[E]) {
|
serverSend<T extends ServerEvents>(event: T, ...args: ArgsOf<T>): void {
|
||||||
this.logger.debug(`Server event: ${event} (send)`);
|
this.logger.debug(`Server event: ${event} (send)`);
|
||||||
this.server?.serverSideEmit(event, data);
|
this.server?.serverSideEmit(event, ...args);
|
||||||
return this.eventEmitter.emit(event, data);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -36,9 +36,7 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
|
|||||||
|
|
||||||
// thumbnails
|
// thumbnails
|
||||||
[JobName.QUEUE_GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION,
|
[JobName.QUEUE_GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION,
|
||||||
[JobName.GENERATE_PREVIEW]: QueueName.THUMBNAIL_GENERATION,
|
[JobName.GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION,
|
||||||
[JobName.GENERATE_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
|
|
||||||
[JobName.GENERATE_THUMBHASH]: QueueName.THUMBNAIL_GENERATION,
|
|
||||||
[JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
|
[JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
|
||||||
|
|
||||||
// tags
|
// tags
|
||||||
|
@ -8,10 +8,12 @@ import sharp from 'sharp';
|
|||||||
import { Colorspace, LogLevel } from 'src/enum';
|
import { Colorspace, LogLevel } from 'src/enum';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import {
|
import {
|
||||||
|
DecodeToBufferOptions,
|
||||||
|
GenerateThumbhashOptions,
|
||||||
|
GenerateThumbnailOptions,
|
||||||
IMediaRepository,
|
IMediaRepository,
|
||||||
ImageDimensions,
|
ImageDimensions,
|
||||||
ProbeOptions,
|
ProbeOptions,
|
||||||
ThumbnailOptions,
|
|
||||||
TranscodeCommand,
|
TranscodeCommand,
|
||||||
VideoInfo,
|
VideoInfo,
|
||||||
} from 'src/interfaces/media.interface';
|
} from 'src/interfaces/media.interface';
|
||||||
@ -57,19 +59,12 @@ export class MediaRepository implements IMediaRepository {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise<void> {
|
decodeImage(input: string, options: DecodeToBufferOptions) {
|
||||||
// some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes
|
return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true });
|
||||||
const pipeline = sharp(input, { failOn: options.processInvalidImages ? 'none' : 'error', limitInputPixels: false })
|
}
|
||||||
.pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16')
|
|
||||||
.rotate();
|
|
||||||
|
|
||||||
if (options.crop) {
|
async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise<void> {
|
||||||
pipeline.extract(options.crop);
|
await this.getImageDecodingPipeline(input, options)
|
||||||
}
|
|
||||||
|
|
||||||
await pipeline
|
|
||||||
.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
|
|
||||||
.withIccProfile(options.colorspace)
|
|
||||||
.toFormat(options.format, {
|
.toFormat(options.format, {
|
||||||
quality: options.quality,
|
quality: options.quality,
|
||||||
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
|
// 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);
|
.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<Buffer> {
|
||||||
|
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<VideoInfo> {
|
async probe(input: string, options?: ProbeOptions): Promise<VideoInfo> {
|
||||||
const results = await probe(input, options?.countFrames ? ['-count_packets'] : []); // gets frame count quickly: https://stackoverflow.com/a/28376817
|
const results = await probe(input, options?.countFrames ? ['-count_packets'] : []); // gets frame count quickly: https://stackoverflow.com/a/28376817
|
||||||
return {
|
return {
|
||||||
@ -150,19 +179,6 @@ export class MediaRepository implements IMediaRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateThumbhash(imagePath: string): Promise<Buffer> {
|
|
||||||
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<ImageDimensions> {
|
async getImageDimensions(input: string): Promise<ImageDimensions> {
|
||||||
const { width = 0, height = 0 } = await sharp(input).metadata();
|
const { width = 0, height = 0 } = await sharp(input).metadata();
|
||||||
return { width, height };
|
return { width, height };
|
||||||
|
@ -9,13 +9,14 @@ import { PersonEntity } from 'src/entities/person.entity';
|
|||||||
import { PaginationMode, SourceType } from 'src/enum';
|
import { PaginationMode, SourceType } from 'src/enum';
|
||||||
import {
|
import {
|
||||||
AssetFaceId,
|
AssetFaceId,
|
||||||
DeleteAllFacesOptions,
|
DeleteFacesOptions,
|
||||||
IPersonRepository,
|
IPersonRepository,
|
||||||
PeopleStatistics,
|
PeopleStatistics,
|
||||||
PersonNameResponse,
|
PersonNameResponse,
|
||||||
PersonNameSearchOptions,
|
PersonNameSearchOptions,
|
||||||
PersonSearchOptions,
|
PersonSearchOptions,
|
||||||
PersonStatistics,
|
PersonStatistics,
|
||||||
|
UnassignFacesOptions,
|
||||||
UpdateFacesData,
|
UpdateFacesData,
|
||||||
} from 'src/interfaces/person.interface';
|
} from 'src/interfaces/person.interface';
|
||||||
import { Instrumentation } from 'src/utils/instrumentation';
|
import { Instrumentation } from 'src/utils/instrumentation';
|
||||||
@ -39,12 +40,23 @@ export class PersonRepository implements IPersonRepository {
|
|||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.update()
|
.update()
|
||||||
.set({ personId: newPersonId })
|
.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();
|
.execute();
|
||||||
|
|
||||||
return result.affected ?? 0;
|
return result.affected ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> {
|
||||||
|
await this.assetFaceRepository
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update()
|
||||||
|
.set({ personId: null })
|
||||||
|
.where({ sourceType })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await this.vacuum({ reindexVectors: false });
|
||||||
|
}
|
||||||
|
|
||||||
async delete(entities: PersonEntity[]): Promise<void> {
|
async delete(entities: PersonEntity[]): Promise<void> {
|
||||||
await this.personRepository.remove(entities);
|
await this.personRepository.remove(entities);
|
||||||
}
|
}
|
||||||
@ -53,21 +65,14 @@ export class PersonRepository implements IPersonRepository {
|
|||||||
await this.personRepository.clear();
|
await this.personRepository.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAllFaces({ sourceType }: DeleteAllFacesOptions): Promise<void> {
|
async deleteFaces({ sourceType }: DeleteFacesOptions): Promise<void> {
|
||||||
if (!sourceType) {
|
|
||||||
return this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.assetFaceRepository
|
await this.assetFaceRepository
|
||||||
.createQueryBuilder('asset_faces')
|
.createQueryBuilder('asset_faces')
|
||||||
.delete()
|
.delete()
|
||||||
.andWhere('sourceType = :sourceType', { sourceType })
|
.andWhere('sourceType = :sourceType', { sourceType })
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search');
|
await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING });
|
||||||
if (sourceType === SourceType.MACHINE_LEARNING) {
|
|
||||||
await this.assetFaceRepository.query('REINDEX INDEX face_index');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllFaces(
|
getAllFaces(
|
||||||
@ -331,4 +336,13 @@ export class PersonRepository implements IPersonRepository {
|
|||||||
const { id } = await this.personRepository.save(person);
|
const { id } = await this.personRepository.save(person);
|
||||||
return this.personRepository.findOneByOrFail({ id });
|
return this.personRepository.findOneByOrFail({ id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { getVectorExtension } from 'src/database.config';
|
import { getVectorExtension } from 'src/database.config';
|
||||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||||
@ -63,22 +64,15 @@ export class SearchRepository implements ISearchRepository {
|
|||||||
{
|
{
|
||||||
takenAfter: DummyValue.DATE,
|
takenAfter: DummyValue.DATE,
|
||||||
lensModel: DummyValue.STRING,
|
lensModel: DummyValue.STRING,
|
||||||
ownerId: DummyValue.UUID,
|
|
||||||
withStacked: true,
|
withStacked: true,
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
ownerIds: [DummyValue.UUID],
|
userIds: [DummyValue.UUID],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity> {
|
async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity> {
|
||||||
let builder = this.assetRepository.createQueryBuilder('asset');
|
let builder = this.assetRepository.createQueryBuilder('asset');
|
||||||
builder = searchAssetBuilder(builder, options);
|
builder = searchAssetBuilder(builder, options).orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC');
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
return paginatedBuilder<AssetEntity>(builder, {
|
return paginatedBuilder<AssetEntity>(builder, {
|
||||||
mode: PaginationMode.SKIP_TAKE,
|
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<AssetEntity[]> {
|
||||||
|
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<AssetFaceEntity>, personIds: string[]) {
|
private createPersonFilter(builder: SelectQueryBuilder<AssetFaceEntity>, personIds: string[]) {
|
||||||
return builder
|
return builder
|
||||||
.select(`${builder.alias}."assetId"`)
|
.select(`${builder.alias}."assetId"`)
|
||||||
|
@ -395,7 +395,7 @@ describe(AssetService.name, () => {
|
|||||||
it('should run the refresh thumbnails job', async () => {
|
it('should run the refresh thumbnails job', async () => {
|
||||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||||
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL });
|
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 () => {
|
it('should run the transcode video', async () => {
|
||||||
|
@ -322,7 +322,7 @@ export class AssetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case AssetJobName.REGENERATE_THUMBNAIL: {
|
case AssetJobName.REGENERATE_THUMBNAIL: {
|
||||||
jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id } });
|
jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id } });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Duration } from 'luxon';
|
import { Duration } from 'luxon';
|
||||||
import semver from 'semver';
|
import semver from 'semver';
|
||||||
import { OnEmit } from 'src/decorators';
|
import { OnEvent } from 'src/decorators';
|
||||||
import { IConfigRepository } from 'src/interfaces/config.interface';
|
import { IConfigRepository } from 'src/interfaces/config.interface';
|
||||||
import {
|
import {
|
||||||
DatabaseExtension,
|
DatabaseExtension,
|
||||||
@ -74,7 +74,7 @@ export class DatabaseService {
|
|||||||
this.logger.setContext(DatabaseService.name);
|
this.logger.setContext(DatabaseService.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'app.bootstrap', priority: -200 })
|
@OnEvent({ name: 'app.bootstrap', priority: -200 })
|
||||||
async onBootstrap() {
|
async onBootstrap() {
|
||||||
const version = await this.databaseRepository.getPostgresVersion();
|
const version = await this.databaseRepository.getPostgresVersion();
|
||||||
const current = semver.coerce(version);
|
const current = semver.coerce(version);
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { SystemConfig } from 'src/config';
|
import { defaults } from 'src/config';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
|
||||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||||
import {
|
import {
|
||||||
@ -60,6 +59,19 @@ describe(JobService.name, () => {
|
|||||||
expect(sut).toBeDefined();
|
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', () => {
|
describe('handleNightlyJobs', () => {
|
||||||
it('should run the scheduled jobs', async () => {
|
it('should run the scheduled jobs', async () => {
|
||||||
await sut.handleNightlyJobs();
|
await sut.handleNightlyJobs();
|
||||||
@ -239,36 +251,6 @@ describe(JobService.name, () => {
|
|||||||
expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length);
|
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[] }> = [
|
const tests: Array<{ item: JobItem; jobs: JobName[] }> = [
|
||||||
{
|
{
|
||||||
item: { name: JobName.SIDECAR_SYNC, data: { id: 'asset-1' } },
|
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' } },
|
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' } },
|
item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1' } },
|
||||||
@ -299,28 +281,16 @@ describe(JobService.name, () => {
|
|||||||
jobs: [],
|
jobs: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1' } },
|
item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } },
|
||||||
jobs: [JobName.GENERATE_THUMBNAIL, JobName.GENERATE_THUMBHASH],
|
jobs: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1', source: 'upload' } },
|
item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1', source: 'upload' } },
|
||||||
jobs: [
|
jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION, JobName.VIDEO_CONVERSION],
|
||||||
JobName.GENERATE_THUMBNAIL,
|
|
||||||
JobName.GENERATE_THUMBHASH,
|
|
||||||
JobName.SMART_SEARCH,
|
|
||||||
JobName.FACE_DETECTION,
|
|
||||||
JobName.VIDEO_CONVERSION,
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-live-image', source: 'upload' } },
|
item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-live-image', source: 'upload' } },
|
||||||
jobs: [
|
jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION, JobName.VIDEO_CONVERSION],
|
||||||
JobName.GENERATE_THUMBNAIL,
|
|
||||||
JobName.GENERATE_THUMBHASH,
|
|
||||||
JobName.SMART_SEARCH,
|
|
||||||
JobName.FACE_DETECTION,
|
|
||||||
JobName.VIDEO_CONVERSION,
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
item: { name: JobName.SMART_SEARCH, data: { id: 'asset-1' } },
|
item: { name: JobName.SMART_SEARCH, data: { id: 'asset-1' } },
|
||||||
@ -338,11 +308,11 @@ describe(JobService.name, () => {
|
|||||||
|
|
||||||
for (const { item, jobs } of tests) {
|
for (const { item, jobs } of tests) {
|
||||||
it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => {
|
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') {
|
if (item.data.id === 'asset-live-image') {
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]);
|
assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]);
|
||||||
} else {
|
} 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 sut.init(makeMockHandlers(JobStatus.FAILED));
|
||||||
await jobMock.addHandler.mock.calls[0][2](item);
|
await jobMock.addHandler.mock.calls[0][2](item);
|
||||||
|
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||||
import { snakeCase } from 'lodash';
|
import { snakeCase } from 'lodash';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||||
|
import { OnEvent } from 'src/decorators';
|
||||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto';
|
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto';
|
||||||
import { AssetType, ManualJobName } from 'src/enum';
|
import { AssetType, ManualJobName } from 'src/enum';
|
||||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
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 {
|
import {
|
||||||
ConcurrentQueueName,
|
ConcurrentQueueName,
|
||||||
IJobRepository,
|
IJobRepository,
|
||||||
@ -45,6 +46,7 @@ const asJobItem = (dto: JobCreateDto): JobItem => {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class JobService {
|
export class JobService {
|
||||||
private configCore: SystemConfigCore;
|
private configCore: SystemConfigCore;
|
||||||
|
private isMicroservices = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||||
@ -59,6 +61,28 @@ export class JobService {
|
|||||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, logger);
|
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<void> {
|
async create(dto: JobCreateDto): Promise<void> {
|
||||||
await this.jobRepository.queue(asJobItem(dto));
|
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 {
|
private isConcurrentQueue(name: QueueName): name is ConcurrentQueueName {
|
||||||
@ -281,7 +293,7 @@ export class JobService {
|
|||||||
|
|
||||||
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: {
|
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: {
|
||||||
if (item.data.source === 'upload' || item.data.source === 'copy') {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
@ -295,40 +307,33 @@ export class JobService {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case JobName.GENERATE_PREVIEW: {
|
case JobName.GENERATE_THUMBNAILS: {
|
||||||
const jobs: JobItem[] = [
|
if (!item.data.notify && item.data.source !== 'upload') {
|
||||||
{ 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')) {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]);
|
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
|
const jobs: JobItem[] = [
|
||||||
if (asset && asset.isVisible) {
|
{ 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));
|
this.eventRepository.clientSend(ClientEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { Stats } from 'node:fs';
|
import { Stats } from 'node:fs';
|
||||||
import { SystemConfig } from 'src/config';
|
import { defaults, SystemConfig } from 'src/config';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
|
||||||
import { mapLibrary } from 'src/dtos/library.dto';
|
import { mapLibrary } from 'src/dtos/library.dto';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
import { UserEntity } from 'src/entities/user.entity';
|
||||||
import { AssetType } from 'src/enum';
|
import { AssetType } from 'src/enum';
|
||||||
@ -81,22 +80,26 @@ describe(LibraryService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('onBootstrapEvent', () => {
|
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);
|
systemMock.get.mockResolvedValue(systemConfigStub.libraryScan);
|
||||||
|
|
||||||
await sut.onBootstrap();
|
await sut.onBootstrap();
|
||||||
expect(systemMock.get).toHaveBeenCalled();
|
|
||||||
expect(jobMock.addCronJob).toHaveBeenCalled();
|
|
||||||
|
|
||||||
SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({
|
expect(jobMock.addCronJob).toHaveBeenCalled();
|
||||||
library: {
|
expect(systemMock.get).toHaveBeenCalled();
|
||||||
scan: {
|
|
||||||
enabled: true,
|
await sut.onConfigUpdate({
|
||||||
cronExpression: '0 1 * * *',
|
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);
|
expect(jobMock.updateCronJob).toHaveBeenCalledWith('libraryScan', '0 1 * * *', true);
|
||||||
});
|
});
|
||||||
|
@ -4,7 +4,7 @@ import path, { basename, parse } from 'node:path';
|
|||||||
import picomatch from 'picomatch';
|
import picomatch from 'picomatch';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||||
import { OnEmit } from 'src/decorators';
|
import { OnEvent } from 'src/decorators';
|
||||||
import {
|
import {
|
||||||
CreateLibraryDto,
|
CreateLibraryDto,
|
||||||
LibraryResponseDto,
|
LibraryResponseDto,
|
||||||
@ -61,7 +61,7 @@ export class LibraryService {
|
|||||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'app.bootstrap' })
|
@OnEvent({ name: 'app.bootstrap' })
|
||||||
async onBootstrap() {
|
async onBootstrap() {
|
||||||
const config = await this.configCore.getConfig({ withCache: false });
|
const config = await this.configCore.getConfig({ withCache: false });
|
||||||
|
|
||||||
@ -83,19 +83,24 @@ export class LibraryService {
|
|||||||
if (this.watchLibraries) {
|
if (this.watchLibraries) {
|
||||||
await this.watchAll();
|
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'>) {
|
onConfigValidate({ newConfig }: ArgOf<'config.validate'>) {
|
||||||
const { scan } = newConfig.library;
|
const { scan } = newConfig.library;
|
||||||
if (!validateCronExpression(scan.cronExpression)) {
|
if (!validateCronExpression(scan.cronExpression)) {
|
||||||
@ -185,7 +190,7 @@ export class LibraryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'app.shutdown' })
|
@OnEvent({ name: 'app.shutdown' })
|
||||||
async onShutdown() {
|
async onShutdown() {
|
||||||
await this.unwatchAll();
|
await this.unwatchAll();
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,7 @@ import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interfac
|
|||||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||||
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.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 { IMoveRepository } from 'src/interfaces/move.interface';
|
||||||
import { IPersonRepository } from 'src/interfaces/person.interface';
|
import { IPersonRepository } from 'src/interfaces/person.interface';
|
||||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||||
@ -94,7 +94,7 @@ describe(MediaService.name, () => {
|
|||||||
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.GENERATE_PREVIEW,
|
name: JobName.GENERATE_THUMBNAILS,
|
||||||
data: { id: assetStub.image.id },
|
data: { id: assetStub.image.id },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@ -127,7 +127,7 @@ describe(MediaService.name, () => {
|
|||||||
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.GENERATE_PREVIEW,
|
name: JobName.GENERATE_THUMBNAILS,
|
||||||
data: { id: assetStub.trashed.id },
|
data: { id: assetStub.trashed.id },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@ -152,7 +152,7 @@ describe(MediaService.name, () => {
|
|||||||
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.GENERATE_PREVIEW,
|
name: JobName.GENERATE_THUMBNAILS,
|
||||||
data: { id: assetStub.archived.id },
|
data: { id: assetStub.archived.id },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@ -202,7 +202,7 @@ describe(MediaService.name, () => {
|
|||||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
|
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.GENERATE_PREVIEW,
|
name: JobName.GENERATE_THUMBNAILS,
|
||||||
data: { id: assetStub.image.id },
|
data: { id: assetStub.image.id },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@ -226,7 +226,7 @@ describe(MediaService.name, () => {
|
|||||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
|
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.GENERATE_THUMBNAIL,
|
name: JobName.GENERATE_THUMBNAILS,
|
||||||
data: { id: assetStub.image.id },
|
data: { id: assetStub.image.id },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@ -250,7 +250,7 @@ describe(MediaService.name, () => {
|
|||||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
|
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.GENERATE_THUMBHASH,
|
name: JobName.GENERATE_THUMBNAILS,
|
||||||
data: { id: assetStub.image.id },
|
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 () => {
|
it('should skip thumbnail generation if asset not found', async () => {
|
||||||
assetMock.getByIds.mockResolvedValue([]);
|
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||||
await sut.handleGeneratePreview({ id: assetStub.image.id });
|
|
||||||
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
|
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
|
||||||
expect(assetMock.update).not.toHaveBeenCalledWith();
|
expect(assetMock.update).not.toHaveBeenCalledWith();
|
||||||
});
|
});
|
||||||
@ -270,80 +279,100 @@ describe(MediaService.name, () => {
|
|||||||
it('should skip video thumbnail generation if no video stream', async () => {
|
it('should skip video thumbnail generation if no video stream', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams);
|
mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams);
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
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(mediaMock.generateThumbnail).not.toHaveBeenCalled();
|
||||||
expect(assetMock.update).not.toHaveBeenCalledWith();
|
expect(assetMock.update).not.toHaveBeenCalledWith();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip invisible assets', async () => {
|
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(mediaMock.generateThumbnail).not.toHaveBeenCalled();
|
||||||
expect(assetMock.update).not.toHaveBeenCalledWith();
|
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 () => {
|
it('should delete previous preview if different path', async () => {
|
||||||
systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } });
|
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');
|
expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate a P3 thumbnail for a wide gamut image', async () => {
|
it('should generate P3 thumbnails for a wide gamut image', async () => {
|
||||||
assetMock.getByIds.mockResolvedValue([
|
assetMock.getById.mockResolvedValue({
|
||||||
{ ...assetStub.image, exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity },
|
...assetStub.image,
|
||||||
]);
|
exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity,
|
||||||
await sut.handleGeneratePreview({ id: assetStub.image.id });
|
});
|
||||||
|
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(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
|
||||||
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
|
|
||||||
'/original/path.jpg',
|
expect(mediaMock.decodeImage).toHaveBeenCalledOnce();
|
||||||
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
|
expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, {
|
||||||
{
|
colorspace: Colorspace.P3,
|
||||||
size: 1440,
|
processInvalidImages: false,
|
||||||
format: ImageFormat.JPEG,
|
size: 1440,
|
||||||
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.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 () => {
|
it('should generate a thumbnail for a video', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
assetMock.getById.mockResolvedValue(assetStub.video);
|
||||||
await sut.handleGeneratePreview({ id: assetStub.video.id });
|
await sut.handleGenerateThumbnails({ id: assetStub.video.id });
|
||||||
|
|
||||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
|
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
|
||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
@ -361,17 +390,24 @@ describe(MediaService.name, () => {
|
|||||||
twoPass: false,
|
twoPass: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(assetMock.upsertFile).toHaveBeenCalledWith({
|
expect(assetMock.upsertFiles).toHaveBeenCalledWith([
|
||||||
assetId: 'asset-id',
|
{
|
||||||
type: AssetFileType.PREVIEW,
|
assetId: 'asset-id',
|
||||||
path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
|
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 () => {
|
it('should tonemap thumbnail for hdr video', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
|
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
assetMock.getById.mockResolvedValue(assetStub.video);
|
||||||
await sut.handleGeneratePreview({ id: assetStub.video.id });
|
await sut.handleGenerateThumbnails({ id: assetStub.video.id });
|
||||||
|
|
||||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
|
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
|
||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
@ -389,11 +425,18 @@ describe(MediaService.name, () => {
|
|||||||
twoPass: false,
|
twoPass: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(assetMock.upsertFile).toHaveBeenCalledWith({
|
expect(assetMock.upsertFiles).toHaveBeenCalledWith([
|
||||||
assetId: 'asset-id',
|
{
|
||||||
type: AssetFileType.PREVIEW,
|
assetId: 'asset-id',
|
||||||
path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
|
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 () => {
|
it('should always generate video thumbnail in one pass', async () => {
|
||||||
@ -401,8 +444,8 @@ describe(MediaService.name, () => {
|
|||||||
systemMock.get.mockResolvedValue({
|
systemMock.get.mockResolvedValue({
|
||||||
ffmpeg: { twoPass: true, maxBitrate: '5000k' },
|
ffmpeg: { twoPass: true, maxBitrate: '5000k' },
|
||||||
});
|
});
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
assetMock.getById.mockResolvedValue(assetStub.video);
|
||||||
await sut.handleGeneratePreview({ id: assetStub.video.id });
|
await sut.handleGenerateThumbnails({ id: assetStub.video.id });
|
||||||
|
|
||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
'/original/path.ext',
|
'/original/path.ext',
|
||||||
@ -424,8 +467,8 @@ describe(MediaService.name, () => {
|
|||||||
it('should use scaling divisible by 2 even when using quick sync', async () => {
|
it('should use scaling divisible by 2 even when using quick sync', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||||
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } });
|
systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } });
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
assetMock.getById.mockResolvedValue(assetStub.video);
|
||||||
await sut.handleGeneratePreview({ id: assetStub.video.id });
|
await sut.handleGenerateThumbnails({ id: assetStub.video.id });
|
||||||
|
|
||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
'/original/path.ext',
|
'/original/path.ext',
|
||||||
@ -438,233 +481,207 @@ describe(MediaService.name, () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should run successfully', async () => {
|
it.each(Object.values(ImageFormat))('should generate an image preview in %s format', async (format) => {
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
systemMock.get.mockResolvedValue({ image: { preview: { format } } });
|
||||||
await sut.handleGeneratePreview({ id: assetStub.image.id });
|
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', () => {
|
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should skip invisible assets', async () => {
|
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]);
|
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).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
|
||||||
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
|
rawBuffer,
|
||||||
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,
|
|
||||||
colorspace: Colorspace.SRGB,
|
colorspace: Colorspace.SRGB,
|
||||||
|
format,
|
||||||
|
size: 1440,
|
||||||
|
quality: 80,
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
});
|
raw: rawInfo,
|
||||||
expect(assetMock.upsertFile).toHaveBeenCalledWith({
|
},
|
||||||
assetId: 'asset-id',
|
previewPath,
|
||||||
type: AssetFileType.THUMBNAIL,
|
);
|
||||||
path: thumbnailPath,
|
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 () => {
|
it('should delete previous thumbnail if different path', async () => {
|
||||||
systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } });
|
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');
|
expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext');
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it('should generate a P3 thumbnail for a wide gamut image', async () => {
|
it('should extract embedded image if enabled and available', async () => {
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.imageDng]);
|
mediaMock.extract.mockResolvedValue(true);
|
||||||
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
|
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');
|
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||||
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
|
|
||||||
assetStub.imageDng.originalPath,
|
const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString();
|
||||||
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
|
expect(mediaMock.decodeImage).toHaveBeenCalledOnce();
|
||||||
{
|
expect(mediaMock.decodeImage).toHaveBeenCalledWith(extractedPath, {
|
||||||
format: ImageFormat.WEBP,
|
|
||||||
size: 250,
|
|
||||||
quality: 80,
|
|
||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
},
|
size: 1440,
|
||||||
);
|
});
|
||||||
expect(assetMock.upsertFile).toHaveBeenCalledWith({
|
expect(extractedPath?.endsWith('.tmp')).toBe(true);
|
||||||
assetId: 'asset-id',
|
expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath);
|
||||||
type: AssetFileType.THUMBNAIL,
|
|
||||||
path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it('should extract embedded image if enabled and available', async () => {
|
it('should resize original image if embedded image is too small', async () => {
|
||||||
mediaMock.extract.mockResolvedValue(true);
|
mediaMock.extract.mockResolvedValue(true);
|
||||||
mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 });
|
||||||
systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.imageDng]);
|
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.decodeImage).toHaveBeenCalledOnce();
|
||||||
expect(mediaMock.generateThumbnail.mock.calls).toEqual([
|
expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, {
|
||||||
[
|
colorspace: Colorspace.P3,
|
||||||
extractedPath,
|
processInvalidImages: false,
|
||||||
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
|
size: 1440,
|
||||||
{
|
});
|
||||||
format: ImageFormat.WEBP,
|
const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString();
|
||||||
size: 250,
|
expect(extractedPath?.endsWith('.tmp')).toBe(true);
|
||||||
quality: 80,
|
expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath);
|
||||||
colorspace: Colorspace.P3,
|
});
|
||||||
processInvalidImages: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
expect(extractedPath?.endsWith('.tmp')).toBe(true);
|
|
||||||
expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resize original image if embedded image is too small', async () => {
|
it('should resize original image if embedded image not found', async () => {
|
||||||
mediaMock.extract.mockResolvedValue(true);
|
systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||||
mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 });
|
assetMock.getById.mockResolvedValue(assetStub.imageDng);
|
||||||
systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
|
||||||
assetMock.getByIds.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,
|
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',
|
'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 () => {
|
expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce();
|
||||||
systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
expect(mediaMock.generateThumbhash).toHaveBeenCalledWith(
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.imageDng]);
|
rawBuffer,
|
||||||
|
expect.objectContaining({ processInvalidImages: true }),
|
||||||
|
);
|
||||||
|
|
||||||
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
|
expect(mediaMock.getImageDimensions).not.toHaveBeenCalled();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { dirname } from 'node:path';
|
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 { SystemConfigCore } from 'src/cores/system-config.core';
|
||||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
@ -18,7 +19,7 @@ import {
|
|||||||
VideoCodec,
|
VideoCodec,
|
||||||
VideoContainer,
|
VideoContainer,
|
||||||
} from 'src/enum';
|
} 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 { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||||
import {
|
import {
|
||||||
IBaseJob,
|
IBaseJob,
|
||||||
@ -95,18 +96,10 @@ export class MediaService {
|
|||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
const { previewFile, thumbnailFile } = getAssetFiles(asset.files);
|
const { previewFile, thumbnailFile } = getAssetFiles(asset.files);
|
||||||
|
|
||||||
if (!previewFile || force) {
|
if (!previewFile || !thumbnailFile || !asset.thumbhash || force) {
|
||||||
jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id: asset.id } });
|
jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id: asset.id } });
|
||||||
continue;
|
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);
|
await this.jobRepository.queueAll(jobs);
|
||||||
@ -181,141 +174,127 @@ export class MediaService {
|
|||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleGeneratePreview({ id }: IEntityJob): Promise<JobStatus> {
|
async handleGenerateThumbnails({ id }: IEntityJob): Promise<JobStatus> {
|
||||||
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true });
|
const asset = await this.assetRepository.getById(id, { exifInfo: true, files: true });
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
|
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found`);
|
||||||
return JobStatus.FAILED;
|
return JobStatus.FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!asset.isVisible) {
|
if (!asset.isVisible) {
|
||||||
|
this.logger.verbose(`Thumbnail generation skipped for asset ${id}: not visible`);
|
||||||
return JobStatus.SKIPPED;
|
return JobStatus.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW);
|
let generated: { previewPath: string; thumbnailPath: string; thumbhash: Buffer };
|
||||||
if (!previewPath) {
|
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;
|
return JobStatus.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { previewFile } = getAssetFiles(asset.files);
|
const { previewFile, thumbnailFile } = getAssetFiles(asset.files);
|
||||||
if (previewFile && previewFile.path !== previewPath) {
|
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}`);
|
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 });
|
if (thumbnailFile && thumbnailFile.path !== generated.thumbnailPath) {
|
||||||
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<JobStatus> {
|
|
||||||
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) {
|
|
||||||
this.logger.debug(`Deleting old thumbnail for asset ${asset.id}`);
|
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 });
|
if (pathsToDelete.length > 0) {
|
||||||
await this.assetRepository.update({ id: asset.id, updatedAt: new Date() });
|
await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path)));
|
||||||
await this.assetRepository.upsertJobStatus({ assetId: asset.id, thumbnailAt: new Date() });
|
}
|
||||||
|
|
||||||
|
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;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleGenerateThumbhash({ id }: IEntityJob): Promise<JobStatus> {
|
private async generateImageThumbnails(asset: AssetEntity) {
|
||||||
const [asset] = await this.assetRepository.getByIds([id], { files: true });
|
const { image } = await this.configCore.getConfig({ withCache: true });
|
||||||
if (!asset) {
|
const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format);
|
||||||
return JobStatus.FAILED;
|
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) {
|
private async generateVideoThumbnails(asset: AssetEntity) {
|
||||||
return JobStatus.SKIPPED;
|
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);
|
const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() });
|
||||||
if (!previewFile) {
|
const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() });
|
||||||
return JobStatus.FAILED;
|
|
||||||
}
|
|
||||||
|
|
||||||
const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path);
|
const previewOptions = previewConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream);
|
||||||
await this.assetRepository.update({ id: asset.id, thumbhash });
|
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<JobStatus> {
|
async handleQueueVideoConversion(job: IBaseJob): Promise<JobStatus> {
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user