Merge branch 'main' into chore/backup-wakelock

This commit is contained in:
Saschl 2024-09-24 09:11:24 +02:00 committed by GitHub
commit 8bb015877b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
476 changed files with 7955 additions and 7291 deletions

View File

@ -56,6 +56,10 @@ jobs:
run: dart format lib/ --set-exit-if-changed run: dart format lib/ --set-exit-if-changed
working-directory: ./mobile working-directory: ./mobile
- name: Run dart custom_lint
run: dart run custom_lint
working-directory: ./mobile
# Enable after riverpod generator migration is completed # Enable after riverpod generator migration is completed
# - name: Run dart custom lint # - name: Run dart custom lint
# run: dart run custom_lint # run: dart run custom_lint

437
cli/package-lock.json generated
View File

@ -825,9 +825,9 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.9.1", "version": "9.10.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.10.0.tgz",
"integrity": "sha512-xIDQRsfg5hNBqHz04H1R3scSVwmI+KUbqjsQKHKQ1DAUSaUjYPReZZmS/5PNiKu1fUvzDd6H7DEDKACSEhu+TQ==", "integrity": "sha512-fuXtbiP5GWIn8Fz+LWoOMVf/Jxm+aajZYkhi6CuEm4SxymFM+eUWzbO9qXT+L0iCkL5+KGYMCSGxo686H19S1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -844,6 +844,19 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@eslint/plugin-kit": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.1.0.tgz",
"integrity": "sha512-autAXT203ixhqei9xt+qkYOvY8l6LAFIdT2UXc/RPNeUVfqRF1BV94GTJyVPFKT8nFM6MyVJhjLj9E8JWvf5zQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"levn": "^0.4.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@humanwhocodes/module-importer": { "node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@ -1340,17 +1353,17 @@
"dev": true "dev": true
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.3.0", "version": "8.6.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.3.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.6.0.tgz",
"integrity": "sha512-FLAIn63G5KH+adZosDYiutqkOkYEx0nvcwNNfJAf+c7Ae/H35qWwTYvPZUKFj5AS+WfHG/WJJfWnDnyNUlp8UA==", "integrity": "sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.10.0", "@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.3.0", "@typescript-eslint/scope-manager": "8.6.0",
"@typescript-eslint/type-utils": "8.3.0", "@typescript-eslint/type-utils": "8.6.0",
"@typescript-eslint/utils": "8.3.0", "@typescript-eslint/utils": "8.6.0",
"@typescript-eslint/visitor-keys": "8.3.0", "@typescript-eslint/visitor-keys": "8.6.0",
"graphemer": "^1.4.0", "graphemer": "^1.4.0",
"ignore": "^5.3.1", "ignore": "^5.3.1",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
@ -1374,16 +1387,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.3.0", "version": "8.6.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.3.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.6.0.tgz",
"integrity": "sha512-h53RhVyLu6AtpUzVCYLPhZGL5jzTD9fZL+SYf/+hYOx2bDkyQXztXSc4tbvKYHzfMXExMLiL9CWqJmVz6+78IQ==", "integrity": "sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.3.0", "@typescript-eslint/scope-manager": "8.6.0",
"@typescript-eslint/types": "8.3.0", "@typescript-eslint/types": "8.6.0",
"@typescript-eslint/typescript-estree": "8.3.0", "@typescript-eslint/typescript-estree": "8.6.0",
"@typescript-eslint/visitor-keys": "8.3.0", "@typescript-eslint/visitor-keys": "8.6.0",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -1403,14 +1416,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.3.0", "version": "8.6.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.3.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.6.0.tgz",
"integrity": "sha512-mz2X8WcN2nVu5Hodku+IR8GgCOl4C0G/Z1ruaWN4dgec64kDBabuXyPAr+/RgJtumv8EEkqIzf3X2U5DUKB2eg==", "integrity": "sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.3.0", "@typescript-eslint/types": "8.6.0",
"@typescript-eslint/visitor-keys": "8.3.0" "@typescript-eslint/visitor-keys": "8.6.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1421,14 +1434,14 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.3.0", "version": "8.6.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.3.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.6.0.tgz",
"integrity": "sha512-wrV6qh//nLbfXZQoj32EXKmwHf4b7L+xXLrP3FZ0GOUU72gSvLjeWUl5J5Ue5IwRxIV1TfF73j/eaBapxx99Lg==", "integrity": "sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/typescript-estree": "8.3.0", "@typescript-eslint/typescript-estree": "8.6.0",
"@typescript-eslint/utils": "8.3.0", "@typescript-eslint/utils": "8.6.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"ts-api-utils": "^1.3.0" "ts-api-utils": "^1.3.0"
}, },
@ -1446,9 +1459,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.3.0", "version": "8.6.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.3.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz",
"integrity": "sha512-y6sSEeK+facMaAyixM36dQ5NVXTnKWunfD1Ft4xraYqxP0lC0POJmIaL/mw72CUMqjY9qfyVfXafMeaUj0noWw==", "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1460,14 +1473,14 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.3.0", "version": "8.6.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.3.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.6.0.tgz",
"integrity": "sha512-Mq7FTHl0R36EmWlCJWojIC1qn/ZWo2YiWYc1XVtasJ7FIgjo0MVv9rZWXEE7IK2CGrtwe1dVOxWwqXUdNgfRCA==", "integrity": "sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.3.0", "@typescript-eslint/types": "8.6.0",
"@typescript-eslint/visitor-keys": "8.3.0", "@typescript-eslint/visitor-keys": "8.6.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@ -1489,16 +1502,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.3.0", "version": "8.6.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.3.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.6.0.tgz",
"integrity": "sha512-F77WwqxIi/qGkIGOGXNBLV7nykwfjLsdauRB/DOFPdv6LTF3BHHkBpq81/b5iMPSF055oO2BiivDJV4ChvNtXA==", "integrity": "sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.4.0", "@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "8.3.0", "@typescript-eslint/scope-manager": "8.6.0",
"@typescript-eslint/types": "8.3.0", "@typescript-eslint/types": "8.6.0",
"@typescript-eslint/typescript-estree": "8.3.0" "@typescript-eslint/typescript-estree": "8.6.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1512,13 +1525,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.3.0", "version": "8.6.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.3.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.6.0.tgz",
"integrity": "sha512-RmZwrTbQ9QveF15m/Cl28n0LXD6ea2CjkhH5rQ55ewz3H24w+AMCJHPVYaZ8/0HoG8Z3cLLFFycRXxeO2tz9FA==", "integrity": "sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.3.0", "@typescript-eslint/types": "8.6.0",
"eslint-visitor-keys": "^3.4.3" "eslint-visitor-keys": "^3.4.3"
}, },
"engines": { "engines": {
@ -1530,19 +1543,20 @@
} }
}, },
"node_modules/@vitest/coverage-v8": { "node_modules/@vitest/coverage-v8": {
"version": "2.0.5", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz",
"integrity": "sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==", "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.3.0", "@ampproject/remapping": "^2.3.0",
"@bcoe/v8-coverage": "^0.2.3", "@bcoe/v8-coverage": "^0.2.3",
"debug": "^4.3.5", "debug": "^4.3.6",
"istanbul-lib-coverage": "^3.2.2", "istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1", "istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^5.0.6", "istanbul-lib-source-maps": "^5.0.6",
"istanbul-reports": "^3.1.7", "istanbul-reports": "^3.1.7",
"magic-string": "^0.30.10", "magic-string": "^0.30.11",
"magicast": "^0.3.4", "magicast": "^0.3.4",
"std-env": "^3.7.0", "std-env": "^3.7.0",
"test-exclude": "^7.0.1", "test-exclude": "^7.0.1",
@ -1552,17 +1566,24 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
}, },
"peerDependencies": { "peerDependencies": {
"vitest": "2.0.5" "@vitest/browser": "2.1.1",
"vitest": "2.1.1"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
} }
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "2.0.5", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz",
"integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==", "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "2.0.5", "@vitest/spy": "2.1.1",
"@vitest/utils": "2.0.5", "@vitest/utils": "2.1.1",
"chai": "^5.1.1", "chai": "^5.1.1",
"tinyrainbow": "^1.2.0" "tinyrainbow": "^1.2.0"
}, },
@ -1570,11 +1591,40 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@vitest/pretty-format": { "node_modules/@vitest/mocker": {
"version": "2.0.5", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz",
"integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "^2.1.0-beta.1",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.11"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/spy": "2.1.1",
"msw": "^2.3.5",
"vite": "^5.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.1.tgz",
"integrity": "sha512-SjxPFOtuINDUW8/UkElJYQSFtnWX7tMksSGW0vfjxMneFqxVr8YJ979QpMbDW7g+BIiq88RAGDjf7en6rvLPPQ==",
"dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"tinyrainbow": "^1.2.0" "tinyrainbow": "^1.2.0"
}, },
@ -1583,12 +1633,13 @@
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "2.0.5", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz",
"integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==", "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@vitest/utils": "2.0.5", "@vitest/utils": "2.1.1",
"pathe": "^1.1.2" "pathe": "^1.1.2"
}, },
"funding": { "funding": {
@ -1596,13 +1647,14 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "2.0.5", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz",
"integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==", "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "2.0.5", "@vitest/pretty-format": "2.1.1",
"magic-string": "^0.30.10", "magic-string": "^0.30.11",
"pathe": "^1.1.2" "pathe": "^1.1.2"
}, },
"funding": { "funding": {
@ -1610,10 +1662,11 @@
} }
}, },
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "2.0.5", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz",
"integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==", "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"tinyspy": "^3.0.0" "tinyspy": "^3.0.0"
}, },
@ -1622,13 +1675,13 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "2.0.5", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz",
"integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==", "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "2.0.5", "@vitest/pretty-format": "2.1.1",
"estree-walker": "^3.0.3",
"loupe": "^3.1.1", "loupe": "^3.1.1",
"tinyrainbow": "^1.2.0" "tinyrainbow": "^1.2.0"
}, },
@ -1710,6 +1763,7 @@
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -1800,6 +1854,7 @@
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -1838,6 +1893,7 @@
"resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz",
"integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"assertion-error": "^2.0.1", "assertion-error": "^2.0.1",
"check-error": "^2.1.1", "check-error": "^2.1.1",
@ -1870,6 +1926,7 @@
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
"integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">= 16" "node": ">= 16"
} }
@ -2014,6 +2071,7 @@
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@ -2112,9 +2170,9 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.9.1", "version": "9.10.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.1.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.10.0.tgz",
"integrity": "sha512-dHvhrbfr4xFQ9/dq+jcVneZMyRYLjggWjk6RVsIiHsP8Rz6yZ8LvZ//iU4TrZF+SXWG+JkNF2OyiZRvzgRDqMg==", "integrity": "sha512-Y4D0IgtBZfOcOUAIQTSXBKoNGfY0REGqHJG6+Q81vNippW5YlKjHFj4soMxamKK1NXHUWuBZTLdU3Km+L/pcHw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2122,7 +2180,8 @@
"@eslint-community/regexpp": "^4.11.0", "@eslint-community/regexpp": "^4.11.0",
"@eslint/config-array": "^0.18.0", "@eslint/config-array": "^0.18.0",
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
"@eslint/js": "9.9.1", "@eslint/js": "9.10.0",
"@eslint/plugin-kit": "^0.1.0",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.3.0", "@humanwhocodes/retry": "^0.3.0",
"@nodelib/fs.walk": "^1.2.8", "@nodelib/fs.walk": "^1.2.8",
@ -2145,7 +2204,6 @@
"is-glob": "^4.0.0", "is-glob": "^4.0.0",
"is-path-inside": "^3.0.3", "is-path-inside": "^3.0.3",
"json-stable-stringify-without-jsonify": "^1.0.1", "json-stable-stringify-without-jsonify": "^1.0.1",
"levn": "^0.4.1",
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
"minimatch": "^3.1.2", "minimatch": "^3.1.2",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
@ -2383,6 +2441,7 @@
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@types/estree": "^1.0.0" "@types/estree": "^1.0.0"
} }
@ -2396,29 +2455,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/execa": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
"integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
"dev": true,
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^8.0.1",
"human-signals": "^5.0.0",
"is-stream": "^3.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^5.1.0",
"onetime": "^6.0.0",
"signal-exit": "^4.1.0",
"strip-final-newline": "^3.0.0"
},
"engines": {
"node": ">=16.17"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -2582,22 +2618,11 @@
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
"integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": "*" "node": "*"
} }
}, },
"node_modules/get-stream": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
"integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
"dev": true,
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/glob": { "node_modules/glob": {
"version": "10.4.5", "version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@ -2688,15 +2713,6 @@
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true "dev": true
}, },
"node_modules/human-signals": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
"integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
"dev": true,
"engines": {
"node": ">=16.17.0"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
@ -2818,18 +2834,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
"integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
"dev": true,
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -3016,6 +3020,7 @@
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz",
"integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==", "integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"get-func-name": "^2.0.1" "get-func-name": "^2.0.1"
} }
@ -3061,12 +3066,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"dev": true
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -3087,18 +3086,6 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/mimic-fn": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
"integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/min-indent": { "node_modules/min-indent": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@ -3219,48 +3206,6 @@
"semver": "bin/semver" "semver": "bin/semver"
} }
}, },
"node_modules/npm-run-path": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
"integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
"dev": true,
"dependencies": {
"path-key": "^4.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/npm-run-path/node_modules/path-key": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/onetime": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
"integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
"dev": true,
"dependencies": {
"mimic-fn": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.3", "version": "0.9.3",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
@ -3397,13 +3342,15 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true "dev": true,
"license": "MIT"
}, },
"node_modules/pathval": { "node_modules/pathval": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz",
"integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">= 14.16" "node": ">= 14.16"
} }
@ -3934,18 +3881,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/strip-final-newline": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
"integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-indent": { "node_modules/strip-indent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@ -4032,10 +3967,18 @@
"dev": true "dev": true
}, },
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.8.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true "dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz",
"integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==",
"dev": true,
"license": "MIT"
}, },
"node_modules/tinypool": { "node_modules/tinypool": {
"version": "1.0.0", "version": "1.0.0",
@ -4056,10 +3999,11 @@
} }
}, },
"node_modules/tinyspy": { "node_modules/tinyspy": {
"version": "3.0.0", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.0.tgz", "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
"integrity": "sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==", "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@ -4141,9 +4085,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.5.4", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@ -4271,15 +4215,15 @@
} }
}, },
"node_modules/vite-node": { "node_modules/vite-node": {
"version": "2.0.5", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz", "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz",
"integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==", "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"cac": "^6.7.14", "cac": "^6.7.14",
"debug": "^4.3.5", "debug": "^4.3.6",
"pathe": "^1.1.2", "pathe": "^1.1.2",
"tinyrainbow": "^1.2.0",
"vite": "^5.0.0" "vite": "^5.0.0"
}, },
"bin": { "bin": {
@ -4313,29 +4257,30 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "2.0.5", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz",
"integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==", "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.3.0", "@vitest/expect": "2.1.1",
"@vitest/expect": "2.0.5", "@vitest/mocker": "2.1.1",
"@vitest/pretty-format": "^2.0.5", "@vitest/pretty-format": "^2.1.1",
"@vitest/runner": "2.0.5", "@vitest/runner": "2.1.1",
"@vitest/snapshot": "2.0.5", "@vitest/snapshot": "2.1.1",
"@vitest/spy": "2.0.5", "@vitest/spy": "2.1.1",
"@vitest/utils": "2.0.5", "@vitest/utils": "2.1.1",
"chai": "^5.1.1", "chai": "^5.1.1",
"debug": "^4.3.5", "debug": "^4.3.6",
"execa": "^8.0.1", "magic-string": "^0.30.11",
"magic-string": "^0.30.10",
"pathe": "^1.1.2", "pathe": "^1.1.2",
"std-env": "^3.7.0", "std-env": "^3.7.0",
"tinybench": "^2.8.0", "tinybench": "^2.9.0",
"tinyexec": "^0.3.0",
"tinypool": "^1.0.0", "tinypool": "^1.0.0",
"tinyrainbow": "^1.2.0", "tinyrainbow": "^1.2.0",
"vite": "^5.0.0", "vite": "^5.0.0",
"vite-node": "2.0.5", "vite-node": "2.1.1",
"why-is-node-running": "^2.3.0" "why-is-node-running": "^2.3.0"
}, },
"bin": { "bin": {
@ -4350,8 +4295,8 @@
"peerDependencies": { "peerDependencies": {
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0", "@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "2.0.5", "@vitest/browser": "2.1.1",
"@vitest/ui": "2.0.5", "@vitest/ui": "2.1.1",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*" "jsdom": "*"
}, },
@ -4536,9 +4481,9 @@
} }
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz",
"integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==", "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {

View File

@ -51,5 +51,4 @@ services:
volumes: volumes:
- /usr/lib/wsl:/usr/lib/wsl - /usr/lib/wsl:/usr/lib/wsl
environment: environment:
- LD_LIBRARY_PATH=/usr/lib/wsl/lib
- LIBVA_DRIVER_NAME=d3d12 - LIBVA_DRIVER_NAME=d3d12

View File

@ -64,3 +64,43 @@ Below is an example config for Apache2 site configuration.
ProxyPreserveHost On ProxyPreserveHost On
</VirtualHost> </VirtualHost>
``` ```
### Traefik Proxy example config
The example below is for Traefik version 3.
The most important is to increase the `respondingTimeouts` of the entrypoint used by immich. In this example of entrypoint `websecure` for port `443`. Per default it's set to 60s which leeds to videos stop uploading after 1 minute (Error Code 499). With this config it will fail after 10 minutes which is in most cases enough. Increase it if needed.
`traefik.yaml`
```yaml
[...]
entryPoints:
websecure:
address: :443
# this section needs to be added
transport:
respondingTimeouts:
readTimeout: 600s
idleTimeout: 600s
writeTimeout: 600s
```
The second part is in the `docker-compose.yml` file where immich is in. Add the Traefik specific labels like in the example.
`docker-compose.yml`
```yaml
services:
immich-server:
[...]
labels:
traefik.enable: true
# increase readingTimeouts for the entrypoint used here
traefik.http.routers.immich.entrypoints: websecure
traefik.http.routers.immich.rule: Host(`immich.your-domain.com`)
traefik.http.services.immich.loadbalancer.server.port: 3001
```
Keep in mind, that Traefik needs to communicate with the network where immich is in, usually done
by adding the Traefik network to the `immich-server`.

View File

@ -3,6 +3,7 @@ sidebar_position: 1
--- ---
import AppArchitecture from './img/app-architecture.png'; import AppArchitecture from './img/app-architecture.png';
import MobileArchitecture from './img/immich_mobile_architecture.svg';
# Architecture # Architecture
@ -28,7 +29,14 @@ All three clients use [OpenAPI](./open-api.md) to auto-generate rest clients for
### Mobile App ### Mobile App
The mobile app is written in [Flutter](https://flutter.dev/). It uses [Isar Database](https://isar.dev/) for a local database and [Riverpod](https://riverpod.dev/) for state management. The mobile app is written in [Dart](https://dart.dev/) using [Flutter](https://flutter.dev/). Below is an architecture overview:
<MobileArchitecture className="p-4 dark:bg-immich-dark-primary my-4" />
The diagrams shows the target architecture, the current state of the code-base is not always following the architecture yet. New code and contributions should follow this architecture.
Currently, it uses [Isar Database](https://isar.dev/) for a local database and [Riverpod](https://riverpod.dev/) for state management (providers).
Entities and Models are the two types of data classes used. While entities are stored in the on-device database, models are ephemeral and only kept in memory.
The Repositories should be the only place where other data classes are used internally (such as OpenAPI DTOs). However, their interfaces must not use foreign data classes!
### Web Client ### Web Client

View File

@ -0,0 +1,104 @@
<mxfile host="app.diagrams.net" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" version="24.7.16">
<diagram name="Page-1" id="Bp2gX--FtC4sSMWxsLrs">
<mxGraphModel dx="1728" dy="954" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="0" pageScale="1" pageWidth="850" pageHeight="1100" background="none" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="zHhczcy2-Jv_nqmJUiNH-1" value="" style="verticalLabelPosition=bottom;verticalAlign=top;html=1;shape=mxgraph.basic.polygon;polyCoords=[[0.25,0],[0.75,0],[1,0.25],[1,0.75],[0.75,1],[0.25,1],[0,0.75],[0,0.25]];polyline=0;strokeWidth=4;rounded=1;fillColor=#4251B0;" vertex="1" parent="1">
<mxGeometry x="280" y="217.5" width="465" height="465" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-2" value="&lt;b&gt;&lt;font style=&quot;font-size: 22px;&quot;&gt;Mobile App&lt;/font&gt;&lt;/b&gt;" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;rounded=1;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="442.5" y="225" width="140" height="40" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-25" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-4" target="zHhczcy2-Jv_nqmJUiNH-5">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-4" value="Services" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFB400;" vertex="1" parent="1">
<mxGeometry x="530" y="420" width="80" height="60" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-26" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-5" target="zHhczcy2-Jv_nqmJUiNH-12">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-5" value="Repositories" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1E83F7;" vertex="1" parent="1">
<mxGeometry x="650" y="420" width="80" height="60" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-24" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-6" target="zHhczcy2-Jv_nqmJUiNH-4">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-6" value="Providers" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ED79B5;" vertex="1" parent="1">
<mxGeometry x="410" y="420" width="80" height="60" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-29" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-7" target="zHhczcy2-Jv_nqmJUiNH-8">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-30" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.75;entryDx=0;entryDy=0;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-7" target="zHhczcy2-Jv_nqmJUiNH-6">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-7" value="Pages" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FA2921;" vertex="1" parent="1">
<mxGeometry x="290" y="480" width="80" height="60" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-31" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.25;entryDx=0;entryDy=0;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-8" target="zHhczcy2-Jv_nqmJUiNH-6">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-8" value="Widgets" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FA2921;" vertex="1" parent="1">
<mxGeometry x="290" y="360" width="80" height="60" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-11" value="User" style="shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;html=1;outlineConnect=0;rounded=1;fillColor=#4251B0;" vertex="1" parent="1">
<mxGeometry x="180" y="368.5" width="81.5" height="163" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-12" value="platform&lt;div&gt;system&lt;/div&gt;" style="rhombus;whiteSpace=wrap;html=1;rounded=1;fillColor=#ED79B5;" vertex="1" parent="1">
<mxGeometry x="800" y="410" width="80" height="80" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-13" value="on-device&lt;div&gt;database&lt;/div&gt;" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=15;rounded=1;fillColor=#FA2921;" vertex="1" parent="1">
<mxGeometry x="810" y="310" width="60" height="80" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-14" value="server" style="ellipse;shape=cloud;whiteSpace=wrap;html=1;rounded=1;fillColor=#FFB400;" vertex="1" parent="1">
<mxGeometry x="780" y="500" width="120" height="80" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-16" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.75;exitDx=0;exitDy=0;entryX=0.07;entryY=0.4;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-5" target="zHhczcy2-Jv_nqmJUiNH-14">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-39" value="OpenAPI" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];rounded=1;labelBackgroundColor=#1E83F7;" vertex="1" connectable="0" parent="zHhczcy2-Jv_nqmJUiNH-16">
<mxGeometry x="0.0697" y="1" relative="1" as="geometry">
<mxPoint x="8" y="10" as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-23" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.75;exitY=1;exitDx=0;exitDy=0;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-6" target="zHhczcy2-Jv_nqmJUiNH-6">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-27" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.25;exitDx=0;exitDy=0;entryX=0;entryY=1;entryDx=0;entryDy=-15;entryPerimeter=0;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-5" target="zHhczcy2-Jv_nqmJUiNH-13">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-34" style="edgeStyle=none;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;dashed=1;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-3">
<mxGeometry relative="1" as="geometry">
<mxPoint x="810" y="360" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-36" value="" style="endArrow=none;dashed=1;html=1;rounded=1;" edge="1" parent="1" source="zHhczcy2-Jv_nqmJUiNH-9">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="512.08" y="665" as="sourcePoint" />
<mxPoint x="512.08" y="265" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-37" value="UI part" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;fontSize=14;fontColor=#FFFFFF;" vertex="1" parent="1">
<mxGeometry x="387.5" y="640" width="70" height="30" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-38" value="non-UI part" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;fontSize=14;fontColor=#FFFFFF;" vertex="1" parent="1">
<mxGeometry x="550" y="640" width="90" height="30" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-41" value="" style="endArrow=none;dashed=1;html=1;rounded=1;" edge="1" parent="1" target="zHhczcy2-Jv_nqmJUiNH-9">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="512.08" y="665" as="sourcePoint" />
<mxPoint x="512.08" y="265" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-9" value="Models" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#18C249;" vertex="1" parent="1">
<mxGeometry x="470" y="510" width="80" height="60" as="geometry" />
</mxCell>
<mxCell id="zHhczcy2-Jv_nqmJUiNH-3" value="Entities" style="rounded=1;whiteSpace=wrap;html=1;gradientColor=none;fillColor=#18C249;" vertex="1" parent="1">
<mxGeometry x="472.5" y="330" width="80" height="60" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

34
docs/package-lock.json generated
View File

@ -6068,9 +6068,10 @@
} }
}, },
"node_modules/docusaurus-lunr-search": { "node_modules/docusaurus-lunr-search": {
"version": "3.4.0", "version": "3.5.0",
"resolved": "https://registry.npmjs.org/docusaurus-lunr-search/-/docusaurus-lunr-search-3.4.0.tgz", "resolved": "https://registry.npmjs.org/docusaurus-lunr-search/-/docusaurus-lunr-search-3.5.0.tgz",
"integrity": "sha512-GfllnNXCLgTSPH9TAKWmbn8VMfwpdOAZ1xl3T2GgX8Pm26qSDLfrrdVwjguaLfMJfzciFL97RKrAJlgrFM48yw==", "integrity": "sha512-k3zN4jYMi/prWInJILGKOxE+BVcgYinwj9+gcECsYm52tS+4ZKzXQzbPnVJAEXmvKOfFMcDFvS3MSmm6cEaxIQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"autocomplete.js": "^0.37.0", "autocomplete.js": "^0.37.0",
"clsx": "^1.2.1", "clsx": "^1.2.1",
@ -6097,14 +6098,16 @@
} }
}, },
"node_modules/docusaurus-lunr-search/node_modules/@types/unist": { "node_modules/docusaurus-lunr-search/node_modules/@types/unist": {
"version": "2.0.10", "version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
"integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
}, },
"node_modules/docusaurus-lunr-search/node_modules/bail": { "node_modules/docusaurus-lunr-search/node_modules/bail": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz",
"integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==", "integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==",
"license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
@ -6114,6 +6117,7 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@ -6122,6 +6126,7 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
"license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -6130,6 +6135,7 @@
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz",
"integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==", "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==",
"license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
@ -6139,6 +6145,7 @@
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/unified/-/unified-9.2.2.tgz", "resolved": "https://registry.npmjs.org/unified/-/unified-9.2.2.tgz",
"integrity": "sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ==", "integrity": "sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"bail": "^1.0.0", "bail": "^1.0.0",
"extend": "^3.0.0", "extend": "^3.0.0",
@ -6156,6 +6163,7 @@
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz",
"integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==",
"license": "MIT",
"dependencies": { "dependencies": {
"@types/unist": "^2.0.2" "@types/unist": "^2.0.2"
}, },
@ -6168,6 +6176,7 @@
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz", "resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz",
"integrity": "sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==", "integrity": "sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@types/unist": "^2.0.0", "@types/unist": "^2.0.0",
"is-buffer": "^2.0.0", "is-buffer": "^2.0.0",
@ -6183,6 +6192,7 @@
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz",
"integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@types/unist": "^2.0.0", "@types/unist": "^2.0.0",
"unist-util-stringify-position": "^2.0.0" "unist-util-stringify-position": "^2.0.0"
@ -16081,9 +16091,9 @@
} }
}, },
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "3.4.10", "version": "3.4.12",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.12.tgz",
"integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==", "integrity": "sha512-Htf/gHj2+soPb9UayUNci/Ja3d8pTmu9ONTfh4QY8r3MATTZOzmv6UYWF7ZwikEIC8okpfqmGqrmDehua8mF8w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
@ -16443,9 +16453,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.5.4", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",

View File

@ -22,7 +22,6 @@ services:
- IMMICH_METRICS=true - IMMICH_METRICS=true
- IMMICH_ENV=testing - IMMICH_ENV=testing
volumes: volumes:
- upload:/usr/src/app/upload
- ./test-assets:/test-assets - ./test-assets:/test-assets
extra_hosts: extra_hosts:
- 'auth-server:host-gateway' - 'auth-server:host-gateway'
@ -44,7 +43,3 @@ services:
POSTGRES_DB: immich POSTGRES_DB: immich
ports: ports:
- 5435:5432 - 5435:5432
volumes:
model-cache:
upload:

761
e2e/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -53,8 +53,10 @@ export default defineConfig({
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
webServer: { webServer: {
command: 'docker compose up --build -V --remove-orphans', command: 'docker compose up --build --renew-anon-volumes --force-recreate --remove-orphans',
url: 'http://127.0.0.1:2285', url: 'http://127.0.0.1:2285',
stdout: 'pipe',
stderr: 'pipe',
reuseExistingServer: true, reuseExistingServer: true,
}, },
}); });

View File

@ -1,8 +1,7 @@
import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk'; import { LoginResponseDto } from '@immich/sdk';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { basename, join } from 'node:path'; import { basename, join } from 'node:path';
import { Socket } from 'socket.io-client'; import { Socket } from 'socket.io-client';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
import { app, testAssetDir, utils } from 'src/utils'; import { app, testAssetDir, utils } from 'src/utils';
import request from 'supertest'; import request from 'supertest';
@ -11,18 +10,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('/map', () => { describe('/map', () => {
let websocket: Socket; let websocket: Socket;
let admin: LoginResponseDto; let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
let asset: AssetMediaResponseDto;
beforeAll(async () => { beforeAll(async () => {
await utils.resetDatabase(); await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false }); admin = await utils.adminSetup({ onboarding: false });
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
websocket = await utils.connectWebsocket(admin.accessToken); websocket = await utils.connectWebsocket(admin.accessToken);
asset = await utils.createAsset(admin.accessToken);
const files = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg']; const files = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg'];
utils.resetEvents(); utils.resetEvents();
const uploadFile = async (input: string) => { const uploadFile = async (input: string) => {
@ -103,63 +97,6 @@ describe('/map', () => {
}); });
}); });
describe('GET /map/style.json', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/map/style.json');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should allow shared link access', async () => {
const sharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset.id],
});
const { status, body } = await request(app).get(`/map/style.json?key=${sharedLink.key}`).query({ theme: 'dark' });
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
it('should throw an error if a theme is not light or dark', async () => {
for (const theme of ['dark1', true, 123, '', null, undefined]) {
const { status, body } = await request(app)
.get('/map/style.json')
.query({ theme })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['theme must be one of the following values: light, dark']));
}
});
it('should return the light style.json', async () => {
const { status, body } = await request(app)
.get('/map/style.json')
.query({ theme: 'light' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-light' }));
});
it('should return the dark style.json', async () => {
const { status, body } = await request(app)
.get('/map/style.json')
.query({ theme: 'dark' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
it('should not require admin authentication', async () => {
const { status, body } = await request(app)
.get('/map/style.json')
.query({ theme: 'dark' })
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
});
describe('GET /map/reverse-geocode', () => { describe('GET /map/reverse-geocode', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(app).get('/map/reverse-geocode'); const { status, body } = await request(app).get('/map/reverse-geocode');

View File

@ -128,6 +128,8 @@ describe('/server-info', () => {
isInitialized: true, isInitialized: true,
externalDomain: '', externalDomain: '',
isOnboarded: false, isOnboarded: false,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
}); });
}); });
}); });

View File

@ -134,6 +134,8 @@ describe('/server', () => {
isInitialized: true, isInitialized: true,
externalDomain: '', externalDomain: '',
isOnboarded: false, isOnboarded: false,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
}); });
}); });
}); });

View File

@ -34,8 +34,11 @@ describe('/trash', () => {
const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true })); expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true }));
const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`); const { status, body } = await request(app)
expect(status).toBe(204); .post('/trash/empty')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ count: 1 });
await utils.waitForWebsocketEvent({ event: 'assetDelete', id: assetId }); await utils.waitForWebsocketEvent({ event: 'assetDelete', id: assetId });
@ -51,8 +54,11 @@ describe('/trash', () => {
const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true, isArchived: true })); expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true, isArchived: true }));
const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`); const { status, body } = await request(app)
expect(status).toBe(204); .post('/trash/empty')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ count: 1 });
await utils.waitForWebsocketEvent({ event: 'assetDelete', id: assetId }); await utils.waitForWebsocketEvent({ event: 'assetDelete', id: assetId });
@ -76,8 +82,11 @@ describe('/trash', () => {
const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true })); expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true }));
const { status } = await request(app).post('/trash/restore').set('Authorization', `Bearer ${admin.accessToken}`); const { status, body } = await request(app)
expect(status).toBe(204); .post('/trash/restore')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ count: 1 });
const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: false })); expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: false }));
@ -99,11 +108,12 @@ describe('/trash', () => {
const before = await utils.getAssetInfo(admin.accessToken, assetId); const before = await utils.getAssetInfo(admin.accessToken, assetId);
expect(before.isTrashed).toBe(true); expect(before.isTrashed).toBe(true);
const { status } = await request(app) const { status, body } = await request(app)
.post('/trash/restore/assets') .post('/trash/restore/assets')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ids: [assetId] }); .send({ ids: [assetId] });
expect(status).toBe(204); expect(status).toBe(200);
expect(body).toEqual({ count: 1 });
const after = await utils.getAssetInfo(admin.accessToken, assetId); const after = await utils.getAssetInfo(admin.accessToken, assetId);
expect(after.isTrashed).toBe(false); expect(after.isTrashed).toBe(false);

View File

@ -12,7 +12,8 @@ const setup = async () => {
const timeout = setTimeout(() => _reject(new Error('Timeout starting e2e environment')), 60_000); const timeout = setTimeout(() => _reject(new Error('Timeout starting e2e environment')), 60_000);
const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' }); const command = 'compose up --build --renew-anon-volumes --force-recreate --remove-orphans';
const child = spawn('docker', command.split(' '), { stdio: 'pipe' });
child.stdout.on('data', (data) => { child.stdout.on('data', (data) => {
const input = data.toString(); const input = data.toString();

View File

@ -156,8 +156,7 @@ export const utils = {
for (const table of tables) { for (const table of tables) {
if (table === 'system_metadata') { if (table === 'system_metadata') {
// prevent reverse geocoder from being re-initialized sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
sql.push(`DELETE FROM "system_metadata" where "key" != 'reverse-geocoding-state';`);
} else { } else {
sql.push(`DELETE FROM ${table} CASCADE;`); sql.push(`DELETE FROM ${table} CASCADE;`);
} }

View File

@ -1237,13 +1237,13 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]] [[package]]
name = "huggingface-hub" name = "huggingface-hub"
version = "0.24.6" version = "0.25.0"
description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub"
optional = false optional = false
python-versions = ">=3.8.0" python-versions = ">=3.8.0"
files = [ files = [
{file = "huggingface_hub-0.24.6-py3-none-any.whl", hash = "sha256:a990f3232aa985fe749bc9474060cbad75e8b2f115f6665a9fda5b9c97818970"}, {file = "huggingface_hub-0.25.0-py3-none-any.whl", hash = "sha256:e2f357b35d72d5012cfd127108c4e14abcd61ba4ebc90a5a374dc2456cb34e12"},
{file = "huggingface_hub-0.24.6.tar.gz", hash = "sha256:cc2579e761d070713eaa9c323e3debe39d5b464ae3a7261c39a9195b27bb8000"}, {file = "huggingface_hub-0.25.0.tar.gz", hash = "sha256:fb5fbe6c12fcd99d187ec7db95db9110fb1a20505f23040a5449a717c1a0db4d"},
] ]
[package.dependencies] [package.dependencies]
@ -1531,13 +1531,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"]
[[package]] [[package]]
name = "locust" name = "locust"
version = "2.31.5" version = "2.31.6"
description = "Developer-friendly load testing framework" description = "Developer-friendly load testing framework"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
{file = "locust-2.31.5-py3-none-any.whl", hash = "sha256:2904ff6307d54d3202c9ebd776f9170214f6dfbe4059504dad9e3ffaca03f600"}, {file = "locust-2.31.6-py3-none-any.whl", hash = "sha256:004c963c7a588dc15d57d710cdc6a262d85b57936d7fad3c38ac0657aa98fc3b"},
{file = "locust-2.31.5.tar.gz", hash = "sha256:14b2fa6f95bf248668e6dc92d100a44f06c5dcb1c26f88a5442bcaaee18faceb"}, {file = "locust-2.31.6.tar.gz", hash = "sha256:03b6da0491d6a0b905692d9ac128d9deec403f40dc605c481a90dbab5126318c"},
] ]
[package.dependencies] [package.dependencies]
@ -2834,29 +2834,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.6.4" version = "0.6.6"
description = "An extremely fast Python linter and code formatter, written in Rust." description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "ruff-0.6.4-py3-none-linux_armv6l.whl", hash = "sha256:c4b153fc152af51855458e79e835fb6b933032921756cec9af7d0ba2aa01a258"}, {file = "ruff-0.6.6-py3-none-linux_armv6l.whl", hash = "sha256:f5bc5398457484fc0374425b43b030e4668ed4d2da8ee7fdda0e926c9f11ccfb"},
{file = "ruff-0.6.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bedff9e4f004dad5f7f76a9d39c4ca98af526c9b1695068198b3bda8c085ef60"}, {file = "ruff-0.6.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:515a698254c9c47bb84335281a170213b3ee5eb47feebe903e1be10087a167ce"},
{file = "ruff-0.6.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d02a4127a86de23002e694d7ff19f905c51e338c72d8e09b56bfb60e1681724f"}, {file = "ruff-0.6.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6bb1b4995775f1837ab70f26698dd73852bbb82e8f70b175d2713c0354fe9182"},
{file = "ruff-0.6.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7862f42fc1a4aca1ea3ffe8a11f67819d183a5693b228f0bb3a531f5e40336fc"}, {file = "ruff-0.6.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69c546f412dfae8bb9cc4f27f0e45cdd554e42fecbb34f03312b93368e1cd0a6"},
{file = "ruff-0.6.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eebe4ff1967c838a1a9618a5a59a3b0a00406f8d7eefee97c70411fefc353617"}, {file = "ruff-0.6.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59627e97364329e4eae7d86fa7980c10e2b129e2293d25c478ebcb861b3e3fd6"},
{file = "ruff-0.6.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:932063a03bac394866683e15710c25b8690ccdca1cf192b9a98260332ca93408"}, {file = "ruff-0.6.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94c3f78c3d32190aafbb6bc5410c96cfed0a88aadb49c3f852bbc2aa9783a7d8"},
{file = "ruff-0.6.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:50e30b437cebef547bd5c3edf9ce81343e5dd7c737cb36ccb4fe83573f3d392e"}, {file = "ruff-0.6.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:704da526c1e137f38c8a067a4a975fe6834b9f8ba7dbc5fd7503d58148851b8f"},
{file = "ruff-0.6.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c44536df7b93a587de690e124b89bd47306fddd59398a0fb12afd6133c7b3818"}, {file = "ruff-0.6.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:efeede5815a24104579a0f6320660536c5ffc1c91ae94f8c65659af915fb9de9"},
{file = "ruff-0.6.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ea086601b22dc5e7693a78f3fcfc460cceabfdf3bdc36dc898792aba48fbad6"}, {file = "ruff-0.6.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e368aef0cc02ca3593eae2fb8186b81c9c2b3f39acaaa1108eb6b4d04617e61f"},
{file = "ruff-0.6.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b52387d3289ccd227b62102c24714ed75fbba0b16ecc69a923a37e3b5e0aaaa"}, {file = "ruff-0.6.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2653fc3b2a9315bd809725c88dd2446550099728d077a04191febb5ea79a4f79"},
{file = "ruff-0.6.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0308610470fcc82969082fc83c76c0d362f562e2f0cdab0586516f03a4e06ec6"}, {file = "ruff-0.6.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bb858cd9ce2d062503337c5b9784d7b583bcf9d1a43c4df6ccb5eab774fbafcb"},
{file = "ruff-0.6.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:803b96dea21795a6c9d5bfa9e96127cc9c31a1987802ca68f35e5c95aed3fc0d"}, {file = "ruff-0.6.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:488f8e15c01ea9afb8c0ba35d55bd951f484d0c1b7c5fd746ce3c47ccdedce68"},
{file = "ruff-0.6.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:66dbfea86b663baab8fcae56c59f190caba9398df1488164e2df53e216248baa"}, {file = "ruff-0.6.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:aefb0bd15f1cfa4c9c227b6120573bb3d6c4ee3b29fb54a5ad58f03859bc43c6"},
{file = "ruff-0.6.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:34d5efad480193c046c86608dbba2bccdc1c5fd11950fb271f8086e0c763a5d1"}, {file = "ruff-0.6.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a4c0698cc780bcb2c61496cbd56b6a3ac0ad858c966652f7dbf4ceb029252fbe"},
{file = "ruff-0.6.4-py3-none-win32.whl", hash = "sha256:f0f8968feea5ce3777c0d8365653d5e91c40c31a81d95824ba61d871a11b8523"}, {file = "ruff-0.6.6-py3-none-win32.whl", hash = "sha256:aadf81ddc8ab5b62da7aae78a91ec933cbae9f8f1663ec0325dae2c364e4ad84"},
{file = "ruff-0.6.4-py3-none-win_amd64.whl", hash = "sha256:549daccee5227282289390b0222d0fbee0275d1db6d514550d65420053021a58"}, {file = "ruff-0.6.6-py3-none-win_amd64.whl", hash = "sha256:0adb801771bc1f1b8cf4e0a6fdc30776e7c1894810ff3b344e50da82ef50eeb1"},
{file = "ruff-0.6.4-py3-none-win_arm64.whl", hash = "sha256:ac4b75e898ed189b3708c9ab3fc70b79a433219e1e87193b4f2b77251d058d14"}, {file = "ruff-0.6.6-py3-none-win_arm64.whl", hash = "sha256:4b4d32c137bc781c298964dd4e52f07d6f7d57c03eae97a72d97856844aa510a"},
{file = "ruff-0.6.4.tar.gz", hash = "sha256:ac3b5bfbee99973f80aa1b7cbd1c9cbce200883bdd067300c22a6cc1c7fba212"}, {file = "ruff-0.6.6.tar.gz", hash = "sha256:0fc030b6fd14814d69ac0196396f6761921bd20831725c7361e1b8100b818034"},
] ]
[[package]] [[package]]

View File

@ -1,3 +1,3 @@
{ {
"flutter": "3.24.0" "flutter": "3.24.3"
} }

View File

@ -1,5 +1,5 @@
{ {
"dart.flutterSdkPath": ".fvm/versions/3.24.0", "dart.flutterSdkPath": ".fvm/versions/3.24.3",
"search.exclude": { "search.exclude": {
"**/.fvm": true "**/.fvm": true
}, },

View File

@ -39,6 +39,73 @@ analyzer:
plugins: plugins:
- custom_lint - custom_lint
custom_lint:
debug: true
rules:
- avoid_build_context_in_providers: false
- avoid_public_notifier_properties: false
- avoid_manual_providers_as_generated_provider_dependency: false
- unsupported_provider_value: false
- import_rule_photo_manager:
message: photo_manager must only be used in MediaRepositories
restrict: package:photo_manager
allowed:
# required / wanted
- 'lib/repositories/{album,asset,file}_media.repository.dart'
# acceptable exceptions for the time being
- lib/entities/asset.entity.dart # to provide local AssetEntity for now
- lib/providers/image/immich_local_{image,thumbnail}_provider.dart # accesses thumbnails via PhotoManager
# refactor to make the providers and services testable
- lib/providers/backup/{backup,manual_upload}.provider.dart # uses only PMProgressHandler
- lib/services/{background,backup}.service.dart # uses only PMProgressHandler
- import_rule_isar:
message: isar must only be used in entities and repositories
restrict: package:isar
allowed:
# required / wanted
- lib/entities/*.entity.dart
- lib/repositories/{album,asset,backup,exif_info,user}.repository.dart
# acceptable exceptions for the time being
- integration_test/test_utils/general_helper.dart
- lib/main.dart
- lib/routing/router.dart
- lib/utils/{db,image_url_builder,migration,renderlist_generator}.dart
- test/**.dart
# refactor to make the providers and services testable
- lib/pages/common/{album_asset_selection,gallery_viewer}.page.dart
- lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart
- lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,backup/manual_upload,search/all_motion_photos,search/recently_added_asset}.provider.dart
- lib/services/{asset,background,backup,hash,immich_logger,memory,partner,person,search,stack,sync,user}.service.dart
- lib/widgets/asset_grid/{asset_grid_data_structure,thumbnail_image}.dart
- import_rule_openapi:
message: openapi must only be used through ApiRepositories
restrict: package:openapi
allowed:
# requried / wanted
- lib/repositories/*_api.repository.dart
# acceptable exceptions for the time being
- lib/entities/{album,asset,exif_info,user}.entity.dart # to convert DTOs to entities
- lib/utils/{image_url_builder,openapi_patching}.dart # utils are fine
- test/modules/utils/openapi_patching_test.dart # filename is self-explanatory...
# refactor
- lib/models/map/map_marker.model.dart
- lib/models/search/search_filter.model.dart
- lib/models/server_info/server_{config,disk_info,features,version}.model.dart
- lib/models/shared_link/shared_link.model.dart
- lib/pages/search/search_input.page.dart
- lib/providers/asset_viewer/asset_people.provider.dart
- lib/providers/authentication.provider.dart
- lib/providers/image/immich_remote_{image,thumbnail}_provider.dart
- lib/providers/map/map_state.provider.dart
- lib/providers/search/{people,search,search_filter}.provider.dart
- lib/providers/websocket.provider.dart
- lib/routing/auth_guard.dart
- lib/services/{api,asset,backup,memory,oauth,partner,person,search,shared_link,stack,trash,user}.service.dart
- lib/widgets/album/album_thumbnail_listtile.dart
- lib/widgets/forms/login/login_form.dart
- lib/widgets/search/search_filter/{camera_picker,location_picker,people_picker}.dart
dart_code_metrics: dart_code_metrics:
metrics: metrics:
cyclomatic-complexity: 20 cyclomatic-complexity: 20

View File

@ -0,0 +1 @@
include: package:lints/recommended.yaml

View File

@ -0,0 +1,86 @@
import 'package:analyzer/error/listener.dart';
import 'package:analyzer/error/error.dart' show ErrorSeverity;
import 'package:custom_lint_builder/custom_lint_builder.dart';
// ignore: depend_on_referenced_packages
import 'package:glob/glob.dart';
PluginBase createPlugin() => ImmichLinter();
class ImmichLinter extends PluginBase {
@override
List<LintRule> getLintRules(CustomLintConfigs configs) {
final List<LintRule> rules = [];
for (final entry in configs.rules.entries) {
if (entry.value.enabled && entry.key.startsWith("import_rule_")) {
final code = makeCode(entry.key, entry.value);
final allowedPaths = getStrings(entry.value, "allowed");
final forbiddenPaths = getStrings(entry.value, "forbidden");
final restrict = getStrings(entry.value, "restrict");
rules.add(ImportRule(code, buildGlob(allowedPaths),
buildGlob(forbiddenPaths), restrict));
}
}
return rules;
}
static makeCode(String name, LintOptions options) => LintCode(
name: name,
problemMessage: options.json["message"] as String,
errorSeverity: ErrorSeverity.WARNING,
);
static List<String> getStrings(LintOptions options, String field) {
final List<String> result = [];
final excludeOption = options.json[field];
if (excludeOption is String) {
result.add(excludeOption);
} else if (excludeOption is List) {
result.addAll(excludeOption.map((option) => option));
}
return result;
}
Glob? buildGlob(List<String> globs) {
if (globs.isEmpty) return null;
if (globs.length == 1) return Glob(globs[0], caseSensitive: true);
return Glob("{${globs.join(",")}}", caseSensitive: true);
}
}
// ignore: must_be_immutable
class ImportRule extends DartLintRule {
ImportRule(LintCode code, this._allowed, this._forbidden, this._restrict)
: super(code: code);
final Glob? _allowed;
final Glob? _forbidden;
final List<String> _restrict;
int _rootOffset = -1;
@override
void run(
CustomLintResolver resolver,
ErrorReporter reporter,
CustomLintContext context,
) {
if (_rootOffset == -1) {
const project = "/immich/mobile/";
_rootOffset = resolver.path.indexOf(project) + project.length;
}
final path = resolver.path.substring(_rootOffset);
if ((_allowed != null && _allowed!.matches(path)) &&
(_forbidden == null || !_forbidden!.matches(path))) return;
context.registry.addImportDirective((node) {
final uri = node.uri.stringValue;
if (uri == null) return;
for (final restricted in _restrict) {
if (uri.startsWith(restricted) == true) {
reporter.atNode(node, code);
return;
}
}
});
}
}

View File

@ -0,0 +1,370 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "45cfa8471b89fb6643fe9bf51bd7931a76b8f5ec2d65de4fb176dba8d4f22c77"
url: "https://pub.dev"
source: hosted
version: "73.0.0"
_macros:
dependency: transitive
description: dart
source: sdk
version: "0.3.2"
analyzer:
dependency: "direct main"
description:
name: analyzer
sha256: "4959fec185fe70cce007c57e9ab6983101dbe593d2bf8bbfb4453aaec0cf470a"
url: "https://pub.dev"
source: hosted
version: "6.8.0"
analyzer_plugin:
dependency: "direct main"
description:
name: analyzer_plugin
sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161"
url: "https://pub.dev"
source: hosted
version: "0.11.3"
args:
dependency: transitive
description:
name: args
sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a"
url: "https://pub.dev"
source: hosted
version: "2.5.0"
async:
dependency: transitive
description:
name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
url: "https://pub.dev"
source: hosted
version: "2.11.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
url: "https://pub.dev"
source: hosted
version: "2.0.3"
ci:
dependency: transitive
description:
name: ci
sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19
url: "https://pub.dev"
source: hosted
version: "0.4.1"
collection:
dependency: transitive
description:
name: collection
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
url: "https://pub.dev"
source: hosted
version: "1.19.0"
convert:
dependency: transitive
description:
name: convert
sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27
url: "https://pub.dev"
source: hosted
version: "3.0.5"
custom_lint:
dependency: transitive
description:
name: custom_lint
sha256: "6e1ec47427ca968f22bce734d00028ae7084361999b41673291138945c5baca0"
url: "https://pub.dev"
source: hosted
version: "0.6.7"
custom_lint_builder:
dependency: "direct main"
description:
name: custom_lint_builder
sha256: ba2f90fff4eff71d202d097eb14b14f87087eaaef742e956208c0eb9d3a40a21
url: "https://pub.dev"
source: hosted
version: "0.6.7"
custom_lint_core:
dependency: transitive
description:
name: custom_lint_core
sha256: "4ddbbdaa774265de44c97054dcec058a83d9081d071785ece601e348c18c267d"
url: "https://pub.dev"
source: hosted
version: "0.6.5"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab"
url: "https://pub.dev"
source: hosted
version: "2.3.7"
file:
dependency: transitive
description:
name: file
sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
freezed_annotation:
dependency: transitive
description:
name: freezed_annotation
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
url: "https://pub.dev"
source: hosted
version: "2.4.4"
glob:
dependency: "direct main"
description:
name: glob
sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
hotreloader:
dependency: transitive
description:
name: hotreloader
sha256: ed56fdc1f3a8ac924e717257621d09e9ec20e308ab6352a73a50a1d7a4d9158e
url: "https://pub.dev"
source: hosted
version: "4.2.0"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
lints:
dependency: "direct dev"
description:
name: lints
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
macros:
dependency: transitive
description:
name: macros
sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536"
url: "https://pub.dev"
source: hosted
version: "0.1.2-main.4"
matcher:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.dev"
source: hosted
version: "0.12.16+1"
meta:
dependency: transitive
description:
name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
url: "https://pub.dev"
source: hosted
version: "1.15.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
path:
dependency: transitive
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8
url: "https://pub.dev"
source: hosted
version: "1.3.0"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
url: "https://pub.dev"
source: hosted
version: "1.10.0"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
url: "https://pub.dev"
source: hosted
version: "1.11.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
url: "https://pub.dev"
source: hosted
version: "2.1.2"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
url: "https://pub.dev"
source: hosted
version: "1.2.1"
test_api:
dependency: transitive
description:
name: test_api
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
url: "https://pub.dev"
source: hosted
version: "0.7.3"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
url: "https://pub.dev"
source: hosted
version: "1.3.2"
uuid:
dependency: transitive
description:
name: uuid
sha256: f33d6bb662f0e4f79dcd7ada2e6170f3b3a2530c28fc41f49a411ddedd576a77
url: "https://pub.dev"
source: hosted
version: "4.5.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev"
source: hosted
version: "14.2.5"
watcher:
dependency: transitive
description:
name: watcher
sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5"
url: "https://pub.dev"
source: hosted
version: "3.1.2"
sdks:
dart: ">=3.4.0 <4.0.0"

View File

@ -0,0 +1,14 @@
name: immich_mobile_immich_lint
publish_to: none
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
analyzer: ^6.8.0
analyzer_plugin: ^0.11.3
custom_lint_builder: ^0.6.4
glob: ^2.1.2
dev_dependencies:
lints: ^4.0.0

View File

@ -1,11 +1,11 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.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/utils/datetime_comparison.dart'; import 'package:immich_mobile/utils/datetime_comparison.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
// ignore: implementation_imports
import 'package:isar/src/common/isar_links_common.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
part 'album.entity.g.dart'; part 'album.entity.g.dart';
@ -25,6 +25,7 @@ class Album {
required this.activityEnabled, required this.activityEnabled,
}); });
// fields stored in DB
Id id = Isar.autoIncrement; Id id = Isar.autoIncrement;
@Index(unique: false, replace: false, type: IndexType.hash) @Index(unique: false, replace: false, type: IndexType.hash)
String? remoteId; String? remoteId;
@ -43,6 +44,17 @@ class Album {
final IsarLinks<User> sharedUsers = IsarLinks<User>(); final IsarLinks<User> sharedUsers = IsarLinks<User>();
final IsarLinks<Asset> assets = IsarLinks<Asset>(); final IsarLinks<Asset> assets = IsarLinks<Asset>();
// transient fields
@ignore
bool isAll = false;
@ignore
String? remoteThumbnailAssetId;
@ignore
int remoteAssetCount = 0;
// getters
@ignore @ignore
bool get isRemote => remoteId != null; bool get isRemote => remoteId != null;
@ -70,6 +82,21 @@ class Album {
return name.join(' '); return name.join(' ');
} }
@ignore
String get eTagKeyAssetCount => "device-album-$localId-asset-count";
// the following getter are needed because Isar links do not make data
// accessible in an object freshly created (not loaded from DB)
@ignore
Iterable<User> get remoteUsers => sharedUsers.isEmpty
? (sharedUsers as IsarLinksCommon<User>).addedObjects
: sharedUsers;
@ignore
Iterable<Asset> get remoteAssets =>
assets.isEmpty ? (assets as IsarLinksCommon<Asset>).addedObjects : assets;
@override @override
bool operator ==(other) { bool operator ==(other) {
if (other is! Album) return false; if (other is! Album) return false;
@ -112,19 +139,6 @@ class Album {
sharedUsers.length.hashCode ^ sharedUsers.length.hashCode ^
assets.length.hashCode; assets.length.hashCode;
static Album local(AssetPathEntity ape) {
final Album a = Album(
name: ape.name,
createdAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
shared: false,
activityEnabled: false,
);
a.owner.value = Store.get(StoreKey.currentUser);
a.localId = ape.id;
return a;
}
static Future<Album> remote(AlbumResponseDto dto) async { static Future<Album> remote(AlbumResponseDto dto) async {
final Isar db = Isar.getInstance()!; final Isar db = Isar.getInstance()!;
final Album a = Album( final Album a = Album(
@ -138,6 +152,7 @@ class Album {
endDate: dto.endDate, endDate: dto.endDate,
activityEnabled: dto.isActivityEnabled, activityEnabled: dto.isActivityEnabled,
); );
a.remoteAssetCount = dto.assetCount;
a.owner.value = await db.users.getById(dto.ownerId); a.owner.value = await db.users.getById(dto.ownerId);
if (dto.albumThumbnailAssetId != null) { if (dto.albumThumbnailAssetId != null) {
a.thumbnail.value = await db.assets a.thumbnail.value = await db.assets
@ -173,11 +188,3 @@ extension AssetsHelper on IsarCollection<Album> {
return a; return a;
} }
} }
extension AlbumResponseDtoHelper on AlbumResponseDto {
List<Asset> getAssets() => assets.map(Asset.remote).toList();
}
extension AssetPathEntityHelper on AssetPathEntity {
String get eTagKeyAssetCount => "device-album-$id-asset-count";
}

View File

@ -1,11 +1,10 @@
import 'dart:convert'; import 'dart:convert';
import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/hash.dart'; import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart' show AssetEntity;
import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -42,33 +41,6 @@ class Asset {
stackId = remote.stack?.id, stackId = remote.stack?.id,
thumbhash = remote.thumbhash; thumbhash = remote.thumbhash;
Asset.local(AssetEntity local, List<int> hash)
: localId = local.id,
checksum = base64.encode(hash),
durationInSeconds = local.duration,
type = AssetType.values[local.typeInt],
height = local.height,
width = local.width,
fileName = local.title!,
ownerId = Store.get(StoreKey.currentUser).isarId,
fileModifiedAt = local.modifiedDateTime,
updatedAt = local.modifiedDateTime,
isFavorite = local.isFavorite,
isArchived = false,
isTrashed = false,
isOffline = false,
stackCount = 0,
fileCreatedAt = local.createDateTime {
if (fileCreatedAt.year == 1970) {
fileCreatedAt = fileModifiedAt;
}
if (local.latitude != null) {
exifInfo = ExifInfo(lat: local.latitude, long: local.longitude);
}
_local = local;
assert(hash.length == 20, "invalid SHA1 hash");
}
Asset({ Asset({
this.id = Isar.autoIncrement, this.id = Isar.autoIncrement,
required this.checksum, required this.checksum,
@ -115,6 +87,8 @@ class Asset {
return _local; return _local;
} }
set local(AssetEntity? assetEntity) => _local = assetEntity;
Id id = Isar.autoIncrement; Id id = Isar.autoIncrement;
/// stores the raw SHA1 bytes as a base64 String /// stores the raw SHA1 bytes as a base64 String
@ -210,6 +184,10 @@ class Asset {
@ignore @ignore
Duration get duration => Duration(seconds: durationInSeconds); Duration get duration => Duration(seconds: durationInSeconds);
// ignore: invalid_annotation_target
@ignore
set byteHash(List<int> hash) => checksum = base64.encode(hash);
@override @override
bool operator ==(other) { bool operator ==(other) {
if (other is! Asset) return false; if (other is! Asset) return false;

View File

@ -0,0 +1,16 @@
import 'package:immich_mobile/models/activities/activity.model.dart';
abstract interface class IActivityApiRepository {
Future<List<Activity>> getAll(
String albumId, {
String? assetId,
});
Future<Activity> create(
String albumId,
ActivityType type, {
String? assetId,
String? comment,
});
Future<void> delete(String id);
Future<ActivityStats> getStats(String albumId, {String? assetId});
}

View File

@ -0,0 +1,40 @@
import 'package:immich_mobile/entities/album.entity.dart';
abstract interface class IAlbumApiRepository {
Future<Album> get(String id);
Future<List<Album>> getAll({bool? shared});
Future<Album> create(
String name, {
required Iterable<String> assetIds,
Iterable<String> sharedUserIds = const [],
});
Future<Album> update(
String albumId, {
String? name,
String? thumbnailAssetId,
String? description,
bool? activityEnabled,
});
Future<void> delete(String albumId);
Future<({List<String> added, List<String> duplicates})> addAssets(
String albumId,
Iterable<String> assetIds,
);
Future<({List<String> removed, List<String> failed})> removeAssets(
String albumId,
Iterable<String> assetIds,
);
Future<Album> addUsers(
String albumId,
Iterable<String> userIds,
);
Future<void> removeUser(String albumId, {required String userId});
}

View File

@ -0,0 +1,21 @@
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
abstract interface class IAlbumMediaRepository {
Future<List<Album>> getAll();
Future<List<String>> getAssetIds(String albumId);
Future<int> getAssetCount(String albumId);
Future<List<Asset>> getAssets(
String albumId, {
int start = 0,
int end = 0x7fffffffffffffff,
DateTime? modifiedFrom,
DateTime? modifiedUntil,
bool orderByModificationDate = false,
});
Future<Album> get(String id);
}

View File

@ -3,6 +3,20 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart';
abstract interface class IAssetRepository { abstract interface class IAssetRepository {
Future<Asset?> getByRemoteId(String id);
Future<List<Asset>> getAllByRemoteId(Iterable<String> ids);
Future<List<Asset>> getByAlbum(Album album, {User? notOwnedBy}); Future<List<Asset>> getByAlbum(Album album, {User? notOwnedBy});
Future<void> deleteById(List<int> ids); Future<void> deleteById(List<int> ids);
Future<List<Asset>> getAll({
required int ownerId,
bool? remote,
int limit = 100,
});
Future<List<Asset>> getMatches({
required List<Asset> assets,
required int ownerId,
bool? remote,
int limit = 100,
});
} }

View File

@ -0,0 +1,16 @@
import 'package:immich_mobile/entities/asset.entity.dart';
abstract interface class IAssetApiRepository {
// Future<Asset> get(String id);
// Future<List<Asset>> getAll();
// Future<Asset> create(Asset asset);
Future<Asset> update(
String id, {
String? description,
});
// Future<void> delete(String id);
}

View File

@ -0,0 +1,7 @@
import 'package:immich_mobile/entities/asset.entity.dart';
abstract interface class IAssetMediaRepository {
Future<List<String>> deleteAll(List<String> ids);
Future<Asset?> get(String id);
}

View File

@ -0,0 +1,9 @@
import 'package:immich_mobile/entities/exif_info.entity.dart';
abstract interface class IExifInfoRepository {
Future<ExifInfo?> get(int id);
Future<ExifInfo> update(ExifInfo exifInfo);
Future<void> delete(int id);
}

View File

@ -0,0 +1,30 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:immich_mobile/entities/asset.entity.dart';
abstract interface class IFileMediaRepository {
Future<Asset?> saveImage(
Uint8List data, {
required String title,
String? relativePath,
});
Future<Asset?> saveVideo(
File file, {
required String title,
String? relativePath,
});
Future<Asset?> saveLivePhoto({
required File image,
required File video,
required String title,
});
Future<void> clearFileCache();
Future<void> enableBackgroundAccess();
Future<void> requestExtendedPermissions();
}

View File

@ -2,4 +2,5 @@ import 'package:immich_mobile/entities/user.entity.dart';
abstract interface class IUserRepository { abstract interface class IUserRepository {
Future<List<User>> getByIds(List<String> ids); Future<List<User>> getByIds(List<String> ids);
Future<User?> get(String id);
} }

View File

@ -1,5 +1,4 @@
import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart';
import 'package:openapi/api.dart';
enum ActivityType { comment, like } enum ActivityType { comment, like }
@ -38,16 +37,6 @@ class Activity {
); );
} }
Activity.fromDto(ActivityResponseDto dto)
: id = dto.id,
assetId = dto.assetId,
comment = dto.comment,
createdAt = dto.createdAt,
type = dto.type == ReactionType.comment
? ActivityType.comment
: ActivityType.like,
user = User.fromSimpleUserDto(dto.user);
@override @override
String toString() { String toString() {
return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)'; return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)';
@ -75,3 +64,9 @@ class Activity {
user.hashCode; user.hashCode;
} }
} }
class ActivityStats {
final int comments;
const ActivityStats({required this.comments});
}

View File

@ -1,45 +1,47 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:photo_manager/photo_manager.dart'; import 'package:immich_mobile/entities/album.entity.dart';
class AvailableAlbum { class AvailableAlbum {
final AssetPathEntity albumEntity; final Album album;
final int assetCount;
final DateTime? lastBackup; final DateTime? lastBackup;
AvailableAlbum({ AvailableAlbum({
required this.albumEntity, required this.album,
required this.assetCount,
this.lastBackup, this.lastBackup,
}); });
AvailableAlbum copyWith({ AvailableAlbum copyWith({
AssetPathEntity? albumEntity, Album? album,
int? assetCount,
DateTime? lastBackup, DateTime? lastBackup,
Uint8List? thumbnailData, Uint8List? thumbnailData,
}) { }) {
return AvailableAlbum( return AvailableAlbum(
albumEntity: albumEntity ?? this.albumEntity, album: album ?? this.album,
assetCount: assetCount ?? this.assetCount,
lastBackup: lastBackup ?? this.lastBackup, lastBackup: lastBackup ?? this.lastBackup,
); );
} }
String get name => albumEntity.name; String get name => album.name;
Future<int> get assetCount => albumEntity.assetCountAsync; String get id => album.localId!;
String get id => albumEntity.id; bool get isAll => album.isAll;
bool get isAll => albumEntity.isAll;
@override @override
String toString() => String toString() =>
'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup)'; 'AvailableAlbum(albumEntity: $album, lastBackup: $lastBackup)';
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
return other is AvailableAlbum && other.albumEntity == albumEntity; return other is AvailableAlbum && other.album == album;
} }
@override @override
int get hashCode => albumEntity.hashCode; int get hashCode => album.hashCode;
} }

View File

@ -1,9 +1,9 @@
import 'package:photo_manager/photo_manager.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
class BackupCandidate { class BackupCandidate {
BackupCandidate({required this.asset, required this.albumNames}); BackupCandidate({required this.asset, required this.albumNames});
AssetEntity asset; Asset asset;
List<String> albumNames; List<String> albumNames;
@override @override

View File

@ -1,11 +1,11 @@
import 'package:photo_manager/photo_manager.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
class ErrorUploadAsset { class ErrorUploadAsset {
final String id; final String id;
final DateTime fileCreatedAt; final DateTime fileCreatedAt;
final String fileName; final String fileName;
final String fileType; final String fileType;
final AssetEntity asset; final Asset asset;
final String errorMessage; final String errorMessage;
const ErrorUploadAsset({ const ErrorUploadAsset({
@ -22,7 +22,7 @@ class ErrorUploadAsset {
DateTime? fileCreatedAt, DateTime? fileCreatedAt,
String? fileName, String? fileName,
String? fileType, String? fileType,
AssetEntity? asset, Asset? asset,
String? errorMessage, String? errorMessage,
}) { }) {
return ErrorUploadAsset( return ErrorUploadAsset(

View File

@ -4,11 +4,15 @@ class ServerConfig {
final int trashDays; final int trashDays;
final String oauthButtonText; final String oauthButtonText;
final String externalDomain; final String externalDomain;
final String mapDarkStyleUrl;
final String mapLightStyleUrl;
const ServerConfig({ const ServerConfig({
required this.trashDays, required this.trashDays,
required this.oauthButtonText, required this.oauthButtonText,
required this.externalDomain, required this.externalDomain,
required this.mapDarkStyleUrl,
required this.mapLightStyleUrl,
}); });
ServerConfig copyWith({ ServerConfig copyWith({
@ -20,6 +24,8 @@ class ServerConfig {
trashDays: trashDays ?? this.trashDays, trashDays: trashDays ?? this.trashDays,
oauthButtonText: oauthButtonText ?? this.oauthButtonText, oauthButtonText: oauthButtonText ?? this.oauthButtonText,
externalDomain: externalDomain ?? this.externalDomain, externalDomain: externalDomain ?? this.externalDomain,
mapDarkStyleUrl: mapDarkStyleUrl,
mapLightStyleUrl: mapLightStyleUrl,
); );
} }
@ -30,7 +36,9 @@ class ServerConfig {
ServerConfig.fromDto(ServerConfigDto dto) ServerConfig.fromDto(ServerConfigDto dto)
: trashDays = dto.trashDays, : trashDays = dto.trashDays,
oauthButtonText = dto.oauthButtonText, oauthButtonText = dto.oauthButtonText,
externalDomain = dto.externalDomain; externalDomain = dto.externalDomain,
mapDarkStyleUrl = dto.mapDarkStyleUrl,
mapLightStyleUrl = dto.mapLightStyleUrl;
@override @override
bool operator ==(covariant ServerConfig other) { bool operator ==(covariant ServerConfig other) {

View File

@ -1,28 +1,27 @@
import 'dart:typed_data';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.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/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/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
@RoutePage() @RoutePage()
class AlbumPreviewPage extends HookConsumerWidget { class AlbumPreviewPage extends HookConsumerWidget {
final AssetPathEntity album; final Album album;
const AlbumPreviewPage({super.key, required this.album}); const AlbumPreviewPage({super.key, required this.album});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final assets = useState<List<AssetEntity>>([]); final assets = useState<List<Asset>>([]);
getAssetsInAlbum() async { getAssetsInAlbum() async {
assets.value = await album.getAssetListRange( assets.value = await ref
start: 0, .read(albumMediaRepositoryProvider)
end: await album.assetCountAsync, .getAssets(album.localId!);
);
} }
useEffect( useEffect(
@ -68,30 +67,10 @@ class AlbumPreviewPage extends HookConsumerWidget {
), ),
itemCount: assets.value.length, itemCount: assets.value.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
Future<Uint8List?> thumbData = return ImmichThumbnail(
assets.value[index].thumbnailDataWithSize( asset: assets.value[index],
const ThumbnailSize(200, 200),
quality: 50,
);
return FutureBuilder<Uint8List?>(
future: thumbData,
builder: ((context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return Image.memory(
snapshot.data!,
width: 100, width: 100,
height: 100, height: 100,
fit: BoxFit.cover,
);
}
return const SizedBox(
width: 100,
height: 100,
child: ImmichLoadingIndicator(),
);
}),
); );
}, },
), ),

View File

@ -3,9 +3,8 @@ import 'package:flutter/material.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/providers/backup/error_backup_list.provider.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager_image_provider/photo_manager_image_provider.dart';
@RoutePage() @RoutePage()
class FailedBackupStatusPage extends HookConsumerWidget { class FailedBackupStatusPage extends HookConsumerWidget {
@ -70,11 +69,10 @@ class FailedBackupStatusPage extends HookConsumerWidget {
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
child: Image( child: Image(
fit: BoxFit.cover, fit: BoxFit.cover,
image: AssetEntityImageProvider( image: ImmichLocalThumbnailProvider(
errorAsset.asset, asset: errorAsset.asset,
isOriginal: false, height: 512,
thumbnailSize: const ThumbnailSize.square(512), width: 512,
thumbnailFormat: ThumbnailFormat.jpeg,
), ),
), ),
), ),

View File

@ -8,11 +8,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.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/repositories/file_media.repository.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -67,7 +67,7 @@ class EditImagePage extends ConsumerWidget {
) async { ) async {
try { try {
final Uint8List imageData = await _imageToUint8List(image); final Uint8List imageData = await _imageToUint8List(image);
await PhotoManager.editor.saveImage( await ref.read(fileMediaRepositoryProvider).saveImage(
imageData, imageData,
title: "${p.withoutExtension(asset.fileName)}_edited.jpg", title: "${p.withoutExtension(asset.fileName)}_edited.jpg",
); );

View File

@ -2,7 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.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/providers/favorite_provider.dart'; import 'package:immich_mobile/providers/favorite.provider.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';

View File

@ -1,4 +1,5 @@
import 'dart:math'; import 'dart:math';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@ -7,27 +8,27 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.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/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/latlngbounds_extension.dart'; import 'package:immich_mobile/extensions/latlngbounds_extension.dart';
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
import 'package:immich_mobile/models/map/map_event.model.dart'; import 'package:immich_mobile/models/map/map_event.model.dart';
import 'package:immich_mobile/models/map/map_marker.model.dart'; import 'package:immich_mobile/models/map/map_marker.model.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/map/map_marker.provider.dart'; import 'package:immich_mobile/providers/map/map_marker.provider.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart'; import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/map_utils.dart'; import 'package:immich_mobile/utils/map_utils.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';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/map/map_app_bar.dart'; import 'package:immich_mobile/widgets/map/map_app_bar.dart';
import 'package:immich_mobile/widgets/map/map_asset_grid.dart'; import 'package:immich_mobile/widgets/map/map_asset_grid.dart';
import 'package:immich_mobile/widgets/map/map_bottom_sheet.dart'; import 'package:immich_mobile/widgets/map/map_bottom_sheet.dart';
import 'package:immich_mobile/widgets/map/map_theme_override.dart'; import 'package:immich_mobile/widgets/map/map_theme_override.dart';
import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart'; import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage() @RoutePage()
@ -304,7 +305,7 @@ class MapPage extends HookConsumerWidget {
), ),
Positioned( Positioned(
right: 0, right: 0,
bottom: MediaQuery.of(context).padding.bottom + 16, bottom: MediaQuery.paddingOf(context).bottom + 16,
child: ElevatedButton( child: ElevatedButton(
onPressed: onZoomToLocation, onPressed: onZoomToLocation,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(

View File

@ -1,9 +1,9 @@
import 'package:immich_mobile/repositories/activity_api.repository.dart';
import 'package:immich_mobile/services/activity.service.dart'; import 'package:immich_mobile/services/activity.service.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'activity_service.provider.g.dart'; part 'activity_service.provider.g.dart';
@riverpod @riverpod
ActivityService activityService(ActivityServiceRef ref) => ActivityService activityService(ActivityServiceRef ref) =>
ActivityService(ref.watch(apiServiceProvider)); ActivityService(ref.watch(activityApiRepositoryProvider));

View File

@ -11,7 +11,7 @@ class ActivityStatistics extends _$ActivityStatistics {
ref ref
.watch(activityServiceProvider) .watch(activityServiceProvider)
.getStatistics(albumId, assetId: assetId) .getStatistics(albumId, assetId: assetId)
.then((comments) => state = comments); .then((stats) => state = stats.comments);
return 0; return 0;
} }

View File

@ -1,6 +1,7 @@
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/providers/memory.provider.dart'; import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
@ -15,7 +16,6 @@ import 'package:immich_mobile/utils/db.dart';
import 'package:immich_mobile/utils/renderlist_generator.dart'; import 'package:immich_mobile/utils/renderlist_generator.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
class AssetNotifier extends StateNotifier<bool> { class AssetNotifier extends StateNotifier<bool> {
final AssetService _assetService; final AssetService _assetService;
@ -257,7 +257,7 @@ class AssetNotifier extends StateNotifier<bool> {
// Delete asset from device // Delete asset from device
if (local.isNotEmpty) { if (local.isNotEmpty) {
try { try {
return await PhotoManager.editor.deleteWithIds(local); return await _ref.read(assetMediaRepositoryProvider).deleteAll(local);
} catch (e, stack) { } catch (e, stack) {
log.severe("Failed to delete asset from device", e, stack); log.severe("Failed to delete asset from device", e, stack);
} }

View File

@ -5,6 +5,9 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.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/entities/album.entity.dart';
import 'package:immich_mobile/interfaces/album_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';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
@ -13,6 +16,8 @@ import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; 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/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';
import 'package:immich_mobile/models/authentication/authentication_state.model.dart'; import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
@ -28,7 +33,7 @@ import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.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'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
class BackupNotifier extends StateNotifier<BackUpState> { class BackupNotifier extends StateNotifier<BackUpState> {
BackupNotifier( BackupNotifier(
@ -38,6 +43,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
this._backgroundService, this._backgroundService,
this._galleryPermissionNotifier, this._galleryPermissionNotifier,
this._db, this._db,
this._albumMediaRepository,
this._fileMediaRepository,
this.ref, this.ref,
) : super( ) : super(
BackUpState( BackUpState(
@ -86,6 +93,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final BackgroundService _backgroundService; final BackgroundService _backgroundService;
final GalleryPermissionNotifier _galleryPermissionNotifier; final GalleryPermissionNotifier _galleryPermissionNotifier;
final Isar _db; final Isar _db;
final IAlbumMediaRepository _albumMediaRepository;
final IFileMediaRepository _fileMediaRepository;
final Ref ref; final Ref ref;
/// ///
@ -224,22 +233,24 @@ class BackupNotifier extends StateNotifier<BackUpState> {
Stopwatch stopwatch = Stopwatch()..start(); Stopwatch stopwatch = Stopwatch()..start();
// Get all albums on the device // Get all albums on the device
List<AvailableAlbum> availableAlbums = []; List<AvailableAlbum> availableAlbums = [];
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList( List<Album> albums = await _albumMediaRepository.getAll();
hasAll: true,
type: RequestType.common,
);
// Map of id -> album for quick album lookup later on. // Map of id -> album for quick album lookup later on.
Map<String, AssetPathEntity> albumMap = {}; Map<String, Album> albumMap = {};
log.info('Found ${albums.length} local albums'); log.info('Found ${albums.length} local albums');
for (AssetPathEntity album in albums) { for (Album album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album); AvailableAlbum availableAlbum = AvailableAlbum(
album: album,
assetCount: await ref
.read(albumMediaRepositoryProvider)
.getAssetCount(album.localId!),
);
availableAlbums.add(availableAlbum); availableAlbums.add(availableAlbum);
albumMap[album.id] = album; albumMap[album.localId!] = album;
} }
state = state.copyWith(availableAlbums: availableAlbums); state = state.copyWith(availableAlbums: availableAlbums);
@ -248,14 +259,18 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final List<BackupAlbum> selectedBackupAlbums = final List<BackupAlbum> selectedBackupAlbums =
await _backupService.selectedAlbumsQuery().findAll(); await _backupService.selectedAlbumsQuery().findAll();
// Generate AssetPathEntity from id to add to local state
final Set<AvailableAlbum> selectedAlbums = {}; final Set<AvailableAlbum> selectedAlbums = {};
for (final BackupAlbum ba in selectedBackupAlbums) { for (final BackupAlbum ba in selectedBackupAlbums) {
final albumAsset = albumMap[ba.id]; final albumAsset = albumMap[ba.id];
if (albumAsset != null) { if (albumAsset != null) {
selectedAlbums.add( selectedAlbums.add(
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup), AvailableAlbum(
album: albumAsset,
assetCount:
await _albumMediaRepository.getAssetCount(albumAsset.localId!),
lastBackup: ba.lastBackup,
),
); );
} else { } else {
log.severe('Selected album not found'); log.severe('Selected album not found');
@ -268,7 +283,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
if (albumAsset != null) { if (albumAsset != null) {
excludedAlbums.add( excludedAlbums.add(
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup), AvailableAlbum(
album: albumAsset,
assetCount: await ref
.read(albumMediaRepositoryProvider)
.getAssetCount(albumAsset.localId!),
lastBackup: ba.lastBackup,
),
); );
} else { } else {
log.severe('Excluded album not found'); log.severe('Excluded album not found');
@ -292,28 +313,32 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// Those assets are unique and are used as the total assets /// Those assets are unique and are used as the total assets
/// ///
Future<void> _updateBackupAssetCount() async { Future<void> _updateBackupAssetCount() async {
// Save to persistent storage
await _updatePersistentAlbumsSelection();
final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds(); final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds();
final Set<BackupCandidate> assetsFromSelectedAlbums = {}; final Set<BackupCandidate> assetsFromSelectedAlbums = {};
final Set<BackupCandidate> assetsFromExcludedAlbums = {}; final Set<BackupCandidate> assetsFromExcludedAlbums = {};
for (final album in state.selectedBackupAlbums) { for (final album in state.selectedBackupAlbums) {
final assetCount = await album.albumEntity.assetCountAsync; final assetCount = await ref
.read(albumMediaRepositoryProvider)
.getAssetCount(album.album.localId!);
if (assetCount == 0) { if (assetCount == 0) {
continue; continue;
} }
final assets = await album.albumEntity.getAssetListRange( final assets = await ref
start: 0, .read(albumMediaRepositoryProvider)
end: assetCount, .getAssets(album.album.localId!);
);
// Add album's name to the asset info // Add album's name to the asset info
for (final asset in assets) { for (final asset in assets) {
List<String> albumNames = [album.name]; List<String> albumNames = [album.name];
final existingAsset = assetsFromSelectedAlbums.firstWhereOrNull( final existingAsset = assetsFromSelectedAlbums.firstWhereOrNull(
(a) => a.asset.id == asset.id, (a) => a.asset.localId == asset.localId,
); );
if (existingAsset != null) { if (existingAsset != null) {
@ -331,16 +356,17 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} }
for (final album in state.excludedBackupAlbums) { for (final album in state.excludedBackupAlbums) {
final assetCount = await album.albumEntity.assetCountAsync; final assetCount = await ref
.read(albumMediaRepositoryProvider)
.getAssetCount(album.album.localId!);
if (assetCount == 0) { if (assetCount == 0) {
continue; continue;
} }
final assets = await album.albumEntity.getAssetListRange( final assets = await ref
start: 0, .read(albumMediaRepositoryProvider)
end: assetCount, .getAssets(album.album.localId!);
);
for (final asset in assets) { for (final asset in assets) {
assetsFromExcludedAlbums.add( assetsFromExcludedAlbums.add(
@ -360,14 +386,14 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Find asset that were backup from selected albums // Find asset that were backup from selected albums
final Set<String> selectedAlbumsBackupAssets = final Set<String> selectedAlbumsBackupAssets =
Set.from(allUniqueAssets.map((e) => e.asset.id)); Set.from(allUniqueAssets.map((e) => e.asset.localId));
selectedAlbumsBackupAssets selectedAlbumsBackupAssets
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId)); .removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
// Remove duplicated asset from all unique assets // Remove duplicated asset from all unique assets
allUniqueAssets.removeWhere( allUniqueAssets.removeWhere(
(candidate) => duplicatedAssetIds.contains(candidate.asset.id), (candidate) => duplicatedAssetIds.contains(candidate.asset.localId),
); );
if (allUniqueAssets.isEmpty) { if (allUniqueAssets.isEmpty) {
@ -385,9 +411,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets, selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
); );
} }
// Save to persistent storage
await _updatePersistentAlbumsSelection();
} }
/// Get all necessary information for calculating the available albums, /// Get all necessary information for calculating the available albums,
@ -454,7 +477,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final hasPermission = _galleryPermissionNotifier.hasPermission; final hasPermission = _galleryPermissionNotifier.hasPermission;
if (hasPermission) { if (hasPermission) {
await PhotoManager.clearFileCache(); await _fileMediaRepository.clearFileCache();
if (state.allUniqueAssets.isEmpty) { if (state.allUniqueAssets.isEmpty) {
log.info("No Asset On Device - Abort Backup Process"); log.info("No Asset On Device - Abort Backup Process");
@ -465,7 +488,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
Set<BackupCandidate> assetsWillBeBackup = Set.from(state.allUniqueAssets); Set<BackupCandidate> assetsWillBeBackup = Set.from(state.allUniqueAssets);
// Remove item that has already been backed up // Remove item that has already been backed up
for (final assetId in state.allAssetsInDatabase) { for (final assetId in state.allAssetsInDatabase) {
assetsWillBeBackup.removeWhere((e) => e.asset.id == assetId); assetsWillBeBackup.removeWhere((e) => e.asset.localId == assetId);
} }
if (assetsWillBeBackup.isEmpty) { if (assetsWillBeBackup.isEmpty) {
@ -531,7 +554,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state = state.copyWith( state = state.copyWith(
allUniqueAssets: state.allUniqueAssets allUniqueAssets: state.allUniqueAssets
.where( .where(
(candidate) => candidate.asset.id != result.candidate.asset.id, (candidate) =>
candidate.asset.localId != result.candidate.asset.localId,
) )
.toSet(), .toSet(),
); );
@ -539,11 +563,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state = state.copyWith( state = state.copyWith(
selectedAlbumsBackupAssetsIds: { selectedAlbumsBackupAssetsIds: {
...state.selectedAlbumsBackupAssetsIds, ...state.selectedAlbumsBackupAssetsIds,
result.candidate.asset.id, result.candidate.asset.localId!,
}, },
allAssetsInDatabase: [ allAssetsInDatabase: [
...state.allAssetsInDatabase, ...state.allAssetsInDatabase,
result.candidate.asset.id, result.candidate.asset.localId!,
], ],
); );
} }
@ -552,7 +576,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state.selectedAlbumsBackupAssetsIds.length == state.selectedAlbumsBackupAssetsIds.length ==
0) { 0) {
final latestAssetBackup = state.allUniqueAssets final latestAssetBackup = state.allUniqueAssets
.map((candidate) => candidate.asset.modifiedDateTime) .map((candidate) => candidate.asset.fileModifiedAt)
.reduce( .reduce(
(v, e) => e.isAfter(v) ? e : v, (v, e) => e.isAfter(v) ? e : v,
); );
@ -741,6 +765,8 @@ final backupProvider =
ref.watch(backgroundServiceProvider), ref.watch(backgroundServiceProvider),
ref.watch(galleryPermissionNotifier.notifier), ref.watch(galleryPermissionNotifier.notifier),
ref.watch(dbProvider), ref.watch(dbProvider),
ref.watch(albumMediaRepositoryProvider),
ref.watch(fileMediaRepositoryProvider),
ref, ref,
); );
}); });

View File

@ -8,6 +8,7 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/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';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
@ -27,7 +28,7 @@ import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:isar/isar.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'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
final manualUploadProvider = final manualUploadProvider =
StateNotifierProvider<ManualUploadNotifier, ManualUploadState>((ref) { StateNotifierProvider<ManualUploadNotifier, ManualUploadState>((ref) {
@ -193,17 +194,10 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
_backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress); _backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress);
if (ref.read(galleryPermissionNotifier.notifier).hasPermission) { if (ref.read(galleryPermissionNotifier.notifier).hasPermission) {
await PhotoManager.clearFileCache(); await ref.read(fileMediaRepositoryProvider).clearFileCache();
// We do not have 1:1 mapping of all AssetEntity fields to Asset. This results in cases final allAssetsFromDevice =
// where platform specific fields such as `subtype` used to detect platform specific assets such as allManualUploads.where((e) => e.isLocal && !e.isRemote).toList();
// LivePhoto in iOS is lost when we directly fetch the local asset from Asset using Asset.local
List<AssetEntity?> allAssetsFromDevice = await Future.wait(
allManualUploads
// Filter local only assets
.where((e) => e.isLocal && !e.isRemote)
.map((e) => e.local!.obtainForNewProperties()),
);
if (allAssetsFromDevice.length != allManualUploads.length) { if (allAssetsFromDevice.length != allManualUploads.length) {
_log.warning( _log.warning(
@ -221,11 +215,17 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
await _backupService.buildUploadCandidates( await _backupService.buildUploadCandidates(
selectedBackupAlbums, selectedBackupAlbums,
excludedBackupAlbums, excludedBackupAlbums,
useTimeFilter: false,
); );
// Extrack candidate from allAssetsFromDevice.nonNulls // Extrack candidate from allAssetsFromDevice
final uploadAssets = candidates final uploadAssets = candidates.where(
.where((e) => allAssetsFromDevice.nonNulls.contains(e.asset)); (candidate) =>
allAssetsFromDevice.firstWhereOrNull(
(asset) => asset.localId == candidate.asset.localId,
) !=
null,
);
if (uploadAssets.isEmpty) { if (uploadAssets.isEmpty) {
debugPrint("[_startUpload] No Assets to upload - Abort Process"); debugPrint("[_startUpload] No Assets to upload - Abort Process");

View File

@ -9,7 +9,7 @@ import 'package:flutter/painting.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
/// The local image provider for an asset /// The local image provider for an asset
class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> { class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {

View File

@ -6,7 +6,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
/// The local image provider for an asset /// The local image provider for an asset
/// Only viable /// Only viable

View File

@ -1,28 +1,23 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/models/map/map_state.model.dart'; import 'package:immich_mobile/models/map/map_state.model.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'map_state.provider.g.dart'; part 'map_state.provider.g.dart';
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
class MapStateNotifier extends _$MapStateNotifier { class MapStateNotifier extends _$MapStateNotifier {
final _log = Logger("MapStateNotifier");
@override @override
MapState build() { MapState build() {
final appSettingsProvider = ref.read(appSettingsServiceProvider); final appSettingsProvider = ref.read(appSettingsServiceProvider);
// Fetch and save the Style JSONs final lightStyleUrl =
loadStyles(); ref.read(serverInfoProvider).serverConfig.mapLightStyleUrl;
final darkStyleUrl =
ref.read(serverInfoProvider).serverConfig.mapDarkStyleUrl;
return MapState( return MapState(
themeMode: ThemeMode.values[ themeMode: ThemeMode.values[
appSettingsProvider.getSetting<int>(AppSettingsEnum.mapThemeMode)], appSettingsProvider.getSetting<int>(AppSettingsEnum.mapThemeMode)],
@ -34,65 +29,11 @@ class MapStateNotifier extends _$MapStateNotifier {
appSettingsProvider.getSetting<bool>(AppSettingsEnum.mapwithPartners), appSettingsProvider.getSetting<bool>(AppSettingsEnum.mapwithPartners),
relativeTime: relativeTime:
appSettingsProvider.getSetting<int>(AppSettingsEnum.mapRelativeDate), appSettingsProvider.getSetting<int>(AppSettingsEnum.mapRelativeDate),
lightStyleFetched: AsyncData(lightStyleUrl),
darkStyleFetched: AsyncData(darkStyleUrl),
); );
} }
void loadStyles() async {
final documents = (await getApplicationDocumentsDirectory()).path;
// Set to loading
state = state.copyWith(lightStyleFetched: const AsyncLoading());
// Fetch and save light theme
final lightResponse = await ref
.read(apiServiceProvider)
.mapApi
.getMapStyleWithHttpInfo(MapTheme.light);
if (lightResponse.statusCode >= HttpStatus.badRequest) {
state = state.copyWith(
lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current),
);
_log.severe(
"Cannot fetch map light style",
lightResponse.toLoggerString(),
);
return;
}
final lightJSON = lightResponse.body;
final lightFile = await File("$documents/map-style-light.json")
.writeAsString(lightJSON, flush: true);
// Update state with path
state =
state.copyWith(lightStyleFetched: AsyncData(lightFile.absolute.path));
// Set to loading
state = state.copyWith(darkStyleFetched: const AsyncLoading());
// Fetch and save dark theme
final darkResponse = await ref
.read(apiServiceProvider)
.mapApi
.getMapStyleWithHttpInfo(MapTheme.dark);
if (darkResponse.statusCode >= HttpStatus.badRequest) {
state = state.copyWith(
darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current),
);
_log.severe("Cannot fetch map dark style", darkResponse.toLoggerString());
return;
}
final darkJSON = darkResponse.body;
final darkFile = await File("$documents/map-style-dark.json")
.writeAsString(darkJSON, flush: true);
// Update state with path
state = state.copyWith(darkStyleFetched: AsyncData(darkFile.absolute.path));
}
void switchTheme(ThemeMode mode) { void switchTheme(ThemeMode mode) {
ref.read(appSettingsServiceProvider).setSetting( ref.read(appSettingsServiceProvider).setSetting(
AppSettingsEnum.mapThemeMode, AppSettingsEnum.mapThemeMode,

View File

@ -34,6 +34,9 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
trashDays: 30, trashDays: 30,
oauthButtonText: '', oauthButtonText: '',
externalDomain: '', externalDomain: '',
mapLightStyleUrl:
'https://tiles.immich.cloud/v1/style/light.json',
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
), ),
serverDiskInfo: const ServerDiskInfo( serverDiskInfo: const ServerDiskInfo(
diskAvailable: "0", diskAvailable: "0",

View File

@ -0,0 +1,67 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/interfaces/activity_api.interface.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/base_api.repository.dart';
import 'package:openapi/api.dart';
final activityApiRepositoryProvider = Provider(
(ref) => ActivityApiRepository(ref.watch(apiServiceProvider).activitiesApi),
);
class ActivityApiRepository extends BaseApiRepository
implements IActivityApiRepository {
final ActivitiesApi _api;
ActivityApiRepository(this._api);
@override
Future<List<Activity>> getAll(String albumId, {String? assetId}) async {
final response =
await checkNull(_api.getActivities(albumId, assetId: assetId));
return response.map(_toActivity).toList();
}
@override
Future<Activity> create(
String albumId,
ActivityType type, {
String? assetId,
String? comment,
}) async {
final dto = ActivityCreateDto(
albumId: albumId,
type: type == ActivityType.comment
? ReactionType.comment
: ReactionType.like,
assetId: assetId,
comment: comment,
);
final response = await checkNull(_api.createActivity(dto));
return _toActivity(response);
}
@override
Future<void> delete(String id) {
return checkNull(_api.deleteActivity(id));
}
@override
Future<ActivityStats> getStats(String albumId, {String? assetId}) async {
final response =
await checkNull(_api.getActivityStatistics(albumId, assetId: assetId));
return ActivityStats(comments: response.comments);
}
static Activity _toActivity(ActivityResponseDto dto) => Activity(
id: dto.id,
createdAt: dto.createdAt,
type: dto.type == ReactionType.comment
? ActivityType.comment
: ActivityType.like,
user: User.fromSimpleUserDto(dto.user),
assetId: dto.assetId,
comment: dto.comment,
);
}

View File

@ -0,0 +1,167 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/interfaces/album_api.interface.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/base_api.repository.dart';
import 'package:openapi/api.dart';
final albumApiRepositoryProvider = Provider(
(ref) => AlbumApiRepository(ref.watch(apiServiceProvider).albumsApi),
);
class AlbumApiRepository extends BaseApiRepository
implements IAlbumApiRepository {
final AlbumsApi _api;
AlbumApiRepository(this._api);
@override
Future<Album> get(String id) async {
final dto = await checkNull(_api.getAlbumInfo(id));
return _toAlbum(dto);
}
@override
Future<List<Album>> getAll({bool? shared}) async {
final dtos = await checkNull(_api.getAllAlbums(shared: shared));
return dtos.map(_toAlbum).toList().cast();
}
@override
Future<Album> create(
String name, {
required Iterable<String> assetIds,
Iterable<String> sharedUserIds = const [],
}) async {
final users = sharedUserIds.map(
(id) => AlbumUserCreateDto(userId: id, role: AlbumUserRole.editor),
);
final responseDto = await checkNull(
_api.createAlbum(
CreateAlbumDto(
albumName: name,
assetIds: assetIds.toList(),
albumUsers: users.toList(),
),
),
);
return _toAlbum(responseDto);
}
@override
Future<Album> update(
String albumId, {
String? name,
String? thumbnailAssetId,
String? description,
bool? activityEnabled,
}) async {
final response = await checkNull(
_api.updateAlbumInfo(
albumId,
UpdateAlbumDto(
albumName: name,
albumThumbnailAssetId: thumbnailAssetId,
description: description,
isActivityEnabled: activityEnabled,
),
),
);
return _toAlbum(response);
}
@override
Future<void> delete(String albumId) {
return _api.deleteAlbum(albumId);
}
@override
Future<({List<String> added, List<String> duplicates})> addAssets(
String albumId,
Iterable<String> assetIds,
) async {
final response = await checkNull(
_api.addAssetsToAlbum(
albumId,
BulkIdsDto(ids: assetIds.toList()),
),
);
final List<String> added = [];
final List<String> duplicates = [];
for (final result in response) {
if (result.success) {
added.add(result.id);
} else if (result.error == BulkIdResponseDtoErrorEnum.duplicate) {
duplicates.add(result.id);
}
}
return (added: added, duplicates: duplicates);
}
@override
Future<({List<String> removed, List<String> failed})> removeAssets(
String albumId,
Iterable<String> assetIds,
) async {
final response = await checkNull(
_api.removeAssetFromAlbum(
albumId,
BulkIdsDto(ids: assetIds.toList()),
),
);
final List<String> removed = [], failed = [];
for (final dto in response) {
if (dto.success) {
removed.add(dto.id);
} else {
failed.add(dto.id);
}
}
return (removed: removed, failed: failed);
}
@override
Future<Album> addUsers(String albumId, Iterable<String> userIds) async {
final albumUsers =
userIds.map((userId) => AlbumUserAddDto(userId: userId)).toList();
final response = await checkNull(
_api.addUsersToAlbum(
albumId,
AddUsersDto(albumUsers: albumUsers),
),
);
return _toAlbum(response);
}
@override
Future<void> removeUser(String albumId, {required String userId}) {
return _api.removeUserFromAlbum(albumId, userId);
}
static Album _toAlbum(AlbumResponseDto dto) {
final Album album = Album(
remoteId: dto.id,
name: dto.albumName,
createdAt: dto.createdAt,
modifiedAt: dto.updatedAt,
lastModifiedAssetTimestamp: dto.lastModifiedAssetTimestamp,
shared: dto.shared,
startDate: dto.startDate,
endDate: dto.endDate,
activityEnabled: dto.isActivityEnabled,
);
album.remoteAssetCount = dto.assetCount;
album.owner.value = User.fromSimpleUserDto(dto.owner);
album.remoteThumbnailAssetId = dto.albumThumbnailAssetId;
final users = dto.albumUsers
.map((albumUser) => User.fromSimpleUserDto(albumUser.user));
album.sharedUsers.addAll(users);
final assets = dto.assets.map(Asset.remote).toList();
album.assets.addAll(assets);
return album;
}
}

View File

@ -0,0 +1,93 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:photo_manager/photo_manager.dart' hide AssetType;
final albumMediaRepositoryProvider = Provider((ref) => AlbumMediaRepository());
class AlbumMediaRepository implements IAlbumMediaRepository {
@override
Future<List<Album>> getAll() async {
final List<AssetPathEntity> assetPathEntities =
await PhotoManager.getAssetPathList(
hasAll: true,
filterOption: FilterOptionGroup(containsPathModified: true),
);
return assetPathEntities.map(_toAlbum).toList();
}
@override
Future<List<String>> getAssetIds(String albumId) async {
final album = await AssetPathEntity.fromId(albumId);
final List<AssetEntity> assets =
await album.getAssetListRange(start: 0, end: 0x7fffffffffffffff);
return assets.map((e) => e.id).toList();
}
@override
Future<int> getAssetCount(String albumId) async {
final album = await AssetPathEntity.fromId(albumId);
return album.assetCountAsync;
}
@override
Future<List<Asset>> getAssets(
String albumId, {
int start = 0,
int end = 0x7fffffffffffffff,
DateTime? modifiedFrom,
DateTime? modifiedUntil,
bool orderByModificationDate = false,
}) async {
final onDevice = await AssetPathEntity.fromId(
albumId,
filterOption: FilterOptionGroup(
containsPathModified: true,
orders: orderByModificationDate
? [const OrderOption(type: OrderOptionType.updateDate)]
: [],
imageOption: const FilterOption(needTitle: true),
videoOption: const FilterOption(needTitle: true),
updateTimeCond: modifiedFrom == null && modifiedUntil == null
? null
: DateTimeCond(
min: modifiedFrom ?? DateTime.utc(-271820),
max: modifiedUntil ?? DateTime.utc(275760),
),
),
);
final List<AssetEntity> assets =
await onDevice.getAssetListRange(start: start, end: end);
return assets.map(AssetMediaRepository.toAsset).toList().cast();
}
@override
Future<Album> get(
String id, {
DateTime? modifiedFrom,
DateTime? modifiedUntil,
}) async {
final assetPathEntity = await AssetPathEntity.fromId(id);
return _toAlbum(assetPathEntity);
}
static Album _toAlbum(AssetPathEntity assetPathEntity) {
final Album album = Album(
name: assetPathEntity.name,
createdAt:
assetPathEntity.lastModified?.toUtc() ?? DateTime.now().toUtc(),
modifiedAt:
assetPathEntity.lastModified?.toUtc() ?? DateTime.now().toUtc(),
shared: false,
activityEnabled: false,
);
album.owner.value = Store.get(StoreKey.currentUser);
album.localId = assetPathEntity.id;
album.isAll = assetPathEntity.isAll;
return album;
}
}

View File

@ -28,4 +28,91 @@ class AssetRepository implements IAssetRepository {
@override @override
Future<void> deleteById(List<int> ids) => Future<void> deleteById(List<int> ids) =>
_db.writeTxn(() => _db.assets.deleteAll(ids)); _db.writeTxn(() => _db.assets.deleteAll(ids));
@override
Future<Asset?> getByRemoteId(String id) => _db.assets.getByRemoteId(id);
@override
Future<List<Asset>> getAllByRemoteId(Iterable<String> ids) =>
_db.assets.getAllByRemoteId(ids);
@override
Future<List<Asset>> getAll({
required int ownerId,
bool? remote,
int limit = 100,
}) {
if (remote == null) {
return _db.assets
.where()
.ownerIdEqualToAnyChecksum(ownerId)
.limit(limit)
.findAll();
} }
final QueryBuilder<Asset, Asset, QAfterFilterCondition> query;
if (remote) {
query = _db.assets
.where()
.localIdIsNull()
.filter()
.remoteIdIsNotNull()
.ownerIdEqualTo(ownerId);
} else {
query = _db.assets
.where()
.remoteIdIsNull()
.filter()
.localIdIsNotNull()
.ownerIdEqualTo(ownerId);
}
return query.limit(limit).findAll();
}
@override
Future<List<Asset>> getMatches({
required List<Asset> assets,
required int ownerId,
bool? remote,
int limit = 100,
}) {
final QueryBuilder<Asset, Asset, QAfterFilterCondition> query;
if (remote == null) {
query = _db.assets.filter().remoteIdIsNotNull().or().localIdIsNotNull();
} else if (remote) {
query = _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull();
} else {
query = _db.assets.where().remoteIdIsNull().filter().localIdIsNotNull();
}
return _getMatchesImpl(query, ownerId, assets, limit);
}
}
Future<List<Asset>> _getMatchesImpl(
QueryBuilder<Asset, Asset, QAfterFilterCondition> query,
int ownerId,
List<Asset> assets,
int limit,
) =>
query
.ownerIdEqualTo(ownerId)
.anyOf(
assets,
(q, Asset a) => q
.fileNameEqualTo(a.fileName)
.and()
.durationInSecondsEqualTo(a.durationInSeconds)
.and()
.fileCreatedAtBetween(
a.fileCreatedAt.subtract(const Duration(hours: 12)),
a.fileCreatedAt.add(const Duration(hours: 12)),
)
.and()
.not()
.checksumEqualTo(a.checksum),
)
.sortByFileName()
.thenByFileCreatedAt()
.thenByFileModifiedAt()
.limit(limit)
.findAll();

View File

@ -0,0 +1,25 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/interfaces/asset_api.interface.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/base_api.repository.dart';
import 'package:openapi/api.dart';
final assetApiRepositoryProvider = Provider(
(ref) => AssetApiRepository(ref.watch(apiServiceProvider).assetsApi),
);
class AssetApiRepository extends BaseApiRepository
implements IAssetApiRepository {
final AssetsApi _api;
AssetApiRepository(this._api);
@override
Future<Asset> update(String id, {String? description}) async {
final response = await checkNull(
_api.updateAsset(id, UpdateAssetDto(description: description)),
);
return Asset.remote(response);
}
}

View File

@ -0,0 +1,46 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/asset_media.interface.dart';
import 'package:photo_manager/photo_manager.dart' hide AssetType;
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository());
class AssetMediaRepository implements IAssetMediaRepository {
@override
Future<List<String>> deleteAll(List<String> ids) =>
PhotoManager.editor.deleteWithIds(ids);
@override
Future<Asset?> get(String id) async {
final entity = await AssetEntity.fromId(id);
return toAsset(entity);
}
static Asset? toAsset(AssetEntity? local) {
if (local == null) return null;
final Asset asset = Asset(
checksum: "",
localId: local.id,
ownerId: Store.get(StoreKey.currentUser).isarId,
fileCreatedAt: local.createDateTime,
fileModifiedAt: local.modifiedDateTime,
updatedAt: local.modifiedDateTime,
durationInSeconds: local.duration,
type: AssetType.values[local.typeInt],
fileName: local.title!,
width: local.width,
height: local.height,
isFavorite: local.isFavorite,
);
if (asset.fileCreatedAt.year == 1970) {
asset.fileCreatedAt = asset.fileModifiedAt;
}
if (local.latitude != null) {
asset.exifInfo = ExifInfo(lat: local.latitude, long: local.longitude);
}
asset.local = local;
return asset;
}
}

View File

@ -0,0 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/constants/errors.dart';
abstract class BaseApiRepository {
@protected
Future<T> checkNull<T>(Future<T?> future) async {
final response = await future;
if (response == null) throw NoResponseDtoError();
return response;
}
}

View File

@ -0,0 +1,28 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/interfaces/exif_info.interface.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:isar/isar.dart';
final exifInfoRepositoryProvider =
Provider((ref) => ExifInfoRepository(ref.watch(dbProvider)));
class ExifInfoRepository implements IExifInfoRepository {
final Isar _db;
ExifInfoRepository(
this._db,
);
@override
Future<void> delete(int id) => _db.exifInfos.delete(id);
@override
Future<ExifInfo?> get(int id) => _db.exifInfos.get(id);
@override
Future<ExifInfo> update(ExifInfo exifInfo) async {
await _db.writeTxn(() => _db.exifInfos.put(exifInfo));
return exifInfo;
}
}

View File

@ -0,0 +1,62 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:photo_manager/photo_manager.dart' hide AssetType;
final fileMediaRepositoryProvider = Provider((ref) => FileMediaRepository());
class FileMediaRepository implements IFileMediaRepository {
@override
Future<Asset?> saveImage(
Uint8List data, {
required String title,
String? relativePath,
}) async {
final entity = await PhotoManager.editor
.saveImage(data, title: title, relativePath: relativePath);
return AssetMediaRepository.toAsset(entity);
}
@override
Future<Asset?> saveLivePhoto({
required File image,
required File video,
required String title,
}) async {
final entity = await PhotoManager.editor.darwin.saveLivePhoto(
imageFile: image,
videoFile: video,
title: title,
);
return AssetMediaRepository.toAsset(entity);
}
@override
Future<Asset?> saveVideo(
File file, {
required String title,
String? relativePath,
}) async {
final entity = await PhotoManager.editor.saveVideo(
file,
title: title,
relativePath: relativePath,
);
return AssetMediaRepository.toAsset(entity);
}
@override
Future<void> clearFileCache() => PhotoManager.clearFileCache();
@override
Future<void> enableBackgroundAccess() =>
PhotoManager.setIgnorePermissionCheck(true);
@override
Future<void> requestExtendedPermissions() =>
PhotoManager.requestPermissionExtend();
}

View File

@ -17,4 +17,7 @@ class UserRepository implements IUserRepository {
@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)).cast();
@override
Future<User?> get(String id) => _db.users.getById(id);
} }

View File

@ -63,7 +63,6 @@ import 'package:immich_mobile/services/api.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';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:photo_manager/photo_manager.dart' hide LatLng;
part 'router.gr.dart'; part 'router.gr.dart';

View File

@ -185,7 +185,7 @@ class AlbumOptionsRouteArgs {
class AlbumPreviewRoute extends PageRouteInfo<AlbumPreviewRouteArgs> { class AlbumPreviewRoute extends PageRouteInfo<AlbumPreviewRouteArgs> {
AlbumPreviewRoute({ AlbumPreviewRoute({
Key? key, Key? key,
required AssetPathEntity album, required Album album,
List<PageRouteInfo>? children, List<PageRouteInfo>? children,
}) : super( }) : super(
AlbumPreviewRoute.name, AlbumPreviewRoute.name,
@ -218,7 +218,7 @@ class AlbumPreviewRouteArgs {
final Key? key; final Key? key;
final AssetPathEntity album; final Album album;
@override @override
String toString() { String toString() {

View File

@ -1,41 +1,31 @@
import 'package:immich_mobile/constants/errors.dart'; import 'package:immich_mobile/interfaces/activity_api.interface.dart';
import 'package:immich_mobile/mixins/error_logger.mixin.dart'; import 'package:immich_mobile/mixins/error_logger.mixin.dart';
import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
class ActivityService with ErrorLoggerMixin { class ActivityService with ErrorLoggerMixin {
final ApiService _apiService; final IActivityApiRepository _activityApiRepository;
@override @override
final Logger logger = Logger("ActivityService"); final Logger logger = Logger("ActivityService");
ActivityService(this._apiService); ActivityService(this._activityApiRepository);
Future<List<Activity>> getAllActivities( Future<List<Activity>> getAllActivities(
String albumId, { String albumId, {
String? assetId, String? assetId,
}) async { }) async {
return logError( return logError(
() async { () => _activityApiRepository.getAll(albumId, assetId: assetId),
final list = await _apiService.activitiesApi
.getActivities(albumId, assetId: assetId);
return list != null ? list.map(Activity.fromDto).toList() : [];
},
defaultValue: [], defaultValue: [],
errorMessage: "Failed to get all activities for album $albumId", errorMessage: "Failed to get all activities for album $albumId",
); );
} }
Future<int> getStatistics(String albumId, {String? assetId}) async { Future<ActivityStats> getStatistics(String albumId, {String? assetId}) async {
return logError( return logError(
() async { () => _activityApiRepository.getStats(albumId, assetId: assetId),
final dto = await _apiService.activitiesApi defaultValue: const ActivityStats(comments: 0),
.getActivityStatistics(albumId, assetId: assetId);
return dto?.comments ?? 0;
},
defaultValue: 0,
errorMessage: "Failed to statistics for album $albumId", errorMessage: "Failed to statistics for album $albumId",
); );
} }
@ -43,7 +33,7 @@ class ActivityService with ErrorLoggerMixin {
Future<bool> removeActivity(String id) async { Future<bool> removeActivity(String id) async {
return logError( return logError(
() async { () async {
await _apiService.activitiesApi.deleteActivity(id); await _activityApiRepository.delete(id);
return true; return true;
}, },
defaultValue: false, defaultValue: false,
@ -58,22 +48,12 @@ class ActivityService with ErrorLoggerMixin {
String? comment, String? comment,
}) async { }) async {
return guardError( return guardError(
() async { () => _activityApiRepository.create(
final dto = await _apiService.activitiesApi.createActivity( albumId,
ActivityCreateDto( type,
albumId: albumId,
type: type == ActivityType.comment
? ReactionType.comment
: ReactionType.like,
assetId: assetId, assetId: assetId,
comment: comment, comment: comment,
), ),
);
if (dto != null) {
return Activity.fromDto(dto);
}
throw NoResponseDtoError();
},
errorMessage: "Failed to create $type for album $albumId", errorMessage: "Failed to create $type for album $albumId",
); );
} }

View File

@ -6,59 +6,61 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/interfaces/album.interface.dart';
import 'package:immich_mobile/interfaces/album_api.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart';
import 'package:immich_mobile/interfaces/user.interface.dart';
import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart'; import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.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/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/providers/api.provider.dart';
import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album.repository.dart';
import 'package:immich_mobile/repositories/album_api.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/entity.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:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
final albumServiceProvider = Provider( final albumServiceProvider = Provider(
(ref) => AlbumService( (ref) => AlbumService(
ref.watch(apiServiceProvider),
ref.watch(userServiceProvider), ref.watch(userServiceProvider),
ref.watch(syncServiceProvider), ref.watch(syncServiceProvider),
ref.watch(entityServiceProvider),
ref.watch(albumRepositoryProvider), ref.watch(albumRepositoryProvider),
ref.watch(assetRepositoryProvider), ref.watch(assetRepositoryProvider),
ref.watch(userRepositoryProvider),
ref.watch(backupRepositoryProvider), ref.watch(backupRepositoryProvider),
ref.watch(albumMediaRepositoryProvider),
ref.watch(albumApiRepositoryProvider),
), ),
); );
class AlbumService { class AlbumService {
final ApiService _apiService;
final UserService _userService; final UserService _userService;
final SyncService _syncService; final SyncService _syncService;
final EntityService _entityService;
final IAlbumRepository _albumRepository; final IAlbumRepository _albumRepository;
final IAssetRepository _assetRepository; final IAssetRepository _assetRepository;
final IUserRepository _userRepository;
final IBackupRepository _backupAlbumRepository; final IBackupRepository _backupAlbumRepository;
final IAlbumMediaRepository _albumMediaRepository;
final IAlbumApiRepository _albumApiRepository;
final Logger _log = Logger('AlbumService'); final Logger _log = Logger('AlbumService');
Completer<bool> _localCompleter = Completer()..complete(false); Completer<bool> _localCompleter = Completer()..complete(false);
Completer<bool> _remoteCompleter = Completer()..complete(false); Completer<bool> _remoteCompleter = Completer()..complete(false);
AlbumService( AlbumService(
this._apiService,
this._userService, this._userService,
this._syncService, this._syncService,
this._entityService,
this._albumRepository, this._albumRepository,
this._assetRepository, this._assetRepository,
this._userRepository,
this._backupAlbumRepository, this._backupAlbumRepository,
this._albumMediaRepository,
this._albumApiRepository,
); );
/// Checks all selected device albums for changes of albums and their assets /// Checks all selected device albums for changes of albums and their assets
@ -84,11 +86,7 @@ class AlbumService {
} }
return false; return false;
} }
final List<AssetPathEntity> onDevice = final List<Album> onDevice = await _albumMediaRepository.getAll();
await PhotoManager.getAssetPathList(
hasAll: true,
filterOption: FilterOptionGroup(containsPathModified: true),
);
_log.info("Found ${onDevice.length} device albums"); _log.info("Found ${onDevice.length} device albums");
Set<String>? excludedAssets; Set<String>? excludedAssets;
if (excludedIds.isNotEmpty) { if (excludedIds.isNotEmpty) {
@ -104,13 +102,15 @@ class AlbumService {
_log.info("Found ${excludedAssets.length} assets to exclude"); _log.info("Found ${excludedAssets.length} assets to exclude");
} }
// remove all excluded albums // remove all excluded albums
onDevice.removeWhere((e) => excludedIds.contains(e.id)); onDevice.removeWhere((e) => excludedIds.contains(e.localId));
_log.info( _log.info(
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums", "Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
); );
} }
final hasAll = selectedIds final hasAll = selectedIds
.map((id) => onDevice.firstWhereOrNull((a) => a.id == id)) .map(
(id) => onDevice.firstWhereOrNull((album) => album.localId == id),
)
.whereNotNull() .whereNotNull()
.any((a) => a.isAll); .any((a) => a.isAll);
if (hasAll) { if (hasAll) {
@ -122,7 +122,7 @@ class AlbumService {
} }
} else { } else {
// keep only the explicitly selected albums // keep only the explicitly selected albums
onDevice.removeWhere((e) => !selectedIds.contains(e.id)); onDevice.removeWhere((e) => !selectedIds.contains(e.localId));
_log.info("'Recents' is not selected, keeping only selected albums"); _log.info("'Recents' is not selected, keeping only selected albums");
} }
changes = changes =
@ -136,15 +136,15 @@ class AlbumService {
} }
Future<Set<String>> _loadExcludedAssetIds( Future<Set<String>> _loadExcludedAssetIds(
List<AssetPathEntity> albums, List<Album> albums,
List<String> excludedAlbumIds, List<String> excludedAlbumIds,
) async { ) async {
final Set<String> result = HashSet<String>(); final Set<String> result = HashSet<String>();
for (AssetPathEntity a in albums) { for (Album album in albums) {
if (excludedAlbumIds.contains(a.id)) { if (excludedAlbumIds.contains(album.localId)) {
final List<AssetEntity> assets = final assetIds =
await a.getAssetListRange(start: 0, end: 0x7fffffffffffffff); await _albumMediaRepository.getAssetIds(album.localId!);
result.addAll(assets.map((e) => e.id)); result.addAll(assetIds);
} }
} }
return result; return result;
@ -162,17 +162,11 @@ class AlbumService {
bool changes = false; bool changes = false;
try { try {
await _userService.refreshUsers(); await _userService.refreshUsers();
final List<AlbumResponseDto>? serverAlbums = await _apiService.albumsApi final List<Album> serverAlbums =
.getAllAlbums(shared: isShared ? true : null); await _albumApiRepository.getAll(shared: isShared ? true : null);
if (serverAlbums == null) {
return false;
}
changes = await _syncService.syncRemoteAlbumsToDb( changes = await _syncService.syncRemoteAlbumsToDb(
serverAlbums, serverAlbums,
isShared: isShared, isShared: isShared,
loadDetails: (dto) async => dto.assetCount == dto.assets.length
? dto
: (await _apiService.albumsApi.getAlbumInfo(dto.id)) ?? dto,
); );
} finally { } finally {
_remoteCompleter.complete(changes); _remoteCompleter.complete(changes);
@ -186,30 +180,13 @@ class AlbumService {
Iterable<Asset> assets, [ Iterable<Asset> assets, [
Iterable<User> sharedUsers = const [], Iterable<User> sharedUsers = const [],
]) async { ]) async {
try { final Album album = await _albumApiRepository.create(
AlbumResponseDto? remote = await _apiService.albumsApi.createAlbum( albumName,
CreateAlbumDto( assetIds: assets.map((asset) => asset.remoteId!),
albumName: albumName, sharedUserIds: sharedUsers.map((user) => user.id),
assetIds: assets.map((asset) => asset.remoteId!).toList(),
albumUsers: sharedUsers
.map(
(e) => AlbumUserCreateDto(
userId: e.id,
role: AlbumUserRole.editor,
),
)
.toList(),
),
); );
if (remote != null) { await _entityService.fillAlbumWithDatabaseEntities(album);
final Album album = await Album.remote(remote); return _albumRepository.create(album);
await _albumRepository.create(album);
return album;
}
} catch (e) {
debugPrint("Error createSharedAlbum ${e.toString()}");
}
return null;
} }
/* /*
@ -241,32 +218,21 @@ class AlbumService {
Album album, Album album,
) async { ) async {
try { try {
var response = await _apiService.albumsApi.addAssetsToAlbum( final result = await _albumApiRepository.addAssets(
album.remoteId!, album.remoteId!,
BulkIdsDto(ids: assets.map((asset) => asset.remoteId!).toList()), assets.map((asset) => asset.remoteId!),
); );
if (response != null) { final List<Asset> addedAssets = result.added
List<Asset> successAssets = []; .map((id) => assets.firstWhere((asset) => asset.remoteId == id))
List<String> duplicatedAssets = []; .toList();
for (final result in response) { await _updateAssets(album.id, add: addedAssets);
if (result.success) {
successAssets
.add(assets.firstWhere((asset) => asset.remoteId == result.id));
} else if (!result.success &&
result.error == BulkIdResponseDtoErrorEnum.duplicate) {
duplicatedAssets.add(result.id);
}
}
await _updateAssets(album.id, add: successAssets);
return AlbumAddAssetsResponse( return AlbumAddAssetsResponse(
alreadyInAlbum: duplicatedAssets, alreadyInAlbum: result.duplicates,
successfullyAdded: successAssets.length, successfullyAdded: addedAssets.length,
); );
}
} catch (e) { } catch (e) {
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}"); debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
} }
@ -291,20 +257,11 @@ class AlbumService {
Album album, Album album,
) async { ) async {
try { try {
final List<AlbumUserAddDto> albumUsers = sharedUserIds final updatedAlbum =
.map((userId) => AlbumUserAddDto(userId: userId)) await _albumApiRepository.addUsers(album.remoteId!, sharedUserIds);
.toList(); await _entityService.fillAlbumWithDatabaseEntities(updatedAlbum);
await _albumRepository.update(updatedAlbum);
final result = await _apiService.albumsApi.addUsersToAlbum(
album.remoteId!,
AddUsersDto(albumUsers: albumUsers),
);
if (result != null) {
album.sharedUsers.addAll(await _userRepository.getByIds(sharedUserIds));
album.shared = result.shared;
await _albumRepository.update(album);
return true; return true;
}
} catch (e) { } catch (e) {
debugPrint("Error addAdditionalUserToAlbum ${e.toString()}"); debugPrint("Error addAdditionalUserToAlbum ${e.toString()}");
} }
@ -313,15 +270,13 @@ class AlbumService {
Future<bool> setActivityEnabled(Album album, bool enabled) async { Future<bool> setActivityEnabled(Album album, bool enabled) async {
try { try {
final result = await _apiService.albumsApi.updateAlbumInfo( final updatedAlbum = await _albumApiRepository.update(
album.remoteId!, album.remoteId!,
UpdateAlbumDto(isActivityEnabled: enabled), activityEnabled: enabled,
); );
if (result != null) { await _entityService.fillAlbumWithDatabaseEntities(updatedAlbum);
album.activityEnabled = enabled; await _albumRepository.update(updatedAlbum);
await _albumRepository.update(album);
return true; return true;
}
} catch (e) { } catch (e) {
debugPrint("Error setActivityEnabled ${e.toString()}"); debugPrint("Error setActivityEnabled ${e.toString()}");
} }
@ -332,7 +287,7 @@ class AlbumService {
try { try {
final user = Store.get(StoreKey.currentUser); final user = Store.get(StoreKey.currentUser);
if (album.owner.value?.isarId == user.isarId) { if (album.owner.value?.isarId == user.isarId) {
await _apiService.albumsApi.deleteAlbum(album.remoteId!); await _albumApiRepository.delete(album.remoteId!);
} }
if (album.shared) { if (album.shared) {
final foreignAssets = final foreignAssets =
@ -363,7 +318,7 @@ class AlbumService {
Future<bool> leaveAlbum(Album album) async { Future<bool> leaveAlbum(Album album) async {
try { try {
await _apiService.albumsApi.removeUserFromAlbum(album.remoteId!, "me"); await _albumApiRepository.removeUser(album.remoteId!, userId: "me");
return true; return true;
} catch (e) { } catch (e) {
debugPrint("Error leaveAlbum ${e.toString()}"); debugPrint("Error leaveAlbum ${e.toString()}");
@ -376,21 +331,14 @@ class AlbumService {
Iterable<Asset> assets, Iterable<Asset> assets,
) async { ) async {
try { try {
final response = await _apiService.albumsApi.removeAssetFromAlbum( final result = await _albumApiRepository.removeAssets(
album.remoteId!, album.remoteId!,
BulkIdsDto( assets.map((asset) => asset.remoteId!),
ids: assets.map((asset) => asset.remoteId!).toList(),
),
); );
if (response != null) { final toRemove = result.removed
final toRemove = response.every((e) => e.success) .map((id) => assets.firstWhere((asset) => asset.remoteId == id));
? assets
: response
.where((e) => e.success)
.map((e) => assets.firstWhere((a) => a.remoteId == e.id));
await _updateAssets(album.id, remove: toRemove.toList()); await _updateAssets(album.id, remove: toRemove.toList());
return true; return true;
}
} catch (e) { } catch (e) {
debugPrint("Error removeAssetFromAlbum ${e.toString()}"); debugPrint("Error removeAssetFromAlbum ${e.toString()}");
} }
@ -402,9 +350,9 @@ class AlbumService {
User user, User user,
) async { ) async {
try { try {
await _apiService.albumsApi.removeUserFromAlbum( await _albumApiRepository.removeUser(
album.remoteId!, album.remoteId!,
user.id, userId: user.id,
); );
album.sharedUsers.remove(user); album.sharedUsers.remove(user);
@ -425,15 +373,12 @@ class AlbumService {
String newAlbumTitle, String newAlbumTitle,
) async { ) async {
try { try {
await _apiService.albumsApi.updateAlbumInfo( album = await _albumApiRepository.update(
album.remoteId!, album.remoteId!,
UpdateAlbumDto( name: newAlbumTitle,
albumName: newAlbumTitle,
),
); );
album.name = newAlbumTitle; await _entityService.fillAlbumWithDatabaseEntities(album);
await _albumRepository.update(album); await _albumRepository.update(album);
return true; return true;
} catch (e) { } catch (e) {
debugPrint("Error changeTitleAlbum ${e.toString()}"); debugPrint("Error changeTitleAlbum ${e.toString()}");
@ -454,12 +399,8 @@ class AlbumService {
for (final albumName in albumNames) { for (final albumName in albumNames) {
Album? album = await getAlbumByName(albumName, true); Album? album = await getAlbumByName(albumName, true);
album ??= await createAlbum(albumName, []); album ??= await createAlbum(albumName, []);
if (album != null && album.remoteId != null) { if (album != null && album.remoteId != null) {
await _apiService.albumsApi.addAssetsToAlbum( await _albumApiRepository.addAssets(album.remoteId!, assetIds);
album.remoteId!,
BulkIdsDto(ids: assetIds),
);
} }
} }
} }

View File

@ -9,9 +9,13 @@ 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/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_api.interface.dart';
import 'package:immich_mobile/interfaces/exif_info.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/providers/db.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/exif_info.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';
@ -24,6 +28,8 @@ import 'package:openapi/api.dart';
final assetServiceProvider = Provider( final assetServiceProvider = Provider(
(ref) => AssetService( (ref) => AssetService(
ref.watch(assetApiRepositoryProvider),
ref.watch(exifInfoRepositoryProvider),
ref.watch(apiServiceProvider), ref.watch(apiServiceProvider),
ref.watch(syncServiceProvider), ref.watch(syncServiceProvider),
ref.watch(userServiceProvider), ref.watch(userServiceProvider),
@ -34,6 +40,8 @@ final assetServiceProvider = Provider(
); );
class AssetService { class AssetService {
final IAssetApiRepository _assetApiRepository;
final IExifInfoRepository _exifInfoRepository;
final ApiService _apiService; final ApiService _apiService;
final SyncService _syncService; final SyncService _syncService;
final UserService _userService; final UserService _userService;
@ -43,6 +51,8 @@ class AssetService {
final Isar _db; final Isar _db;
AssetService( AssetService(
this._assetApiRepository,
this._exifInfoRepository,
this._apiService, this._apiService,
this._syncService, this._syncService,
this._userService, this._userService,
@ -321,7 +331,7 @@ class AssetService {
for (BackupCandidate candidate in candidates) { for (BackupCandidate candidate in candidates) {
final asset = remoteAssets.firstWhereOrNull( final asset = remoteAssets.firstWhereOrNull(
(a) => a.localId == candidate.asset.id, (a) => a.localId == candidate.asset.localId,
); );
if (asset != null) { if (asset != null) {
@ -342,4 +352,46 @@ class AssetService {
log.severe("Error while syncing uploaded asset to albums", error, stack); log.severe("Error while syncing uploaded asset to albums", error, stack);
} }
} }
Future<void> setDescription(
Asset asset,
String newDescription,
) async {
final remoteAssetId = asset.remoteId;
final localExifId = asset.exifInfo?.id;
// Guard [remoteAssetId] and [localExifId] null
if (remoteAssetId == null || localExifId == null) {
return;
}
final result = await _assetApiRepository.update(
remoteAssetId,
description: newDescription,
);
final description = result.exifInfo?.description;
if (description != null) {
var exifInfo = await _exifInfoRepository.get(localExifId);
if (exifInfo != null) {
exifInfo.description = description;
await _exifInfoRepository.update(exifInfo);
}
}
}
Future<String> getDescription(Asset asset) async {
final localExifId = asset.exifInfo?.id;
// Guard [remoteAssetId] and [localExifId] null
if (localExifId == null) {
return "";
}
final exifInfo = await _exifInfoRepository.get(localExifId);
return exifInfo?.description ?? "";
}
} }

View File

@ -1,66 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
class AssetDescriptionService {
AssetDescriptionService(this._db, this._api);
final Isar _db;
final ApiService _api;
Future<void> setDescription(
Asset asset,
String newDescription,
) async {
final remoteAssetId = asset.remoteId;
final localExifId = asset.exifInfo?.id;
// Guard [remoteAssetId] and [localExifId] null
if (remoteAssetId == null || localExifId == null) {
return;
}
final result = await _api.assetsApi.updateAsset(
remoteAssetId,
UpdateAssetDto(description: newDescription),
);
final description = result?.exifInfo?.description;
if (description != null) {
var exifInfo = await _db.exifInfos.get(localExifId);
if (exifInfo != null) {
exifInfo.description = description;
await _db.writeTxn(
() => _db.exifInfos.put(exifInfo),
);
}
}
}
String getAssetDescription(Asset asset) {
final localExifId = asset.exifInfo?.id;
// Guard [remoteAssetId] and [localExifId] null
if (localExifId == null) {
return "";
}
final exifInfo = _db.exifInfos.getSync(localExifId);
return exifInfo?.description ?? "";
}
}
final assetDescriptionServiceProvider = Provider(
(ref) => AssetDescriptionService(
ref.watch(dbProvider),
ref.watch(apiServiceProvider),
),
);

View File

@ -13,10 +13,14 @@ 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';
import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album.repository.dart';
import 'package:immich_mobile/repositories/album_api.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset.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/file_media.repository.dart';
import 'package:immich_mobile/repositories/user.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/entity.service.dart';
import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart';
@ -34,7 +38,7 @@ 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: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'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
final backgroundServiceProvider = Provider( final backgroundServiceProvider = Provider(
(ref) => BackgroundService(), (ref) => BackgroundService(),
@ -361,23 +365,42 @@ class BackgroundService {
PartnerService partnerService = PartnerService(apiService, db); PartnerService partnerService = PartnerService(apiService, db);
AlbumRepository albumRepository = AlbumRepository(db); AlbumRepository albumRepository = AlbumRepository(db);
AssetRepository assetRepository = AssetRepository(db); AssetRepository assetRepository = AssetRepository(db);
UserRepository userRepository = UserRepository(db);
BackupRepository backupAlbumRepository = BackupRepository(db); BackupRepository backupAlbumRepository = BackupRepository(db);
HashService hashService = HashService(db, this); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository();
SyncService syncSerive = SyncService(db, hashService); FileMediaRepository fileMediaRepository = FileMediaRepository();
UserRepository userRepository = UserRepository(db);
AlbumApiRepository albumApiRepository =
AlbumApiRepository(apiService.albumsApi);
HashService hashService = HashService(db, this, albumMediaRepository);
EntityService entityService =
EntityService(assetRepository, userRepository);
SyncService syncSerive = SyncService(
db,
hashService,
entityService,
albumMediaRepository,
albumApiRepository,
);
UserService userService = UserService userService =
UserService(apiService, db, syncSerive, partnerService); UserService(apiService, db, syncSerive, partnerService);
AlbumService albumService = AlbumService( AlbumService albumService = AlbumService(
apiService,
userService, userService,
syncSerive, syncSerive,
entityService,
albumRepository, albumRepository,
assetRepository, assetRepository,
userRepository,
backupAlbumRepository, backupAlbumRepository,
albumMediaRepository,
albumApiRepository,
);
BackupService backupService = BackupService(
apiService,
db,
settingService,
albumService,
albumMediaRepository,
fileMediaRepository,
); );
BackupService backupService =
BackupService(apiService, db, settingService, albumService);
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync(); final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync(); final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
@ -385,7 +408,7 @@ class BackgroundService {
return true; return true;
} }
await PhotoManager.setIgnorePermissionCheck(true); await fileMediaRepository.enableBackgroundAccess();
do { do {
final bool backupOk = await _runBackup( final bool backupOk = await _runBackup(

View File

@ -6,9 +6,13 @@ import 'package:cancellation_token_http/http.dart' as http;
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/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/duplicated_asset.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/file_media.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/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
@ -16,6 +20,8 @@ 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/providers/db.provider.dart';
import 'package:immich_mobile/repositories/album_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';
@ -24,7 +30,7 @@ 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;
import 'package:permission_handler/permission_handler.dart' as pm; import 'package:permission_handler/permission_handler.dart' as pm;
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
final backupServiceProvider = Provider( final backupServiceProvider = Provider(
(ref) => BackupService( (ref) => BackupService(
@ -32,6 +38,8 @@ final backupServiceProvider = Provider(
ref.watch(dbProvider), ref.watch(dbProvider),
ref.watch(appSettingsServiceProvider), ref.watch(appSettingsServiceProvider),
ref.watch(albumServiceProvider), ref.watch(albumServiceProvider),
ref.watch(albumMediaRepositoryProvider),
ref.watch(fileMediaRepositoryProvider),
), ),
); );
@ -42,12 +50,16 @@ class BackupService {
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 IFileMediaRepository _fileMediaRepository;
BackupService( BackupService(
this._apiService, this._apiService,
this._db, this._db,
this._appSetting, this._appSetting,
this._albumService, this._albumService,
this._albumMediaRepository,
this._fileMediaRepository,
); );
Future<List<String>?> getDeviceBackupAsset() async { Future<List<String>?> getDeviceBackupAsset() async {
@ -86,44 +98,17 @@ class BackupService {
List<BackupAlbum> excludedBackupAlbums, { List<BackupAlbum> excludedBackupAlbums, {
bool useTimeFilter = true, bool useTimeFilter = true,
}) async { }) async {
final filter = FilterOptionGroup(
containsPathModified: true,
orders: [const OrderOption(type: OrderOptionType.updateDate)],
// title is needed to create Assets
imageOption: const FilterOption(needTitle: true),
videoOption: const FilterOption(needTitle: true),
);
final now = DateTime.now(); final now = DateTime.now();
final List<AssetPathEntity?> selectedAlbums =
await _loadAlbumsWithTimeFilter(
selectedBackupAlbums,
filter,
now,
useTimeFilter: useTimeFilter,
);
if (selectedAlbums.every((e) => e == null)) {
return {};
}
final List<AssetPathEntity?> excludedAlbums =
await _loadAlbumsWithTimeFilter(
excludedBackupAlbums,
filter,
now,
useTimeFilter: useTimeFilter,
);
final Set<BackupCandidate> toAdd = await _fetchAssetsAndUpdateLastBackup( final Set<BackupCandidate> toAdd = await _fetchAssetsAndUpdateLastBackup(
selectedAlbums,
selectedBackupAlbums, selectedBackupAlbums,
now, now,
useTimeFilter: useTimeFilter, useTimeFilter: useTimeFilter,
); );
if (toAdd.isEmpty) return {};
final Set<BackupCandidate> toRemove = await _fetchAssetsAndUpdateLastBackup( final Set<BackupCandidate> toRemove = await _fetchAssetsAndUpdateLastBackup(
excludedAlbums,
excludedBackupAlbums, excludedBackupAlbums,
now, now,
useTimeFilter: useTimeFilter, useTimeFilter: useTimeFilter,
@ -132,92 +117,62 @@ class BackupService {
return toAdd.difference(toRemove); return toAdd.difference(toRemove);
} }
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
List<BackupAlbum> albums,
FilterOptionGroup filter,
DateTime now, {
bool useTimeFilter = true,
}) async {
List<AssetPathEntity?> result = [];
for (BackupAlbum backupAlbum in albums) {
try {
final optionGroup = useTimeFilter
? filter.copyWith(
updateTimeCond: DateTimeCond(
// subtract 2 seconds to prevent missing assets due to rounding issues
min: backupAlbum.lastBackup
.subtract(const Duration(seconds: 2)),
max: now,
),
)
: filter;
final AssetPathEntity album =
await AssetPathEntity.obtainPathFromProperties(
id: backupAlbum.id,
optionGroup: optionGroup,
maxDateTimeToNow: false,
);
result.add(album);
} on StateError {
// either there are no assets matching the filter criteria OR the album no longer exists
}
}
return result;
}
Future<Set<BackupCandidate>> _fetchAssetsAndUpdateLastBackup( Future<Set<BackupCandidate>> _fetchAssetsAndUpdateLastBackup(
List<AssetPathEntity?> localAlbums,
List<BackupAlbum> backupAlbums, List<BackupAlbum> backupAlbums,
DateTime now, { DateTime now, {
bool useTimeFilter = true, bool useTimeFilter = true,
}) async { }) async {
Set<BackupCandidate> candidate = {}; Set<BackupCandidate> candidates = {};
for (int i = 0; i < localAlbums.length; i++) { for (final BackupAlbum backupAlbum in backupAlbums) {
final localAlbum = localAlbums[i]; final Album localAlbum;
if (localAlbum == null) { try {
localAlbum = await _albumMediaRepository.get(backupAlbum.id);
} on StateError {
// the album no longer exists
continue; continue;
} }
if (useTimeFilter && if (useTimeFilter &&
localAlbum.lastModified?.isBefore(backupAlbums[i].lastBackup) == localAlbum.modifiedAt.isBefore(backupAlbum.lastBackup)) {
true) {
continue; continue;
} }
final List<Asset> assets;
final assets = await localAlbum.getAssetListRange( try {
start: 0, assets = await _albumMediaRepository.getAssets(
end: await localAlbum.assetCountAsync, backupAlbum.id,
modifiedFrom: useTimeFilter
?
// subtract 2 seconds to prevent missing assets due to rounding issues
backupAlbum.lastBackup.subtract(const Duration(seconds: 2))
: null,
modifiedUntil: useTimeFilter ? now : null,
); );
} on StateError {
// either there are no assets matching the filter criteria OR the album no longer exists
continue;
}
// Add album's name to the asset info // Add album's name to the asset info
for (final asset in assets) { for (final asset in assets) {
List<String> albumNames = [localAlbum.name]; List<String> albumNames = [localAlbum.name];
final existingAsset = candidate.firstWhereOrNull( final existingAsset = candidates.firstWhereOrNull(
(a) => a.asset.id == asset.id, (candidate) => candidate.asset.localId == asset.localId,
); );
if (existingAsset != null) { if (existingAsset != null) {
albumNames.addAll(existingAsset.albumNames); albumNames.addAll(existingAsset.albumNames);
candidate.remove(existingAsset); candidates.remove(existingAsset);
} }
candidate.add( candidates.add(BackupCandidate(asset: asset, albumNames: albumNames));
BackupCandidate(
asset: asset,
albumNames: albumNames,
),
);
} }
backupAlbums[i].lastBackup = now; backupAlbum.lastBackup = now;
} }
return candidate; return candidates;
} }
/// Returns a new list of assets not yet uploaded /// Returns a new list of assets not yet uploaded
@ -230,7 +185,7 @@ class BackupService {
final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds(); final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds();
candidates.removeWhere( candidates.removeWhere(
(candidate) => duplicatedAssetIds.contains(candidate.asset.id), (candidate) => duplicatedAssetIds.contains(candidate.asset.localId),
); );
if (candidates.isEmpty) { if (candidates.isEmpty) {
@ -243,7 +198,7 @@ class BackupService {
final CheckExistingAssetsResponseDto? duplicates = final CheckExistingAssetsResponseDto? duplicates =
await _apiService.assetsApi.checkExistingAssets( await _apiService.assetsApi.checkExistingAssets(
CheckExistingAssetsDto( CheckExistingAssetsDto(
deviceAssetIds: candidates.map((c) => c.asset.id).toList(), deviceAssetIds: candidates.map((c) => c.asset.localId!).toList(),
deviceId: deviceId, deviceId: deviceId,
), ),
); );
@ -259,7 +214,7 @@ class BackupService {
} }
if (existing.isNotEmpty) { if (existing.isNotEmpty) {
candidates.removeWhere((c) => existing.contains(c.asset.id)); candidates.removeWhere((c) => existing.contains(c.asset.localId));
} }
return candidates; return candidates;
@ -278,7 +233,7 @@ class BackupService {
// DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS // DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS
if (Platform.isIOS) { if (Platform.isIOS) {
await PhotoManager.requestPermissionExtend(); await _fileMediaRepository.requestExtendedPermissions();
} }
return true; return true;
@ -289,9 +244,9 @@ class BackupService {
List<BackupCandidate> _sortPhotosFirst(List<BackupCandidate> candidates) { List<BackupCandidate> _sortPhotosFirst(List<BackupCandidate> candidates) {
return candidates.sorted( return candidates.sorted(
(a, b) { (a, b) {
final cmp = a.asset.typeInt - b.asset.typeInt; final cmp = a.asset.type.index - b.asset.type.index;
if (cmp != 0) return cmp; if (cmp != 0) return cmp;
return a.asset.createDateTime.compareTo(b.asset.createDateTime); return a.asset.fileCreatedAt.compareTo(b.asset.fileCreatedAt);
}, },
); );
} }
@ -325,13 +280,13 @@ class BackupService {
} }
for (final candidate in candidates) { for (final candidate in candidates) {
final AssetEntity entity = candidate.asset; final Asset asset = candidate.asset;
File? file; File? file;
File? livePhotoFile; File? livePhotoFile;
try { try {
final isAvailableLocally = final isAvailableLocally =
await entity.isLocallyAvailable(isOrigin: true); await asset.local!.isLocallyAvailable(isOrigin: true);
// Handle getting files from iCloud // Handle getting files from iCloud
if (!isAvailableLocally && Platform.isIOS) { if (!isAvailableLocally && Platform.isIOS) {
@ -342,39 +297,41 @@ class BackupService {
onCurrentAsset( onCurrentAsset(
CurrentUploadAsset( CurrentUploadAsset(
id: entity.id, id: asset.localId!,
fileCreatedAt: entity.createDateTime.year == 1970 fileCreatedAt: asset.fileCreatedAt.year == 1970
? entity.modifiedDateTime ? asset.fileModifiedAt
: entity.createDateTime, : asset.fileCreatedAt,
fileName: await entity.titleAsync, fileName: asset.fileName,
fileType: _getAssetType(entity.type), fileType: _getAssetType(asset.type),
iCloudAsset: true, iCloudAsset: true,
), ),
); );
file = await entity.loadFile(progressHandler: pmProgressHandler); file =
if (entity.isLivePhoto) { await asset.local!.loadFile(progressHandler: pmProgressHandler);
livePhotoFile = await entity.loadFile( if (asset.local!.isLivePhoto) {
livePhotoFile = await asset.local!.loadFile(
withSubtype: true, withSubtype: true,
progressHandler: pmProgressHandler, progressHandler: pmProgressHandler,
); );
} }
} else { } else {
if (entity.type == AssetType.video) { if (asset.type == AssetType.video) {
file = await entity.originFile; file = await asset.local!.originFile;
} else { } else {
file = await entity.originFile.timeout(const Duration(seconds: 5)); file = await asset.local!.originFile
if (entity.isLivePhoto) { .timeout(const Duration(seconds: 5));
livePhotoFile = await entity.originFileWithSubtype if (asset.local!.isLivePhoto) {
livePhotoFile = await asset.local!.originFileWithSubtype
.timeout(const Duration(seconds: 5)); .timeout(const Duration(seconds: 5));
} }
} }
} }
if (file != null) { if (file != null) {
String originalFileName = await entity.titleAsync; String originalFileName = asset.fileName;
if (entity.isLivePhoto) { if (asset.local!.isLivePhoto) {
if (livePhotoFile == null) { if (livePhotoFile == null) {
_log.warning( _log.warning(
"Failed to obtain motion part of the livePhoto - $originalFileName", "Failed to obtain motion part of the livePhoto - $originalFileName",
@ -398,31 +355,31 @@ class BackupService {
baseRequest.headers.addAll(ApiService.getRequestHeaders()); baseRequest.headers.addAll(ApiService.getRequestHeaders());
baseRequest.headers["Transfer-Encoding"] = "chunked"; baseRequest.headers["Transfer-Encoding"] = "chunked";
baseRequest.fields['deviceAssetId'] = entity.id; baseRequest.fields['deviceAssetId'] = asset.localId!;
baseRequest.fields['deviceId'] = deviceId; baseRequest.fields['deviceId'] = deviceId;
baseRequest.fields['fileCreatedAt'] = baseRequest.fields['fileCreatedAt'] =
entity.createDateTime.toUtc().toIso8601String(); asset.fileCreatedAt.toUtc().toIso8601String();
baseRequest.fields['fileModifiedAt'] = baseRequest.fields['fileModifiedAt'] =
entity.modifiedDateTime.toUtc().toIso8601String(); asset.fileModifiedAt.toUtc().toIso8601String();
baseRequest.fields['isFavorite'] = entity.isFavorite.toString(); baseRequest.fields['isFavorite'] = asset.isFavorite.toString();
baseRequest.fields['duration'] = entity.videoDuration.toString(); baseRequest.fields['duration'] = asset.duration.toString();
baseRequest.files.add(assetRawUploadData); baseRequest.files.add(assetRawUploadData);
onCurrentAsset( onCurrentAsset(
CurrentUploadAsset( CurrentUploadAsset(
id: entity.id, id: asset.localId!,
fileCreatedAt: entity.createDateTime.year == 1970 fileCreatedAt: asset.fileCreatedAt.year == 1970
? entity.modifiedDateTime ? asset.fileModifiedAt
: entity.createDateTime, : asset.fileCreatedAt,
fileName: originalFileName, fileName: originalFileName,
fileType: _getAssetType(entity.type), fileType: _getAssetType(asset.type),
fileSize: file.lengthSync(), fileSize: file.lengthSync(),
iCloudAsset: false, iCloudAsset: false,
), ),
); );
String? livePhotoVideoId; String? livePhotoVideoId;
if (entity.isLivePhoto && livePhotoFile != null) { if (asset.local!.isLivePhoto && livePhotoFile != null) {
livePhotoVideoId = await uploadLivePhotoVideo( livePhotoVideoId = await uploadLivePhotoVideo(
originalFileName, originalFileName,
livePhotoFile, livePhotoFile,
@ -448,16 +405,16 @@ class BackupService {
final errorMessage = error['message'] ?? error['error']; final errorMessage = error['message'] ?? error['error'];
debugPrint( debugPrint(
"Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}", "Error(${error['statusCode']}) uploading ${asset.localId} | $originalFileName | Created on ${asset.fileCreatedAt} | ${error['error']}",
); );
onError( onError(
ErrorUploadAsset( ErrorUploadAsset(
asset: entity, asset: asset,
id: entity.id, id: asset.localId!,
fileCreatedAt: entity.createDateTime, fileCreatedAt: asset.fileCreatedAt,
fileName: originalFileName, fileName: originalFileName,
fileType: _getAssetType(entity.type), fileType: _getAssetType(candidate.asset.type),
errorMessage: errorMessage, errorMessage: errorMessage,
), ),
); );
@ -473,7 +430,7 @@ class BackupService {
bool isDuplicate = false; bool isDuplicate = false;
if (response.statusCode == 200) { if (response.statusCode == 200) {
isDuplicate = true; isDuplicate = true;
duplicatedAssetIds.add(entity.id); duplicatedAssetIds.add(asset.localId!);
} }
onSuccess( onSuccess(

View File

@ -8,39 +8,46 @@ 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/exif_info.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/exif_info.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/exif_info.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:photo_manager/photo_manager.dart' show PhotoManager;
/// Finds duplicates originating from missing EXIF information /// Finds duplicates originating from missing EXIF information
class BackupVerificationService { class BackupVerificationService {
final Isar _db; final IFileMediaRepository _fileMediaRepository;
final IAssetRepository _assetRepository;
final IExifInfoRepository _exifInfoRepository;
BackupVerificationService(this._db); BackupVerificationService(
this._fileMediaRepository,
this._assetRepository,
this._exifInfoRepository,
);
/// Returns at most [limit] assets that were backed up without exif /// Returns at most [limit] assets that were backed up without exif
Future<List<Asset>> findWronglyBackedUpAssets({int limit = 100}) async { Future<List<Asset>> findWronglyBackedUpAssets({int limit = 100}) async {
final owner = Store.get(StoreKey.currentUser).isarId; final owner = Store.get(StoreKey.currentUser).isarId;
final List<Asset> onlyLocal = await _db.assets final List<Asset> onlyLocal = await _assetRepository.getAll(
.where() ownerId: owner,
.remoteIdIsNull() remote: false,
.filter() limit: limit,
.ownerIdEqualTo(owner)
.localIdIsNotNull()
.findAll();
final List<Asset> remoteMatches = await _getMatches(
_db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(),
owner,
onlyLocal,
limit,
); );
final List<Asset> localMatches = await _getMatches( final List<Asset> remoteMatches = await _assetRepository.getMatches(
_db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(), assets: onlyLocal,
owner, ownerId: owner,
remoteMatches, remote: true,
limit, limit: limit,
);
final List<Asset> localMatches = await _assetRepository.getMatches(
assets: remoteMatches,
ownerId: owner,
remote: false,
limit: limit,
); );
final List<Asset> deleteCandidates = [], originals = []; final List<Asset> deleteCandidates = [], originals = [];
@ -50,7 +57,7 @@ class BackupVerificationService {
localMatches, localMatches,
compare: (a, b) => a.fileName.compareTo(b.fileName), compare: (a, b) => a.fileName.compareTo(b.fileName),
both: (a, b) async { both: (a, b) async {
a.exifInfo = await _db.exifInfos.get(a.id); a.exifInfo = await _exifInfoRepository.get(a.id);
deleteCandidates.add(a); deleteCandidates.add(a);
originals.add(b); originals.add(b);
return false; return false;
@ -71,6 +78,7 @@ class BackupVerificationService {
auth: Store.get(StoreKey.accessToken), auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint), endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken, rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository,
), ),
); );
final upper = compute( final upper = compute(
@ -81,6 +89,7 @@ class BackupVerificationService {
auth: Store.get(StoreKey.accessToken), auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint), endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken, rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository,
), ),
); );
toDelete = await lower + await upper; toDelete = await lower + await upper;
@ -93,6 +102,7 @@ class BackupVerificationService {
auth: Store.get(StoreKey.accessToken), auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint), endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken, rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository,
), ),
); );
} }
@ -106,12 +116,13 @@ class BackupVerificationService {
String auth, String auth,
String endpoint, String endpoint,
RootIsolateToken rootIsolateToken, RootIsolateToken rootIsolateToken,
IFileMediaRepository fileMediaRepository,
}) tuple, }) tuple,
) async { ) async {
assert(tuple.deleteCandidates.length == tuple.originals.length); assert(tuple.deleteCandidates.length == tuple.originals.length);
final List<Asset> result = []; final List<Asset> result = [];
BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken); BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken);
await PhotoManager.setIgnorePermissionCheck(true); await tuple.fileMediaRepository.enableBackgroundAccess();
final ApiService apiService = ApiService(); final ApiService apiService = ApiService();
apiService.setEndpoint(tuple.endpoint); apiService.setEndpoint(tuple.endpoint);
apiService.setAccessToken(tuple.auth); apiService.setAccessToken(tuple.auth);
@ -186,35 +197,6 @@ class BackupVerificationService {
return bytes.buffer.asUint64List(start); return bytes.buffer.asUint64List(start);
} }
static Future<List<Asset>> _getMatches(
QueryBuilder<Asset, Asset, QAfterFilterCondition> query,
int ownerId,
List<Asset> assets,
int limit,
) =>
query
.ownerIdEqualTo(ownerId)
.anyOf(
assets,
(q, Asset a) => q
.fileNameEqualTo(a.fileName)
.and()
.durationInSecondsEqualTo(a.durationInSeconds)
.and()
.fileCreatedAtBetween(
a.fileCreatedAt.subtract(const Duration(hours: 12)),
a.fileCreatedAt.add(const Duration(hours: 12)),
)
.and()
.not()
.checksumEqualTo(a.checksum),
)
.sortByFileName()
.thenByFileCreatedAt()
.thenByFileModifiedAt()
.limit(limit)
.findAll();
static bool _sameExceptTimeZone(DateTime a, DateTime b) { static bool _sameExceptTimeZone(DateTime a, DateTime b) {
final ms = a.isAfter(b) final ms = a.isAfter(b)
? a.millisecondsSinceEpoch - b.millisecondsSinceEpoch ? a.millisecondsSinceEpoch - b.millisecondsSinceEpoch
@ -227,6 +209,8 @@ class BackupVerificationService {
final backupVerificationServiceProvider = Provider( final backupVerificationServiceProvider = Provider(
(ref) => BackupVerificationService( (ref) => BackupVerificationService(
ref.watch(dbProvider), ref.watch(fileMediaRepositoryProvider),
ref.watch(assetRepositoryProvider),
ref.watch(exifInfoRepositoryProvider),
), ),
); );

View File

@ -0,0 +1,52 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/user.interface.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/user.repository.dart';
class EntityService {
final IAssetRepository _assetRepository;
final IUserRepository _userRepository;
EntityService(
this._assetRepository,
this._userRepository,
);
Future<Album> fillAlbumWithDatabaseEntities(Album album) async {
final ownerId = album.ownerId;
if (ownerId != null) {
// replace owner with user from database
album.owner.value = await _userRepository.get(ownerId);
}
final thumbnailAssetId =
album.remoteThumbnailAssetId ?? album.thumbnail.value?.remoteId;
if (thumbnailAssetId != null) {
// set thumbnail with asset from database
album.thumbnail.value =
await _assetRepository.getByRemoteId(thumbnailAssetId);
}
if (album.remoteUsers.isNotEmpty) {
// replace all users with users from database
final users = await _userRepository
.getByIds(album.remoteUsers.map((user) => user.id).toList());
album.sharedUsers.clear();
album.sharedUsers.addAll(users);
}
if (album.remoteAssets.isNotEmpty) {
// replace all assets with assets from database
final assets = await _assetRepository
.getAllByRemoteId(album.remoteAssets.map((asset) => asset.remoteId!));
album.assets.clear();
album.assets.addAll(assets);
}
return album;
}
}
final entityServiceProvider = Provider(
(ref) => EntityService(
ref.watch(assetRepositoryProvider),
ref.watch(userRepositoryProvider),
),
);

View File

@ -2,6 +2,9 @@ import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.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/interfaces/album_media.interface.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/background.service.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';
@ -11,38 +14,46 @@ import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
class HashService { class HashService {
HashService(this._db, this._backgroundService); HashService(this._db, this._backgroundService, this._albumMediaRepository);
final Isar _db; final Isar _db;
final BackgroundService _backgroundService; final BackgroundService _backgroundService;
final IAlbumMediaRepository _albumMediaRepository;
final _log = Logger('HashService'); final _log = Logger('HashService');
/// Returns all assets that were successfully hashed /// Returns all assets that were successfully hashed
Future<List<Asset>> getHashedAssets( Future<List<Asset>> getHashedAssets(
AssetPathEntity album, { Album album, {
int start = 0, int start = 0,
int end = 0x7fffffffffffffff, int end = 0x7fffffffffffffff,
DateTime? modifiedFrom,
DateTime? modifiedUntil,
Set<String>? excludedAssets, Set<String>? excludedAssets,
}) async { }) async {
final entities = await album.getAssetListRange(start: start, end: end); final entities = await _albumMediaRepository.getAssets(
album.localId!,
start: start,
end: end,
modifiedFrom: modifiedFrom,
modifiedUntil: modifiedUntil,
);
final filtered = excludedAssets == null final filtered = excludedAssets == null
? entities ? entities
: entities.where((e) => !excludedAssets.contains(e.id)).toList(); : entities.where((e) => !excludedAssets.contains(e.localId!)).toList();
return _hashAssets(filtered); return _hashAssets(filtered);
} }
/// Converts a list of [AssetEntity]s to [Asset]s including only those /// Processes a list of local [Asset]s, storing their hash and returning only those
/// that were successfully hashed. Hashes are looked up in a DB table /// that were successfully hashed. Hashes are looked up in a DB table
/// [AndroidDeviceAsset] / [IOSDeviceAsset] by local id. Only missing /// [AndroidDeviceAsset] / [IOSDeviceAsset] by local id. Only missing
/// entries are newly hashed and added to the DB table. /// entries are newly hashed and added to the DB table.
Future<List<Asset>> _hashAssets(List<AssetEntity> assetEntities) async { Future<List<Asset>> _hashAssets(List<Asset> assets) async {
const int batchFileCount = 128; const int batchFileCount = 128;
const int batchDataSize = 1024 * 1024 * 1024; // 1GB const int batchDataSize = 1024 * 1024 * 1024; // 1GB
final ids = assetEntities final ids = assets
.map(Platform.isAndroid ? (a) => a.id.toInt() : (a) => a.id) .map(Platform.isAndroid ? (a) => a.localId!.toInt() : (a) => a.localId!)
.toList(); .toList();
final List<DeviceAsset?> hashes = await _lookupHashes(ids); final List<DeviceAsset?> hashes = await _lookupHashes(ids);
final List<DeviceAsset> toAdd = []; final List<DeviceAsset> toAdd = [];
@ -50,22 +61,28 @@ class HashService {
int bytes = 0; int bytes = 0;
for (int i = 0; i < assetEntities.length; i++) { for (int i = 0; i < assets.length; i++) {
if (hashes[i] != null) { if (hashes[i] != null) {
continue; continue;
} }
final file = await assetEntities[i].originFile;
if (file == null) { File? file;
final fileName = await assetEntities[i].titleAsync.catchError((error) {
try {
file = await assets[i].local!.originFile;
} catch (error, stackTrace) {
_log.warning( _log.warning(
"Failed to get title for asset ${assetEntities[i].id}", "Error getting file to hash for asset ${assets[i].localId}, name: ${assets[i].fileName}, created on: ${assets[i].fileCreatedAt}, skipping",
error,
stackTrace,
); );
}
return ""; if (file == null) {
}); final fileName = assets[i].fileName;
_log.warning( _log.warning(
"Failed to get file for asset ${assetEntities[i].id}, name: $fileName, created on: ${assetEntities[i].createDateTime}, skipping", "Failed to get file for asset ${assets[i].localId}, name: $fileName, created on: ${assets[i].fileCreatedAt}, skipping",
); );
continue; continue;
} }
@ -86,7 +103,7 @@ class HashService {
if (toHash.isNotEmpty) { if (toHash.isNotEmpty) {
await _processBatch(toHash, toAdd); await _processBatch(toHash, toAdd);
} }
return _mapAllHashedAssets(assetEntities, hashes); return _getHashedAssets(assets, hashes);
} }
/// Lookup hashes of assets by their local ID /// Lookup hashes of assets by their local ID
@ -133,15 +150,16 @@ class HashService {
return hashes; return hashes;
} }
/// Converts [AssetEntity]s that were successfully hashed to [Asset]s /// Returns all successfully hashed [Asset]s with their hash value set
List<Asset> _mapAllHashedAssets( List<Asset> _getHashedAssets(
List<AssetEntity> assets, List<Asset> assets,
List<DeviceAsset?> hashes, List<DeviceAsset?> hashes,
) { ) {
final List<Asset> result = []; final List<Asset> result = [];
for (int i = 0; i < assets.length; i++) { for (int i = 0; i < assets.length; i++) {
if (hashes[i] != null && hashes[i]!.hash.isNotEmpty) { if (hashes[i] != null && hashes[i]!.hash.isNotEmpty) {
result.add(Asset.local(assets[i], hashes[i]!.hash)); assets[i].byteHash = hashes[i]!.hash;
result.add(assets[i]);
} }
} }
return result; return result;
@ -152,5 +170,6 @@ final hashServiceProvider = Provider(
(ref) => HashService( (ref) => HashService(
ref.watch(dbProvider), ref.watch(dbProvider),
ref.watch(backgroundServiceProvider), ref.watch(backgroundServiceProvider),
ref.watch(albumMediaRepositoryProvider),
), ),
); );

View File

@ -3,21 +3,27 @@ import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/response_extensions.dart'; import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/entities/asset.entity.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/providers/api.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
final imageViewerServiceProvider = final imageViewerServiceProvider = Provider(
Provider((ref) => ImageViewerService(ref.watch(apiServiceProvider))); (ref) => ImageViewerService(
ref.watch(apiServiceProvider),
ref.watch(fileMediaRepositoryProvider),
),
);
class ImageViewerService { class ImageViewerService {
final ApiService _apiService; final ApiService _apiService;
final IFileMediaRepository _fileMediaRepository;
final Logger _log = Logger("ImageViewerService"); final Logger _log = Logger("ImageViewerService");
ImageViewerService(this._apiService); ImageViewerService(this._apiService, this._fileMediaRepository);
Future<bool> downloadAsset(Asset asset) async { Future<bool> downloadAsset(Asset asset) async {
File? imageFile; File? imageFile;
@ -46,7 +52,7 @@ class ImageViewerService {
return false; return false;
} }
AssetEntity? entity; Asset? resultAsset;
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
videoFile = await File('${tempDir.path}/livephoto.mov').create(); videoFile = await File('${tempDir.path}/livephoto.mov').create();
@ -54,24 +60,21 @@ class ImageViewerService {
videoFile.writeAsBytesSync(motionResponse.bodyBytes); videoFile.writeAsBytesSync(motionResponse.bodyBytes);
imageFile.writeAsBytesSync(imageResponse.bodyBytes); imageFile.writeAsBytesSync(imageResponse.bodyBytes);
entity = await PhotoManager.editor.darwin.saveLivePhoto( resultAsset = await _fileMediaRepository.saveLivePhoto(
imageFile: imageFile, image: imageFile,
videoFile: videoFile, video: videoFile,
title: asset.fileName, title: asset.fileName,
); );
if (entity == null) { if (resultAsset == null) {
_log.warning( _log.warning(
"Asset cannot be saved as a live photo. This is most likely a motion photo. Saving only the image file", "Asset cannot be saved as a live photo. This is most likely a motion photo. Saving only the image file",
); );
resultAsset = await _fileMediaRepository
entity = await PhotoManager.editor.saveImage( .saveImage(imageResponse.bodyBytes, title: asset.fileName);
imageResponse.bodyBytes,
title: asset.fileName,
);
} }
return entity != null; return resultAsset != null;
} else { } else {
var res = await _apiService.assetsApi var res = await _apiService.assetsApi
.downloadAssetWithHttpInfo(asset.remoteId!); .downloadAssetWithHttpInfo(asset.remoteId!);
@ -81,11 +84,11 @@ class ImageViewerService {
return false; return false;
} }
final AssetEntity? entity; final Asset? resultAsset;
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
if (asset.isImage) { if (asset.isImage) {
entity = await PhotoManager.editor.saveImage( resultAsset = await _fileMediaRepository.saveImage(
res.bodyBytes, res.bodyBytes,
title: asset.fileName, title: asset.fileName,
relativePath: relativePath, relativePath: relativePath,
@ -94,13 +97,13 @@ class ImageViewerService {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
videoFile = await File('${tempDir.path}/${asset.fileName}').create(); videoFile = await File('${tempDir.path}/${asset.fileName}').create();
videoFile.writeAsBytesSync(res.bodyBytes); videoFile.writeAsBytesSync(res.bodyBytes);
entity = await PhotoManager.editor.saveVideo( resultAsset = await _fileMediaRepository.saveVideo(
videoFile, videoFile,
title: asset.fileName, title: asset.fileName,
relativePath: relativePath, relativePath: relativePath,
); );
} }
return entity != null; return resultAsset != null;
} }
} catch (error, stack) { } catch (error, stack) {
_log.severe("Error saving downloaded asset", error, stack); _log.severe("Error saving downloaded asset", error, stack);

View File

@ -8,7 +8,12 @@ import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/exif_info.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/album_api.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/album_api.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.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';
@ -16,20 +21,33 @@ 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:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
final syncServiceProvider = Provider( final syncServiceProvider = Provider(
(ref) => SyncService(ref.watch(dbProvider), ref.watch(hashServiceProvider)), (ref) => SyncService(
ref.watch(dbProvider),
ref.watch(hashServiceProvider),
ref.watch(entityServiceProvider),
ref.watch(albumMediaRepositoryProvider),
ref.watch(albumApiRepositoryProvider),
),
); );
class SyncService { class SyncService {
final Isar _db; final Isar _db;
final HashService _hashService; final HashService _hashService;
final EntityService _entityService;
final IAlbumMediaRepository _albumMediaRepository;
final IAlbumApiRepository _albumApiRepository;
final AsyncMutex _lock = AsyncMutex(); final AsyncMutex _lock = AsyncMutex();
final Logger _log = Logger('SyncService'); final Logger _log = Logger('SyncService');
SyncService(this._db, this._hashService); SyncService(
this._db,
this._hashService,
this._entityService,
this._albumMediaRepository,
this._albumApiRepository,
);
// public methods: // public methods:
@ -59,16 +77,15 @@ class SyncService {
/// Syncs remote albums to the database /// Syncs remote albums to the database
/// returns `true` if there were any changes /// returns `true` if there were any changes
Future<bool> syncRemoteAlbumsToDb( Future<bool> syncRemoteAlbumsToDb(
List<AlbumResponseDto> remote, { List<Album> remote, {
required bool isShared, required bool isShared,
required FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
}) => }) =>
_lock.run(() => _syncRemoteAlbumsToDb(remote, isShared, loadDetails)); _lock.run(() => _syncRemoteAlbumsToDb(remote, isShared));
/// Syncs all device albums and their assets to the database /// Syncs all device albums and their assets to the database
/// Returns `true` if there were any changes /// Returns `true` if there were any changes
Future<bool> syncLocalAlbumAssetsToDb( Future<bool> syncLocalAlbumAssetsToDb(
List<AssetPathEntity> onDevice, [ List<Album> onDevice, [
Set<String>? excludedAssets, Set<String>? excludedAssets,
]) => ]) =>
_lock.run(() => _syncLocalAlbumAssetsToDb(onDevice, excludedAssets)); _lock.run(() => _syncLocalAlbumAssetsToDb(onDevice, excludedAssets));
@ -283,11 +300,10 @@ class SyncService {
/// Syncs remote albums to the database /// Syncs remote albums to the database
/// returns `true` if there were any changes /// returns `true` if there were any changes
Future<bool> _syncRemoteAlbumsToDb( Future<bool> _syncRemoteAlbumsToDb(
List<AlbumResponseDto> remote, List<Album> remoteAlbums,
bool isShared, bool isShared,
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
) async { ) async {
remote.sortBy((e) => e.id); remoteAlbums.sortBy((e) => e.remoteId!);
final baseQuery = _db.albums.where().remoteIdIsNotNull().filter(); final baseQuery = _db.albums.where().remoteIdIsNotNull().filter();
final QueryBuilder<Album, Album, QAfterFilterCondition> query; final QueryBuilder<Album, Album, QAfterFilterCondition> query;
@ -304,14 +320,14 @@ class SyncService {
final List<Asset> existing = []; final List<Asset> existing = [];
final bool changes = await diffSortedLists( final bool changes = await diffSortedLists(
remote, remoteAlbums,
dbAlbums, dbAlbums,
compare: (AlbumResponseDto a, Album b) => a.id.compareTo(b.remoteId!), compare: (remoteAlbum, dbAlbum) =>
both: (AlbumResponseDto a, Album b) => remoteAlbum.remoteId!.compareTo(dbAlbum.remoteId!),
_syncRemoteAlbum(a, b, toDelete, existing, loadDetails), both: (remoteAlbum, dbAlbum) =>
onlyFirst: (AlbumResponseDto a) => _syncRemoteAlbum(remoteAlbum, dbAlbum, toDelete, existing),
_addAlbumFromServer(a, existing, loadDetails), onlyFirst: (remoteAlbum) => _addAlbumFromServer(remoteAlbum, existing),
onlySecond: (Album a) => _removeAlbumFromDb(a, toDelete), onlySecond: (dbAlbum) => _removeAlbumFromDb(dbAlbum, toDelete),
); );
if (isShared && toDelete.isNotEmpty) { if (isShared && toDelete.isNotEmpty) {
@ -332,26 +348,22 @@ class SyncService {
/// syncing changes from local back to server) /// syncing changes from local back to server)
/// accumulates /// accumulates
Future<bool> _syncRemoteAlbum( Future<bool> _syncRemoteAlbum(
AlbumResponseDto dto, Album dto,
Album album, Album album,
List<Asset> deleteCandidates, List<Asset> deleteCandidates,
List<Asset> existing, List<Asset> existing,
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
) async { ) async {
if (!_hasAlbumResponseDtoChanged(dto, album)) { if (!_hasRemoteAlbumChanged(dto, album)) {
return false; return false;
} }
// loadDetails (/api/album/:id) will not include lastModifiedAssetTimestamp, // loadDetails (/api/album/:id) will not include lastModifiedAssetTimestamp,
// 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 loadDetails(dto); dto = await _albumApiRepository.get(dto.remoteId!);
if (dto.assetCount != dto.assets.length) {
return false;
}
final assetsInDb = final assetsInDb =
await album.assets.filter().sortByOwnerId().thenByChecksum().findAll(); await album.assets.filter().sortByOwnerId().thenByChecksum().findAll();
assert(assetsInDb.isSorted(Asset.compareByOwnerChecksum), "inDb unsorted!"); assert(assetsInDb.isSorted(Asset.compareByOwnerChecksum), "inDb unsorted!");
final List<Asset> assetsOnRemote = dto.getAssets(); final List<Asset> assetsOnRemote = dto.remoteAssets.toList();
assetsOnRemote.sort(Asset.compareByOwnerChecksum); assetsOnRemote.sort(Asset.compareByOwnerChecksum);
final (toAdd, toUpdate, toUnlink) = _diffAssets( final (toAdd, toUpdate, toUnlink) = _diffAssets(
assetsOnRemote, assetsOnRemote,
@ -362,15 +374,16 @@ class SyncService {
// update shared users // update shared users
final List<User> sharedUsers = album.sharedUsers.toList(growable: false); final List<User> sharedUsers = album.sharedUsers.toList(growable: false);
sharedUsers.sort((a, b) => a.id.compareTo(b.id)); sharedUsers.sort((a, b) => a.id.compareTo(b.id));
dto.albumUsers.sort((a, b) => a.user.id.compareTo(b.user.id)); final List<User> users = dto.remoteUsers.toList()
..sort((a, b) => a.id.compareTo(b.id));
final List<String> userIdsToAdd = []; final List<String> userIdsToAdd = [];
final List<User> usersToUnlink = []; final List<User> usersToUnlink = [];
diffSortedListsSync( diffSortedListsSync(
dto.albumUsers, users,
sharedUsers, sharedUsers,
compare: (AlbumUserResponseDto a, User b) => a.user.id.compareTo(b.id), compare: (User a, User b) => a.id.compareTo(b.id),
both: (a, b) => false, both: (a, b) => false,
onlyFirst: (AlbumUserResponseDto a) => userIdsToAdd.add(a.user.id), onlyFirst: (User a) => userIdsToAdd.add(a.id),
onlySecond: (User a) => usersToUnlink.add(a), onlySecond: (User a) => usersToUnlink.add(a),
); );
@ -380,19 +393,19 @@ class SyncService {
final assetsToLink = existingInDb + updated; final assetsToLink = existingInDb + updated;
final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast<User>(); final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast<User>();
album.name = dto.albumName; album.name = dto.name;
album.shared = dto.shared; album.shared = dto.shared;
album.createdAt = dto.createdAt; album.createdAt = dto.createdAt;
album.modifiedAt = dto.updatedAt; album.modifiedAt = dto.modifiedAt;
album.startDate = dto.startDate; album.startDate = dto.startDate;
album.endDate = dto.endDate; album.endDate = dto.endDate;
album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp; album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp;
album.shared = dto.shared; album.shared = dto.shared;
album.activityEnabled = dto.isActivityEnabled; album.activityEnabled = dto.activityEnabled;
if (album.thumbnail.value?.remoteId != dto.albumThumbnailAssetId) { if (album.thumbnail.value?.remoteId != dto.remoteThumbnailAssetId) {
album.thumbnail.value = await _db.assets album.thumbnail.value = await _db.assets
.where() .where()
.remoteIdEqualTo(dto.albumThumbnailAssetId) .remoteIdEqualTo(dto.remoteThumbnailAssetId)
.findFirst(); .findFirst();
} }
@ -428,27 +441,26 @@ class SyncService {
/// (shared) assets to the database beforehand /// (shared) assets to the database beforehand
/// accumulates assets already existing in the database /// accumulates assets already existing in the database
Future<void> _addAlbumFromServer( Future<void> _addAlbumFromServer(
AlbumResponseDto dto, Album album,
List<Asset> existing, List<Asset> existing,
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
) async { ) async {
if (dto.assetCount != dto.assets.length) { if (album.remoteAssetCount != album.remoteAssets.length) {
dto = await loadDetails(dto); album = await _albumApiRepository.get(album.remoteId!);
} }
if (dto.assetCount == dto.assets.length) { if (album.remoteAssetCount == album.remoteAssets.length) {
// in case an album contains assets not yet present in local DB: // in case an album contains assets not yet present in local DB:
// put missing album assets into local DB // put missing album assets into local DB
final (existingInDb, updated) = final (existingInDb, updated) =
await _linkWithExistingFromDb(dto.getAssets()); await _linkWithExistingFromDb(album.remoteAssets.toList());
existing.addAll(existingInDb); existing.addAll(existingInDb);
await upsertAssetsWithExif(updated); await upsertAssetsWithExif(updated);
final Album a = await Album.remote(dto); await _entityService.fillAlbumWithDatabaseEntities(album);
await _db.writeTxn(() => _db.albums.store(a)); await _db.writeTxn(() => _db.albums.store(album));
} else { } else {
_log.warning( _log.warning(
"Failed to add album from server: assetCount ${dto.assetCount} != " "Failed to add album from server: assetCount ${album.remoteAssetCount} != "
"asset array length ${dto.assets.length} for album ${dto.albumName}"); "asset array length ${album.remoteAssets.length} for album ${album.name}");
} }
} }
@ -492,7 +504,7 @@ class SyncService {
/// Syncs all device albums and their assets to the database /// Syncs all device albums and their assets to the database
/// Returns `true` if there were any changes /// Returns `true` if there were any changes
Future<bool> _syncLocalAlbumAssetsToDb( Future<bool> _syncLocalAlbumAssetsToDb(
List<AssetPathEntity> onDevice, [ List<Album> onDevice, [
Set<String>? excludedAssets, Set<String>? excludedAssets,
]) async { ]) async {
onDevice.sort((a, b) => a.id.compareTo(b.id)); onDevice.sort((a, b) => a.id.compareTo(b.id));
@ -504,16 +516,15 @@ class SyncService {
final bool anyChanges = await diffSortedLists( final bool anyChanges = await diffSortedLists(
onDevice, onDevice,
inDb, inDb,
compare: (AssetPathEntity a, Album b) => a.id.compareTo(b.localId!), compare: (Album a, Album b) => a.localId!.compareTo(b.localId!),
both: (AssetPathEntity ape, Album album) => _syncAlbumInDbAndOnDevice( both: (Album a, Album b) => _syncAlbumInDbAndOnDevice(
ape, a,
album, b,
deleteCandidates, deleteCandidates,
existing, existing,
excludedAssets, excludedAssets,
), ),
onlyFirst: (AssetPathEntity ape) => onlyFirst: (Album a) => _addAlbumFromDevice(a, existing, excludedAssets),
_addAlbumFromDevice(ape, existing, excludedAssets),
onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates), onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates),
); );
_log.fine( _log.fine(
@ -541,58 +552,65 @@ class SyncService {
/// returns `true` if there were any changes /// returns `true` if there were any changes
/// Accumulates asset candidates to delete and those already existing in DB /// Accumulates asset candidates to delete and those already existing in DB
Future<bool> _syncAlbumInDbAndOnDevice( Future<bool> _syncAlbumInDbAndOnDevice(
AssetPathEntity ape, Album deviceAlbum,
Album album, Album dbAlbum,
List<Asset> deleteCandidates, List<Asset> deleteCandidates,
List<Asset> existing, [ List<Asset> existing, [
Set<String>? excludedAssets, Set<String>? excludedAssets,
bool forceRefresh = false, bool forceRefresh = false,
]) async { ]) async {
if (!forceRefresh && !await _hasAssetPathEntityChanged(ape, album)) { if (!forceRefresh && !await _hasAlbumChangeOnDevice(deviceAlbum, dbAlbum)) {
_log.fine("Local album ${ape.name} has not changed. Skipping sync."); _log.fine(
"Local album ${deviceAlbum.name} has not changed. Skipping sync.",
);
return false; return false;
} }
if (!forceRefresh && if (!forceRefresh &&
excludedAssets == null && excludedAssets == null &&
await _syncDeviceAlbumFast(ape, album)) { 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 album.assets final inDb = await dbAlbum.assets
.filter() .filter()
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId) .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.sortByChecksum() .sortByChecksum()
.findAll(); .findAll();
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!"); assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
final int assetCountOnDevice = await ape.assetCountAsync; final int assetCountOnDevice =
final List<Asset> onDevice = await _albumMediaRepository.getAssetCount(deviceAlbum.localId!);
await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets); final List<Asset> onDevice = await _hashService.getHashedAssets(
deviceAlbum,
excludedAssets: excludedAssets,
);
_removeDuplicates(onDevice); _removeDuplicates(onDevice);
// _removeDuplicates sorts `onDevice` by checksum // _removeDuplicates sorts `onDevice` by checksum
final (toAdd, toUpdate, toDelete) = _diffAssets(onDevice, inDb); final (toAdd, toUpdate, toDelete) = _diffAssets(onDevice, inDb);
if (toAdd.isEmpty && if (toAdd.isEmpty &&
toUpdate.isEmpty && toUpdate.isEmpty &&
toDelete.isEmpty && toDelete.isEmpty &&
album.name == ape.name && dbAlbum.name == deviceAlbum.name &&
ape.lastModified != null && dbAlbum.modifiedAt.isAtSameMomentAs(deviceAlbum.modifiedAt)) {
album.modifiedAt.isAtSameMomentAs(ape.lastModified!)) {
// changes only affeted excluded albums // changes only affeted excluded albums
_log.fine( _log.fine(
"Only excluded assets in local album ${ape.name} changed. Stopping sync.", "Only excluded assets in local album ${deviceAlbum.name} changed. Stopping sync.",
); );
if (assetCountOnDevice != if (assetCountOnDevice !=
_db.eTags.getByIdSync(ape.eTagKeyAssetCount)?.assetCount) { _db.eTags.getByIdSync(deviceAlbum.eTagKeyAssetCount)?.assetCount) {
await _db.writeTxn( await _db.writeTxn(
() => _db.eTags.put( () => _db.eTags.put(
ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice), ETag(
id: deviceAlbum.eTagKeyAssetCount,
assetCount: assetCountOnDevice,
),
), ),
); );
} }
return false; return false;
} }
_log.fine( _log.fine(
"Syncing local album ${ape.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete", "Syncing local album ${deviceAlbum.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete",
); );
final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd); final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd);
_log.fine( _log.fine(
@ -600,28 +618,31 @@ class SyncService {
); );
deleteCandidates.addAll(toDelete); deleteCandidates.addAll(toDelete);
existing.addAll(existingInDb); existing.addAll(existingInDb);
album.name = ape.name; dbAlbum.name = deviceAlbum.name;
album.modifiedAt = ape.lastModified ?? DateTime.now(); dbAlbum.modifiedAt = deviceAlbum.modifiedAt;
if (album.thumbnail.value != null && if (dbAlbum.thumbnail.value != null &&
toDelete.contains(album.thumbnail.value)) { toDelete.contains(dbAlbum.thumbnail.value)) {
album.thumbnail.value = null; dbAlbum.thumbnail.value = null;
} }
try { try {
await _db.writeTxn(() async { await _db.writeTxn(() async {
await _db.assets.putAll(updated); await _db.assets.putAll(updated);
await _db.assets.putAll(toUpdate); await _db.assets.putAll(toUpdate);
await album.assets await dbAlbum.assets
.update(link: existingInDb + updated, unlink: toDelete); .update(link: existingInDb + updated, unlink: toDelete);
await _db.albums.put(album); await _db.albums.put(dbAlbum);
album.thumbnail.value ??= await album.assets.filter().findFirst(); dbAlbum.thumbnail.value ??= await dbAlbum.assets.filter().findFirst();
await album.thumbnail.save(); await dbAlbum.thumbnail.save();
await _db.eTags.put( await _db.eTags.put(
ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice), ETag(
id: deviceAlbum.eTagKeyAssetCount,
assetCount: assetCountOnDevice,
),
); );
}); });
_log.info("Synced changes of local album ${ape.name} to DB"); _log.info("Synced changes of local album ${deviceAlbum.name} to DB");
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe("Failed to update synced album ${ape.name} in DB", e); _log.severe("Failed to update synced album ${deviceAlbum.name} in DB", e);
} }
return true; return true;
@ -629,45 +650,45 @@ class SyncService {
/// fast path for common case: only new assets were added to device album /// fast path for common case: only new assets were added to device album
/// returns `true` if successfull, else `false` /// returns `true` if successfull, else `false`
Future<bool> _syncDeviceAlbumFast(AssetPathEntity ape, Album album) async { Future<bool> _syncDeviceAlbumFast(Album deviceAlbum, Album dbAlbum) async {
if (!(ape.lastModified ?? DateTime.now()).isAfter(album.modifiedAt)) { if (!deviceAlbum.modifiedAt.isAfter(dbAlbum.modifiedAt)) {
return false; return false;
} }
final int totalOnDevice = await ape.assetCountAsync; final int totalOnDevice =
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!);
final int lastKnownTotal = final int lastKnownTotal =
(await _db.eTags.getById(ape.eTagKeyAssetCount))?.assetCount ?? 0; (await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount))?.assetCount ??
final AssetPathEntity? modified = totalOnDevice > lastKnownTotal 0;
? await ape.fetchPathProperties( if (totalOnDevice <= lastKnownTotal) {
filterOptionGroup: FilterOptionGroup(
updateTimeCond: DateTimeCond(
min: album.modifiedAt.add(const Duration(seconds: 1)),
max: ape.lastModified ?? DateTime.now(),
),
),
)
: null;
if (modified == null) {
return false; return false;
} }
final List<Asset> newAssets = await _hashService.getHashedAssets(modified); final List<Asset> newAssets = await _hashService.getHashedAssets(
deviceAlbum,
modifiedFrom: dbAlbum.modifiedAt.add(const Duration(seconds: 1)),
modifiedUntil: deviceAlbum.modifiedAt,
);
if (totalOnDevice != lastKnownTotal + newAssets.length) { if (totalOnDevice != lastKnownTotal + newAssets.length) {
return false; return false;
} }
album.modifiedAt = ape.lastModified ?? DateTime.now(); dbAlbum.modifiedAt = deviceAlbum.modifiedAt;
_removeDuplicates(newAssets); _removeDuplicates(newAssets);
final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets); final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets);
try { try {
await _db.writeTxn(() async { await _db.writeTxn(() async {
await _db.assets.putAll(updated); await _db.assets.putAll(updated);
await album.assets.update(link: existingInDb + updated); await dbAlbum.assets.update(link: existingInDb + updated);
await _db.albums.put(album); await _db.albums.put(dbAlbum);
await _db.eTags await _db.eTags.put(
.put(ETag(id: ape.eTagKeyAssetCount, assetCount: totalOnDevice)); ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice),
);
}); });
_log.info("Fast synced local album ${ape.name} to DB"); _log.info("Fast synced local album ${deviceAlbum.name} to DB");
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe("Failed to fast sync local album ${ape.name} to DB", e); _log.severe(
"Failed to fast sync local album ${deviceAlbum.name} to DB",
e,
);
return false; return false;
} }
@ -677,14 +698,15 @@ class SyncService {
/// Adds a new album from the device to the database and Accumulates all /// Adds a new album from the device to the database and Accumulates all
/// assets already existing in the database to the list of `existing` assets /// assets already existing in the database to the list of `existing` assets
Future<void> _addAlbumFromDevice( Future<void> _addAlbumFromDevice(
AssetPathEntity ape, Album album,
List<Asset> existing, [ List<Asset> existing, [
Set<String>? excludedAssets, Set<String>? excludedAssets,
]) async { ]) async {
_log.info("Syncing a new local album to DB: ${ape.name}"); _log.info("Syncing a new local album to DB: ${album.name}");
final Album a = Album.local(ape); final assets = await _hashService.getHashedAssets(
final assets = album,
await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets); excludedAssets: excludedAssets,
);
_removeDuplicates(assets); _removeDuplicates(assets);
final (existingInDb, updated) = await _linkWithExistingFromDb(assets); final (existingInDb, updated) = await _linkWithExistingFromDb(assets);
_log.info( _log.info(
@ -692,15 +714,15 @@ class SyncService {
); );
await upsertAssetsWithExif(updated); await upsertAssetsWithExif(updated);
existing.addAll(existingInDb); existing.addAll(existingInDb);
a.assets.addAll(existingInDb); album.assets.addAll(existingInDb);
a.assets.addAll(updated); album.assets.addAll(updated);
final thumb = existingInDb.firstOrNull ?? updated.firstOrNull; final thumb = existingInDb.firstOrNull ?? updated.firstOrNull;
a.thumbnail.value = thumb; album.thumbnail.value = thumb;
try { try {
await _db.writeTxn(() => _db.albums.store(a)); await _db.writeTxn(() => _db.albums.store(album));
_log.info("Added a new local album to DB: ${ape.name}"); _log.info("Added a new local album to DB: ${album.name}");
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe("Failed to add new local album ${ape.name} to DB", e); _log.severe("Failed to add new local album ${album.name} to DB", e);
} }
} }
@ -798,12 +820,15 @@ class SyncService {
} }
/// returns `true` if the albums differ on the surface /// returns `true` if the albums differ on the surface
Future<bool> _hasAssetPathEntityChanged(AssetPathEntity a, Album b) async { Future<bool> _hasAlbumChangeOnDevice(
return a.name != b.name || Album deviceAlbum,
a.lastModified == null || Album dbAlbum,
!a.lastModified!.isAtSameMomentAs(b.modifiedAt) || ) async {
await a.assetCountAsync != return deviceAlbum.name != dbAlbum.name ||
(await _db.eTags.getById(a.eTagKeyAssetCount))?.assetCount; !deviceAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) ||
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!) !=
(await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount))
?.assetCount;
} }
Future<bool> _removeAllLocalAlbumsAndAssets() async { Future<bool> _removeAllLocalAlbumsAndAssets() async {
@ -900,17 +925,17 @@ class SyncService {
} }
/// returns `true` if the albums differ on the surface /// returns `true` if the albums differ on the surface
bool _hasAlbumResponseDtoChanged(AlbumResponseDto dto, Album a) { bool _hasRemoteAlbumChanged(Album remoteAlbum, Album dbAlbum) {
return dto.assetCount != a.assetCount || return remoteAlbum.remoteAssetCount != dbAlbum.assetCount ||
dto.albumName != a.name || remoteAlbum.name != dbAlbum.name ||
dto.albumThumbnailAssetId != a.thumbnail.value?.remoteId || remoteAlbum.remoteThumbnailAssetId != dbAlbum.thumbnail.value?.remoteId ||
dto.shared != a.shared || remoteAlbum.shared != dbAlbum.shared ||
dto.albumUsers.length != a.sharedUsers.length || remoteAlbum.remoteUsers.length != dbAlbum.sharedUsers.length ||
!dto.updatedAt.isAtSameMomentAs(a.modifiedAt) || !remoteAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) ||
!isAtSameMomentAs(dto.startDate, a.startDate) || !isAtSameMomentAs(remoteAlbum.startDate, dbAlbum.startDate) ||
!isAtSameMomentAs(dto.endDate, a.endDate) || !isAtSameMomentAs(remoteAlbum.endDate, dbAlbum.endDate) ||
!isAtSameMomentAs( !isAtSameMomentAs(
dto.lastModifiedAssetTimestamp, remoteAlbum.lastModifiedAssetTimestamp,
a.lastModifiedAssetTimestamp, dbAlbum.lastModifiedAssetTimestamp,
); );
} }

View File

@ -12,6 +12,29 @@ dynamic upgradeDto(dynamic value, String targetType) {
addDefault(value, 'tags', TagsResponse().toJson()); addDefault(value, 'tags', TagsResponse().toJson());
} }
break; break;
case 'ServerConfigDto':
if (value is Map) {
addDefault(
value,
'mapLightStyleUrl',
'https://tiles.immich.cloud/v1/style/light.json',
);
addDefault(
value,
'mapDarkStyleUrl',
'https://tiles.immich.cloud/v1/style/dark.json',
);
}
case 'UserResponseDto':
if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
}
break;
case 'UserAdminResponseDto':
if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
}
break;
} }
} }

View File

@ -8,7 +8,7 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/asset_description.service.dart'; import 'package:immich_mobile/services/asset.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@ -29,14 +29,16 @@ class DescriptionInput extends HookConsumerWidget {
final focusNode = useFocusNode(); final focusNode = useFocusNode();
final isFocus = useState(false); final isFocus = useState(false);
final isTextEmpty = useState(controller.text.isEmpty); final isTextEmpty = useState(controller.text.isEmpty);
final descriptionProvider = ref.watch(assetDescriptionServiceProvider); final assetService = ref.watch(assetServiceProvider);
final owner = ref.watch(currentUserProvider); final owner = ref.watch(currentUserProvider);
final hasError = useState(false); final hasError = useState(false);
final assetWithExif = ref.watch(assetDetailProvider(asset)); final assetWithExif = ref.watch(assetDetailProvider(asset));
useEffect( useEffect(
() { () {
controller.text = descriptionProvider.getAssetDescription(asset); assetService
.getDescription(asset)
.then((value) => controller.text = value);
return null; return null;
}, },
[assetWithExif.value], [assetWithExif.value],
@ -45,7 +47,7 @@ class DescriptionInput extends HookConsumerWidget {
submitDescription(String description) async { submitDescription(String description) async {
hasError.value = false; hasError.value = false;
try { try {
await descriptionProvider.setDescription( await assetService.setDescription(
asset, asset,
description, description,
); );

View File

@ -183,23 +183,13 @@ class AlbumInfoCard extends HookConsumerWidget {
), ),
Padding( Padding(
padding: const EdgeInsets.only(top: 2.0), padding: const EdgeInsets.only(top: 2.0),
child: FutureBuilder( child: Text(
builder: ((context, snapshot) { album.assetCount.toString() +
if (snapshot.hasData) { (album.isAll ? " (${'backup_all'.tr()})" : ""),
return Text(
snapshot.data.toString() +
(album.isAll
? " (${'backup_all'.tr()})"
: ""),
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.grey[600], color: Colors.grey[600],
), ),
);
}
return const Text("0");
}),
future: album.assetCount,
), ),
), ),
], ],
@ -208,7 +198,7 @@ class AlbumInfoCard extends HookConsumerWidget {
IconButton( IconButton(
onPressed: () { onPressed: () {
context.pushRoute( context.pushRoute(
AlbumPreviewRoute(album: album.albumEntity), AlbumPreviewRoute(album: album.album),
); );
}, },
icon: Icon( icon: Icon(

View File

@ -1,6 +1,5 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
@ -24,19 +23,10 @@ class AlbumInfoListTile extends HookConsumerWidget {
ref.watch(backupProvider).selectedBackupAlbums.contains(album); ref.watch(backupProvider).selectedBackupAlbums.contains(album);
final bool isExcluded = final bool isExcluded =
ref.watch(backupProvider).excludedBackupAlbums.contains(album); ref.watch(backupProvider).excludedBackupAlbums.contains(album);
final assetCount = useState(0);
final syncAlbum = ref final syncAlbum = ref
.watch(appSettingsServiceProvider) .watch(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.syncAlbums); .getSetting(AppSettingsEnum.syncAlbums);
useEffect(
() {
album.assetCount.then((value) => assetCount.value = value);
return null;
},
[album],
);
buildTileColor() { buildTileColor() {
if (isSelected) { if (isSelected) {
return context.isDarkTheme return context.isDarkTheme
@ -117,11 +107,11 @@ class AlbumInfoListTile extends HookConsumerWidget {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
subtitle: Text(assetCount.value.toString()), subtitle: Text(album.assetCount.toString()),
trailing: IconButton( trailing: IconButton(
onPressed: () { onPressed: () {
context.pushRoute( context.pushRoute(
AlbumPreviewRoute(album: album.albumEntity), AlbumPreviewRoute(album: album.album),
); );
}, },
icon: Icon( icon: Icon(

View File

@ -2,18 +2,19 @@ import 'dart:io';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.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/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.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/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
class CurrentUploadingAssetInfoBox extends HookConsumerWidget { class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
const CurrentUploadingAssetInfoBox({super.key}); const CurrentUploadingAssetInfoBox({super.key});
@ -148,17 +149,6 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
); );
} }
buildAssetThumbnail() async {
var assetEntity = await AssetEntity.fromId(asset.id);
if (assetEntity != null) {
return assetEntity.thumbnailDataWithSize(
const ThumbnailSize(500, 500),
quality: 100,
);
}
}
buildiCloudDownloadProgerssBar() { buildiCloudDownloadProgerssBar() {
if (asset.iCloudAsset != null && asset.iCloudAsset!) { if (asset.iCloudAsset != null && asset.iCloudAsset!) {
return Padding( return Padding(
@ -239,8 +229,8 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
); );
} }
return FutureBuilder<Uint8List?>( return FutureBuilder<Asset?>(
future: buildAssetThumbnail(), future: ref.read(assetMediaRepositoryProvider).get(asset.id),
builder: (context, thumbnail) => ListTile( builder: (context, thumbnail) => ListTile(
isThreeLine: true, isThreeLine: true,
leading: AnimatedCrossFade( leading: AnimatedCrossFade(
@ -250,9 +240,8 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
child: thumbnail.hasData child: thumbnail.hasData
? ClipRRect( ? ClipRRect(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
child: Image.memory( child: ImmichThumbnail(
thumbnail.data!, asset: thumbnail.data,
fit: BoxFit.cover,
width: 50, width: 50,
height: 50, height: 50,
), ),

View File

@ -116,6 +116,7 @@ Class | Method | HTTP request | Description
*AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up |
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
*DeprecatedApi* | [**getPersonAssets**](doc//DeprecatedApi.md#getpersonassets) | **GET** /people/{id}/assets | *DeprecatedApi* | [**getPersonAssets**](doc//DeprecatedApi.md#getpersonassets) | **GET** /people/{id}/assets |
*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random |
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive |
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info |
*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | *DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates |
@ -137,7 +138,6 @@ Class | Method | HTTP request | Description
*LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | *LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} |
*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | *LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate |
*MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | *MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers |
*MapApi* | [**getMapStyle**](doc//MapApi.md#getmapstyle) | **GET** /map/style.json |
*MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode | *MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode |
*MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | *MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets |
*MemoriesApi* | [**createMemory**](doc//MemoriesApi.md#creatememory) | **POST** /memories | *MemoriesApi* | [**createMemory**](doc//MemoriesApi.md#creatememory) | **POST** /memories |
@ -172,6 +172,7 @@ Class | Method | HTTP request | Description
*SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata | *SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata |
*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person | *SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person |
*SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places | *SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places |
*SearchApi* | [**searchRandom**](doc//SearchApi.md#searchrandom) | **POST** /search/random |
*SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart | *SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart |
*ServerApi* | [**deleteServerLicense**](doc//ServerApi.md#deleteserverlicense) | **DELETE** /server/license | *ServerApi* | [**deleteServerLicense**](doc//ServerApi.md#deleteserverlicense) | **DELETE** /server/license |
*ServerApi* | [**getAboutInfo**](doc//ServerApi.md#getaboutinfo) | **GET** /server/about | *ServerApi* | [**getAboutInfo**](doc//ServerApi.md#getaboutinfo) | **GET** /server/about |
@ -346,7 +347,6 @@ Class | Method | HTTP request | Description
- [ManualJobName](doc//ManualJobName.md) - [ManualJobName](doc//ManualJobName.md)
- [MapMarkerResponseDto](doc//MapMarkerResponseDto.md) - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
- [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md) - [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md)
- [MapTheme](doc//MapTheme.md)
- [MemoriesResponse](doc//MemoriesResponse.md) - [MemoriesResponse](doc//MemoriesResponse.md)
- [MemoriesUpdate](doc//MemoriesUpdate.md) - [MemoriesUpdate](doc//MemoriesUpdate.md)
- [MemoryCreateDto](doc//MemoryCreateDto.md) - [MemoryCreateDto](doc//MemoryCreateDto.md)
@ -379,6 +379,7 @@ Class | Method | HTTP request | Description
- [PurchaseResponse](doc//PurchaseResponse.md) - [PurchaseResponse](doc//PurchaseResponse.md)
- [PurchaseUpdate](doc//PurchaseUpdate.md) - [PurchaseUpdate](doc//PurchaseUpdate.md)
- [QueueStatusDto](doc//QueueStatusDto.md) - [QueueStatusDto](doc//QueueStatusDto.md)
- [RandomSearchDto](doc//RandomSearchDto.md)
- [RatingsResponse](doc//RatingsResponse.md) - [RatingsResponse](doc//RatingsResponse.md)
- [RatingsUpdate](doc//RatingsUpdate.md) - [RatingsUpdate](doc//RatingsUpdate.md)
- [ReactionLevel](doc//ReactionLevel.md) - [ReactionLevel](doc//ReactionLevel.md)
@ -452,6 +453,7 @@ Class | Method | HTTP request | Description
- [ToneMapping](doc//ToneMapping.md) - [ToneMapping](doc//ToneMapping.md)
- [TranscodeHWAccel](doc//TranscodeHWAccel.md) - [TranscodeHWAccel](doc//TranscodeHWAccel.md)
- [TranscodePolicy](doc//TranscodePolicy.md) - [TranscodePolicy](doc//TranscodePolicy.md)
- [TrashResponseDto](doc//TrashResponseDto.md)
- [UpdateAlbumDto](doc//UpdateAlbumDto.md) - [UpdateAlbumDto](doc//UpdateAlbumDto.md)
- [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md) - [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md)
- [UpdateAssetDto](doc//UpdateAssetDto.md) - [UpdateAssetDto](doc//UpdateAssetDto.md)

View File

@ -159,7 +159,6 @@ part 'model/logout_response_dto.dart';
part 'model/manual_job_name.dart'; part 'model/manual_job_name.dart';
part 'model/map_marker_response_dto.dart'; part 'model/map_marker_response_dto.dart';
part 'model/map_reverse_geocode_response_dto.dart'; part 'model/map_reverse_geocode_response_dto.dart';
part 'model/map_theme.dart';
part 'model/memories_response.dart'; part 'model/memories_response.dart';
part 'model/memories_update.dart'; part 'model/memories_update.dart';
part 'model/memory_create_dto.dart'; part 'model/memory_create_dto.dart';
@ -192,6 +191,7 @@ part 'model/places_response_dto.dart';
part 'model/purchase_response.dart'; part 'model/purchase_response.dart';
part 'model/purchase_update.dart'; part 'model/purchase_update.dart';
part 'model/queue_status_dto.dart'; part 'model/queue_status_dto.dart';
part 'model/random_search_dto.dart';
part 'model/ratings_response.dart'; part 'model/ratings_response.dart';
part 'model/ratings_update.dart'; part 'model/ratings_update.dart';
part 'model/reaction_level.dart'; part 'model/reaction_level.dart';
@ -265,6 +265,7 @@ part 'model/time_bucket_size.dart';
part 'model/tone_mapping.dart'; part 'model/tone_mapping.dart';
part 'model/transcode_hw_accel.dart'; part 'model/transcode_hw_accel.dart';
part 'model/transcode_policy.dart'; part 'model/transcode_policy.dart';
part 'model/trash_response_dto.dart';
part 'model/update_album_dto.dart'; part 'model/update_album_dto.dart';
part 'model/update_album_user_dto.dart'; part 'model/update_album_user_dto.dart';
part 'model/update_asset_dto.dart'; part 'model/update_asset_dto.dart';

View File

@ -449,7 +449,10 @@ class AssetsApi {
return null; return null;
} }
/// Performs an HTTP 'GET /assets/random' operation and returns the [Response]. /// This property was deprecated in v1.116.0
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters: /// Parameters:
/// ///
/// * [num] count: /// * [num] count:
@ -482,6 +485,8 @@ class AssetsApi {
); );
} }
/// This property was deprecated in v1.116.0
///
/// Parameters: /// Parameters:
/// ///
/// * [num] count: /// * [num] count:

View File

@ -71,4 +71,63 @@ class DeprecatedApi {
} }
return null; return null;
} }
/// This property was deprecated in v1.116.0
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [num] count:
Future<Response> getRandomWithHttpInfo({ num? count, }) async {
// ignore: prefer_const_declarations
final path = r'/assets/random';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (count != null) {
queryParams.addAll(_queryParams('', 'count', count));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// This property was deprecated in v1.116.0
///
/// Parameters:
///
/// * [num] count:
Future<List<AssetResponseDto>?> getRandom({ num? count, }) async {
final response = await getRandomWithHttpInfo( count: count, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<AssetResponseDto>') as List)
.cast<AssetResponseDto>()
.toList(growable: false);
}
return null;
}
} }

View File

@ -105,62 +105,6 @@ class MapApi {
return null; return null;
} }
/// Performs an HTTP 'GET /map/style.json' operation and returns the [Response].
/// Parameters:
///
/// * [MapTheme] theme (required):
///
/// * [String] key:
Future<Response> getMapStyleWithHttpInfo(MapTheme theme, { String? key, }) async {
// ignore: prefer_const_declarations
final path = r'/map/style.json';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
queryParams.addAll(_queryParams('', 'theme', theme));
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [MapTheme] theme (required):
///
/// * [String] key:
Future<Object?> getMapStyle(MapTheme theme, { String? key, }) async {
final response = await getMapStyleWithHttpInfo(theme, key: key, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'Object',) as Object;
}
return null;
}
/// Performs an HTTP 'GET /map/reverse-geocode' operation and returns the [Response]. /// Performs an HTTP 'GET /map/reverse-geocode' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///

View File

@ -351,6 +351,53 @@ class SearchApi {
return null; return null;
} }
/// Performs an HTTP 'POST /search/random' operation and returns the [Response].
/// Parameters:
///
/// * [RandomSearchDto] randomSearchDto (required):
Future<Response> searchRandomWithHttpInfo(RandomSearchDto randomSearchDto,) async {
// ignore: prefer_const_declarations
final path = r'/search/random';
// ignore: prefer_final_locals
Object? postBody = randomSearchDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [RandomSearchDto] randomSearchDto (required):
Future<SearchResponseDto?> searchRandom(RandomSearchDto randomSearchDto,) async {
final response = await searchRandomWithHttpInfo(randomSearchDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SearchResponseDto',) as SearchResponseDto;
}
return null;
}
/// Performs an HTTP 'POST /search/smart' operation and returns the [Response]. /// Performs an HTTP 'POST /search/smart' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///

View File

@ -42,11 +42,19 @@ class TrashApi {
); );
} }
Future<void> emptyTrash() async { Future<TrashResponseDto?> emptyTrash() async {
final response = await emptyTrashWithHttpInfo(); final response = await emptyTrashWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TrashResponseDto',) as TrashResponseDto;
}
return null;
} }
/// Performs an HTTP 'POST /trash/restore/assets' operation and returns the [Response]. /// Performs an HTTP 'POST /trash/restore/assets' operation and returns the [Response].
@ -81,11 +89,19 @@ class TrashApi {
/// Parameters: /// Parameters:
/// ///
/// * [BulkIdsDto] bulkIdsDto (required): /// * [BulkIdsDto] bulkIdsDto (required):
Future<void> restoreAssets(BulkIdsDto bulkIdsDto,) async { Future<TrashResponseDto?> restoreAssets(BulkIdsDto bulkIdsDto,) async {
final response = await restoreAssetsWithHttpInfo(bulkIdsDto,); final response = await restoreAssetsWithHttpInfo(bulkIdsDto,);
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TrashResponseDto',) as TrashResponseDto;
}
return null;
} }
/// Performs an HTTP 'POST /trash/restore' operation and returns the [Response]. /// Performs an HTTP 'POST /trash/restore' operation and returns the [Response].
@ -114,10 +130,18 @@ class TrashApi {
); );
} }
Future<void> restoreTrash() async { Future<TrashResponseDto?> restoreTrash() async {
final response = await restoreTrashWithHttpInfo(); final response = await restoreTrashWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TrashResponseDto',) as TrashResponseDto;
}
return null;
} }
} }

View File

@ -166,7 +166,6 @@ class ApiClient {
/// Returns a native instance of an OpenAPI class matching the [specified type][targetType]. /// Returns a native instance of an OpenAPI class matching the [specified type][targetType].
static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) { static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) {
upgradeDto(value, targetType);
try { try {
switch (targetType) { switch (targetType) {
case 'String': case 'String':
@ -373,8 +372,6 @@ class ApiClient {
return MapMarkerResponseDto.fromJson(value); return MapMarkerResponseDto.fromJson(value);
case 'MapReverseGeocodeResponseDto': case 'MapReverseGeocodeResponseDto':
return MapReverseGeocodeResponseDto.fromJson(value); return MapReverseGeocodeResponseDto.fromJson(value);
case 'MapTheme':
return MapThemeTypeTransformer().decode(value);
case 'MemoriesResponse': case 'MemoriesResponse':
return MemoriesResponse.fromJson(value); return MemoriesResponse.fromJson(value);
case 'MemoriesUpdate': case 'MemoriesUpdate':
@ -439,6 +436,8 @@ class ApiClient {
return PurchaseUpdate.fromJson(value); return PurchaseUpdate.fromJson(value);
case 'QueueStatusDto': case 'QueueStatusDto':
return QueueStatusDto.fromJson(value); return QueueStatusDto.fromJson(value);
case 'RandomSearchDto':
return RandomSearchDto.fromJson(value);
case 'RatingsResponse': case 'RatingsResponse':
return RatingsResponse.fromJson(value); return RatingsResponse.fromJson(value);
case 'RatingsUpdate': case 'RatingsUpdate':
@ -585,6 +584,8 @@ class ApiClient {
return TranscodeHWAccelTypeTransformer().decode(value); return TranscodeHWAccelTypeTransformer().decode(value);
case 'TranscodePolicy': case 'TranscodePolicy':
return TranscodePolicyTypeTransformer().decode(value); return TranscodePolicyTypeTransformer().decode(value);
case 'TrashResponseDto':
return TrashResponseDto.fromJson(value);
case 'UpdateAlbumDto': case 'UpdateAlbumDto':
return UpdateAlbumDto.fromJson(value); return UpdateAlbumDto.fromJson(value);
case 'UpdateAlbumUserDto': case 'UpdateAlbumUserDto':

View File

@ -100,9 +100,6 @@ String parameterToString(dynamic value) {
if (value is ManualJobName) { if (value is ManualJobName) {
return ManualJobNameTypeTransformer().encode(value).toString(); return ManualJobNameTypeTransformer().encode(value).toString();
} }
if (value is MapTheme) {
return MapThemeTypeTransformer().encode(value).toString();
}
if (value is MemoryType) { if (value is MemoryType) {
return MemoryTypeTypeTransformer().encode(value).toString(); return MemoryTypeTypeTransformer().encode(value).toString();
} }

View File

@ -78,6 +78,7 @@ class ActivityCreateDto {
/// [value] if it's a [Map], null otherwise. /// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods // ignore: prefer_constructors_over_static_methods
static ActivityCreateDto? fromJson(dynamic value) { static ActivityCreateDto? fromJson(dynamic value) {
upgradeDto(value, "ActivityCreateDto");
if (value is Map) { if (value is Map) {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();

View File

@ -78,6 +78,7 @@ class ActivityResponseDto {
/// [value] if it's a [Map], null otherwise. /// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods // ignore: prefer_constructors_over_static_methods
static ActivityResponseDto? fromJson(dynamic value) { static ActivityResponseDto? fromJson(dynamic value) {
upgradeDto(value, "ActivityResponseDto");
if (value is Map) { if (value is Map) {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();

View File

@ -40,6 +40,7 @@ class ActivityStatisticsResponseDto {
/// [value] if it's a [Map], null otherwise. /// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods // ignore: prefer_constructors_over_static_methods
static ActivityStatisticsResponseDto? fromJson(dynamic value) { static ActivityStatisticsResponseDto? fromJson(dynamic value) {
upgradeDto(value, "ActivityStatisticsResponseDto");
if (value is Map) { if (value is Map) {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();

View File

@ -40,6 +40,7 @@ class AddUsersDto {
/// [value] if it's a [Map], null otherwise. /// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods // ignore: prefer_constructors_over_static_methods
static AddUsersDto? fromJson(dynamic value) { static AddUsersDto? fromJson(dynamic value) {
upgradeDto(value, "AddUsersDto");
if (value is Map) { if (value is Map) {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();

View File

@ -40,6 +40,7 @@ class AdminOnboardingUpdateDto {
/// [value] if it's a [Map], null otherwise. /// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods // ignore: prefer_constructors_over_static_methods
static AdminOnboardingUpdateDto? fromJson(dynamic value) { static AdminOnboardingUpdateDto? fromJson(dynamic value) {
upgradeDto(value, "AdminOnboardingUpdateDto");
if (value is Map) { if (value is Map) {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();

View File

@ -186,6 +186,7 @@ class AlbumResponseDto {
/// [value] if it's a [Map], null otherwise. /// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods // ignore: prefer_constructors_over_static_methods
static AlbumResponseDto? fromJson(dynamic value) { static AlbumResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AlbumResponseDto");
if (value is Map) { if (value is Map) {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();

View File

@ -52,6 +52,7 @@ class AlbumStatisticsResponseDto {
/// [value] if it's a [Map], null otherwise. /// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods // ignore: prefer_constructors_over_static_methods
static AlbumStatisticsResponseDto? fromJson(dynamic value) { static AlbumStatisticsResponseDto? fromJson(dynamic value) {
upgradeDto(value, "AlbumStatisticsResponseDto");
if (value is Map) { if (value is Map) {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();

Some files were not shown because too many files have changed in this diff Show More