Compare commits
46 Commits
v1.139.2
...
fix/map-th
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b0e18949 | ||
|
|
76eaee3657 | ||
|
|
d5fec0edab | ||
|
|
a7821a0b79 | ||
|
|
73e67ebfea | ||
|
|
0eaa054218 | ||
|
|
2024d06cb7 | ||
|
|
204299d500 | ||
|
|
70e59c00d5 | ||
|
|
5405810a38 | ||
|
|
e67265cef2 | ||
|
|
19c53609e1 | ||
|
|
0d0bb0e2d9 | ||
|
|
8f1b505ba0 | ||
|
|
d04675fb41 | ||
|
|
acfd40b77a | ||
|
|
840e43430c | ||
|
|
a3e0c6cef5 | ||
|
|
63088b22e0 | ||
|
|
d9d8beb92f | ||
|
|
38a8a67be9 | ||
|
|
7531ffcbfb | ||
|
|
d5f3629c49 | ||
|
|
be5b4cb1d1 | ||
|
|
5fb8d651ec | ||
|
|
c2313f7a99 | ||
|
|
59627e2b4c | ||
|
|
530bf059ad | ||
|
|
b44d2a241d | ||
|
|
1af10ded74 | ||
|
|
3f1e11afcc | ||
|
|
28dce2d0df | ||
|
|
605764f226 | ||
|
|
44e1c83c84 | ||
|
|
0729887c9c | ||
|
|
3bfa8b7575 | ||
|
|
3138048b96 | ||
|
|
f8b41ea8aa | ||
|
|
1d33ed6bed | ||
|
|
2be1a58c5b | ||
|
|
03e7922589 | ||
|
|
801af34d9a | ||
|
|
bedaa729e9 | ||
|
|
13c8a6e61d | ||
|
|
01edf6533b | ||
|
|
30d0bea4df |
@@ -5,7 +5,8 @@
|
|||||||
"immich-server",
|
"immich-server",
|
||||||
"redis",
|
"redis",
|
||||||
"database",
|
"database",
|
||||||
"immich-machine-learning"
|
"immich-machine-learning",
|
||||||
|
"init"
|
||||||
],
|
],
|
||||||
"dockerComposeFile": [
|
"dockerComposeFile": [
|
||||||
"../docker/docker-compose.dev.yml",
|
"../docker/docker-compose.dev.yml",
|
||||||
|
|||||||
@@ -11,8 +11,22 @@ services:
|
|||||||
- ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
|
- ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
|
||||||
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload
|
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
- pnpm-store:/usr/src/app/.pnpm-store
|
||||||
|
- server-node_modules:/usr/src/app/server/node_modules
|
||||||
|
- web-node_modules:/usr/src/app/web/node_modules
|
||||||
|
- github-node_modules:/usr/src/app/.github/node_modules
|
||||||
|
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||||
|
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||||
|
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||||
|
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||||
|
- app-node_modules:/usr/src/app/node_modules
|
||||||
|
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||||
|
- coverage:/usr/src/app/web/coverage
|
||||||
immich-web:
|
immich-web:
|
||||||
env_file: !reset []
|
env_file: !reset []
|
||||||
|
init:
|
||||||
|
env_file: !reset []
|
||||||
|
command: sh -c 'for path in /data /data/upload /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done'
|
||||||
immich-machine-learning:
|
immich-machine-learning:
|
||||||
env_file: !reset []
|
env_file: !reset []
|
||||||
database:
|
database:
|
||||||
|
|||||||
@@ -4,34 +4,13 @@ module.exports = {
|
|||||||
if (!pkg.name) {
|
if (!pkg.name) {
|
||||||
return pkg;
|
return pkg;
|
||||||
}
|
}
|
||||||
switch (pkg.name) {
|
if (pkg.name === "exiftool-vendored") {
|
||||||
case "exiftool-vendored":
|
if (pkg.optionalDependencies["exiftool-vendored.pl"]) {
|
||||||
if (pkg.optionalDependencies["exiftool-vendored.pl"]) {
|
// make exiftool-vendored.pl a regular dependency
|
||||||
// make exiftool-vendored.pl a regular dependency
|
pkg.dependencies["exiftool-vendored.pl"] =
|
||||||
pkg.dependencies["exiftool-vendored.pl"] =
|
pkg.optionalDependencies["exiftool-vendored.pl"];
|
||||||
pkg.optionalDependencies["exiftool-vendored.pl"];
|
delete pkg.optionalDependencies["exiftool-vendored.pl"];
|
||||||
delete pkg.optionalDependencies["exiftool-vendored.pl"];
|
}
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "sharp":
|
|
||||||
const optionalDeps = Object.keys(pkg.optionalDependencies).filter(
|
|
||||||
(dep) => dep.startsWith("@img")
|
|
||||||
);
|
|
||||||
for (const dep of optionalDeps) {
|
|
||||||
// remove all optionalDependencies from sharp (they will be compiled from source), except:
|
|
||||||
// include the precompiled musl version of sharp, for web
|
|
||||||
// include precompiled linux-x64 version of sharp, for server (stage: web-prod)
|
|
||||||
// include precompiled linux-arm64 version of sharp, for server (stage: web-prod)
|
|
||||||
if (
|
|
||||||
dep.includes("musl") ||
|
|
||||||
dep.includes("linux-x64") ||
|
|
||||||
dep.includes("linux-arm64")
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
delete pkg.optionalDependencies[dep];
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
return pkg;
|
return pkg;
|
||||||
},
|
},
|
||||||
|
|||||||
46
Makefile
46
Makefile
@@ -1,23 +1,23 @@
|
|||||||
dev:
|
dev: prepare-volumes
|
||||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
|
||||||
|
|
||||||
dev-down:
|
dev-down:
|
||||||
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
|
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
|
||||||
|
|
||||||
dev-update:
|
dev-update: prepare-volumes
|
||||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
|
||||||
|
|
||||||
dev-scale:
|
dev-scale: prepare-volumes
|
||||||
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
|
||||||
|
|
||||||
dev-docs:
|
dev-docs: prepare-volumes
|
||||||
npm --prefix docs run start
|
npm --prefix docs run start
|
||||||
|
|
||||||
.PHONY: e2e
|
.PHONY: e2e
|
||||||
e2e:
|
e2e: prepare-volumes
|
||||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
|
||||||
|
|
||||||
e2e-update:
|
e2e-update: prepare-volumes
|
||||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
||||||
|
|
||||||
e2e-down:
|
e2e-down:
|
||||||
@@ -33,16 +33,16 @@ prod-scale:
|
|||||||
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
|
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
|
||||||
|
|
||||||
.PHONY: open-api
|
.PHONY: open-api
|
||||||
open-api:
|
open-api: prepare-volumes
|
||||||
cd ./open-api && bash ./bin/generate-open-api.sh
|
cd ./open-api && bash ./bin/generate-open-api.sh
|
||||||
|
|
||||||
open-api-dart:
|
open-api-dart: prepare-volumes
|
||||||
cd ./open-api && bash ./bin/generate-open-api.sh dart
|
cd ./open-api && bash ./bin/generate-open-api.sh dart
|
||||||
|
|
||||||
open-api-typescript:
|
open-api-typescript: prepare-volumes
|
||||||
cd ./open-api && bash ./bin/generate-open-api.sh typescript
|
cd ./open-api && bash ./bin/generate-open-api.sh typescript
|
||||||
|
|
||||||
sql:
|
sql: prepare-volumes
|
||||||
pnpm --filter immich run sync:sql
|
pnpm --filter immich run sync:sql
|
||||||
|
|
||||||
attach-server:
|
attach-server:
|
||||||
@@ -51,6 +51,30 @@ attach-server:
|
|||||||
renovate:
|
renovate:
|
||||||
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
|
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
|
||||||
|
|
||||||
|
# Directories that need to be created for volumes or build output
|
||||||
|
VOLUME_DIRS = \
|
||||||
|
./.pnpm-store \
|
||||||
|
./web/.svelte-kit \
|
||||||
|
./web/node_modules \
|
||||||
|
./web/coverage \
|
||||||
|
./e2e/node_modules \
|
||||||
|
./docs/node_modules \
|
||||||
|
./server/node_modules \
|
||||||
|
./server/dist \
|
||||||
|
./open-api/typescript-sdk/node_modules \
|
||||||
|
./.github/node_modules \
|
||||||
|
./node_modules \
|
||||||
|
./cli/node_modules
|
||||||
|
|
||||||
|
# create empty directories and chown to current user
|
||||||
|
prepare-volumes:
|
||||||
|
@for dir in $(VOLUME_DIRS); do \
|
||||||
|
mkdir -p $$dir; \
|
||||||
|
done
|
||||||
|
@if [ -n "$(VOLUME_DIRS)" ]; then \
|
||||||
|
chown -R $$(id -u):$$(id -g) $(VOLUME_DIRS); \
|
||||||
|
fi
|
||||||
|
|
||||||
MODULES = e2e server web cli sdk docs .github
|
MODULES = e2e server web cli sdk docs .github
|
||||||
|
|
||||||
# directory to package name mapping function
|
# directory to package name mapping function
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.82",
|
"version": "2.2.84",
|
||||||
"description": "Command Line Interface (CLI) for Immich",
|
"description": "Command Line Interface (CLI) for Immich",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./dist/index.js",
|
"exports": "./dist/index.js",
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ services:
|
|||||||
- ${UPLOAD_LOCATION}/photos:/data
|
- ${UPLOAD_LOCATION}/photos:/data
|
||||||
- ${UPLOAD_LOCATION}/photos/upload:/data/upload
|
- ${UPLOAD_LOCATION}/photos/upload:/data/upload
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
- pnpm-store:/usr/src/app/.pnpm-store
|
||||||
|
- server-node_modules:/usr/src/app/server/node_modules
|
||||||
|
- web-node_modules:/usr/src/app/web/node_modules
|
||||||
|
- github-node_modules:/usr/src/app/.github/node_modules
|
||||||
|
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||||
|
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||||
|
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||||
|
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||||
|
- app-node_modules:/usr/src/app/node_modules
|
||||||
|
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||||
|
- coverage:/usr/src/app/web/coverage
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
@@ -84,6 +95,17 @@ services:
|
|||||||
- 24678:24678
|
- 24678:24678
|
||||||
volumes:
|
volumes:
|
||||||
- ..:/usr/src/app
|
- ..:/usr/src/app
|
||||||
|
- pnpm-store:/usr/src/app/.pnpm-store
|
||||||
|
- server-node_modules:/usr/src/app/server/node_modules
|
||||||
|
- web-node_modules:/usr/src/app/web/node_modules
|
||||||
|
- github-node_modules:/usr/src/app/.github/node_modules
|
||||||
|
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||||
|
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||||
|
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||||
|
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||||
|
- app-node_modules:/usr/src/app/node_modules
|
||||||
|
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||||
|
- coverage:/usr/src/app/web/coverage
|
||||||
ulimits:
|
ulimits:
|
||||||
nofile:
|
nofile:
|
||||||
soft: 1048576
|
soft: 1048576
|
||||||
@@ -167,9 +189,31 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
user: 0:0
|
user: 0:0
|
||||||
command: sh -c 'for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done'
|
command: sh -c 'for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done'
|
||||||
|
volumes:
|
||||||
|
- pnpm-store:/usr/src/app/.pnpm-store
|
||||||
|
- server-node_modules:/usr/src/app/server/node_modules
|
||||||
|
- web-node_modules:/usr/src/app/web/node_modules
|
||||||
|
- github-node_modules:/usr/src/app/.github/node_modules
|
||||||
|
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||||
|
- docs-node_modules:/usr/src/app/docs/node_modules
|
||||||
|
- e2e-node_modules:/usr/src/app/e2e/node_modules
|
||||||
|
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
|
||||||
|
- app-node_modules:/usr/src/app/node_modules
|
||||||
|
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||||
|
- coverage:/usr/src/app/web/coverage
|
||||||
volumes:
|
volumes:
|
||||||
model-cache:
|
model-cache:
|
||||||
prometheus-data:
|
prometheus-data:
|
||||||
grafana-data:
|
grafana-data:
|
||||||
|
pnpm-store:
|
||||||
|
server-node_modules:
|
||||||
|
web-node_modules:
|
||||||
|
github-node_modules:
|
||||||
|
cli-node_modules:
|
||||||
|
docs-node_modules:
|
||||||
|
e2e-node_modules:
|
||||||
|
sdk-node_modules:
|
||||||
|
app-node_modules:
|
||||||
|
sveltekit:
|
||||||
|
coverage:
|
||||||
|
|||||||
@@ -1,5 +1,31 @@
|
|||||||
# FAQ
|
# FAQ
|
||||||
|
|
||||||
|
## Commercial Guidelines
|
||||||
|
|
||||||
|
### Are you open to commercial partnerships and collaborations?
|
||||||
|
|
||||||
|
We are working to commercialize Immich and we'd love for you to help us by making Immich better. FUTO is dedicated to developing sustainable models for developing open source software for our customers. We want our customers to be delighted by the products our engineers deliver, and we want our engineers to be paid when they succeed.
|
||||||
|
|
||||||
|
If you wish to use Immich in a commercial product not owned by FUTO, we have the following requirements:
|
||||||
|
|
||||||
|
- Plugin Integrations: Integrations for other platforms are typically approved, provided proper notification is given.
|
||||||
|
|
||||||
|
- Reseller Partnerships: Must adhere to the guidelines outlined below regarding trademark usage, and proper representation.
|
||||||
|
|
||||||
|
- Strategic Collaborations: We welcome discussions about mutually beneficial partnerships that enhance the value proposition for both organizations.
|
||||||
|
|
||||||
|
### What are your guidelines for resellers and trademark usage?
|
||||||
|
|
||||||
|
For organizations seeking to resell Immich, we have established the following guidelines to protect our brand integrity and ensure proper representation.
|
||||||
|
|
||||||
|
- We request that resellers do not display our trademarks on their websites or marketing materials. If such usage is discovered, we will contact you to request removal.
|
||||||
|
|
||||||
|
- Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team.
|
||||||
|
|
||||||
|
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase licenses directy from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
|
||||||
|
|
||||||
|
When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app
|
||||||
|
|
||||||
## User
|
## User
|
||||||
|
|
||||||
### How can I reset the admin password?
|
### How can I reset the admin password?
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Unable to set `app.immich:///oauth-callback` as a valid redirect URI? See [Mobil
|
|||||||
|
|
||||||
Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including:
|
Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including:
|
||||||
|
|
||||||
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
|
- [Authentik](https://integrations.goauthentik.io/media/immich/)
|
||||||
- [Authelia](https://www.authelia.com/integration/openid-connect/immich/)
|
- [Authelia](https://www.authelia.com/integration/openid-connect/immich/)
|
||||||
- [Okta](https://www.okta.com/openid-connect/)
|
- [Okta](https://www.okta.com/openid-connect/)
|
||||||
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
|
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
|
||||||
@@ -88,7 +88,7 @@ The `.well-known/openid-configuration` part of the url is optional and will be a
|
|||||||
## Auto Launch
|
## Auto Launch
|
||||||
|
|
||||||
When Auto Launch is enabled, the login page will automatically redirect the user to the OAuth authorization url, to login with OAuth. To access the login screen again, use the browser's back button, or navigate directly to `/auth/login?autoLaunch=0`.
|
When Auto Launch is enabled, the login page will automatically redirect the user to the OAuth authorization url, to login with OAuth. To access the login screen again, use the browser's back button, or navigate directly to `/auth/login?autoLaunch=0`.
|
||||||
Auto Launch can also be enabled on a per-request basis by navigating to `/auth/login?authLaunch=1`, this can be useful in situations where Immich is called from e.g. Nextcloud using the _External sites_ app and the _oidc_ app so as to enable users to directly interact with a logged-in instance of Immich.
|
Auto Launch can also be enabled on a per-request basis by navigating to `/auth/login?autoLaunch=1`, this can be useful in situations where Immich is called from e.g. Nextcloud using the _External sites_ app and the _oidc_ app so as to enable users to directly interact with a logged-in instance of Immich.
|
||||||
|
|
||||||
## Mobile Redirect URI
|
## Mobile Redirect URI
|
||||||
|
|
||||||
|
|||||||
8
docs/static/archived-versions.json
vendored
8
docs/static/archived-versions.json
vendored
@@ -1,4 +1,12 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"label": "v1.139.4",
|
||||||
|
"url": "https://v1.139.4.archive.immich.app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "v1.139.3",
|
||||||
|
"url": "https://v1.139.3.archive.immich.app"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "v1.139.2",
|
"label": "v1.139.2",
|
||||||
"url": "https://v1.139.2.archive.immich.app"
|
"url": "https://v1.139.2.archive.immich.app"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ services:
|
|||||||
image: redis:6.2-alpine@sha256:7fe72c486b910f6b1a9769c937dad5d63648ddee82e056f47417542dd40825bb
|
image: redis:6.2-alpine@sha256:7fe72c486b910f6b1a9769c937dad5d63648ddee82e056f47417542dd40825bb
|
||||||
|
|
||||||
database:
|
database:
|
||||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:0e763a2383d56f90364fcd72767ac41400cd30d2627f407f7e7960c9f1923c21
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:7a4469b9484e37bf2630a60bc2f02f086dae898143b599ecc1c93f619849ef6b
|
||||||
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.139.2",
|
"version": "1.139.4",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -500,7 +500,7 @@
|
|||||||
"assets": "Assets",
|
"assets": "Assets",
|
||||||
"assets_added_count": "Added {count, plural, one {# asset} other {# assets}}",
|
"assets_added_count": "Added {count, plural, one {# asset} other {# assets}}",
|
||||||
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
|
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
|
||||||
"assets_added_to_albums_count": "Added {assetTotal, plural, one {# asset} other {# assets}} to {albumTotal} albums",
|
"assets_added_to_albums_count": "Added {assetTotal, plural, one {# asset} other {# assets}} to {albumTotal, plural, one {# album} other {# albums}}",
|
||||||
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
|
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
|
||||||
"assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums",
|
"assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums",
|
||||||
"assets_count": "{count, plural, one {# asset} other {# assets}}",
|
"assets_count": "{count, plural, one {# asset} other {# assets}}",
|
||||||
@@ -1941,7 +1941,9 @@
|
|||||||
"to_change_password": "Change password",
|
"to_change_password": "Change password",
|
||||||
"to_favorite": "Favorite",
|
"to_favorite": "Favorite",
|
||||||
"to_login": "Login",
|
"to_login": "Login",
|
||||||
|
"to_multi_select": "to multi-select",
|
||||||
"to_parent": "Go to parent",
|
"to_parent": "Go to parent",
|
||||||
|
"to_select": "to select",
|
||||||
"to_trash": "Trash",
|
"to_trash": "Trash",
|
||||||
"toggle_settings": "Toggle settings",
|
"toggle_settings": "Toggle settings",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ platform :android do
|
|||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 3007,
|
"android.injected.version.code" => 3009,
|
||||||
"android.injected.version.name" => "1.139.2",
|
"android.injected.version.name" => "1.139.4",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
);
|
||||||
path = Sync;
|
path = Sync;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -667,7 +669,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 215;
|
CURRENT_PROJECT_VERSION = 217;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
@@ -811,7 +813,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 215;
|
CURRENT_PROJECT_VERSION = 217;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
@@ -841,7 +843,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 215;
|
CURRENT_PROJECT_VERSION = 217;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
@@ -875,7 +877,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 215;
|
CURRENT_PROJECT_VERSION = 217;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
@@ -918,7 +920,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 215;
|
CURRENT_PROJECT_VERSION = 217;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
@@ -958,7 +960,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 215;
|
CURRENT_PROJECT_VERSION = 217;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
@@ -997,7 +999,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 215;
|
CURRENT_PROJECT_VERSION = 217;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -1041,7 +1043,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 215;
|
CURRENT_PROJECT_VERSION = 217;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -1082,7 +1084,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 215;
|
CURRENT_PROJECT_VERSION = 217;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.138.1</string>
|
<string>1.139.3</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>215</string>
|
<string>217</string>
|
||||||
<key>FLTEnableImpeller</key>
|
<key>FLTEnableImpeller</key>
|
||||||
<true />
|
<true />
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ platform :ios do
|
|||||||
path: "./Runner.xcodeproj",
|
path: "./Runner.xcodeproj",
|
||||||
)
|
)
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "1.139.2"
|
version_number: "1.139.4"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ profileChangedAt: $profileChangedAt
|
|||||||
bool? isPartnerSharedWith,
|
bool? isPartnerSharedWith,
|
||||||
bool? hasProfileImage,
|
bool? hasProfileImage,
|
||||||
DateTime? profileChangedAt,
|
DateTime? profileChangedAt,
|
||||||
|
int? quotaSizeInBytes,
|
||||||
|
int? quotaUsageInBytes,
|
||||||
}) => UserDto(
|
}) => UserDto(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
email: email ?? this.email,
|
email: email ?? this.email,
|
||||||
@@ -88,6 +90,8 @@ profileChangedAt: $profileChangedAt
|
|||||||
isPartnerSharedWith: isPartnerSharedWith ?? this.isPartnerSharedWith,
|
isPartnerSharedWith: isPartnerSharedWith ?? this.isPartnerSharedWith,
|
||||||
hasProfileImage: hasProfileImage ?? this.hasProfileImage,
|
hasProfileImage: hasProfileImage ?? this.hasProfileImage,
|
||||||
profileChangedAt: profileChangedAt ?? this.profileChangedAt,
|
profileChangedAt: profileChangedAt ?? this.profileChangedAt,
|
||||||
|
quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes,
|
||||||
|
quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -105,7 +109,9 @@ profileChangedAt: $profileChangedAt
|
|||||||
other.memoryEnabled == memoryEnabled &&
|
other.memoryEnabled == memoryEnabled &&
|
||||||
other.inTimeline == inTimeline &&
|
other.inTimeline == inTimeline &&
|
||||||
other.hasProfileImage == hasProfileImage &&
|
other.hasProfileImage == hasProfileImage &&
|
||||||
other.profileChangedAt.isAtSameMomentAs(profileChangedAt);
|
other.profileChangedAt.isAtSameMomentAs(profileChangedAt) &&
|
||||||
|
other.quotaSizeInBytes == quotaSizeInBytes &&
|
||||||
|
other.quotaUsageInBytes == quotaUsageInBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -121,7 +127,9 @@ profileChangedAt: $profileChangedAt
|
|||||||
isPartnerSharedBy.hashCode ^
|
isPartnerSharedBy.hashCode ^
|
||||||
isPartnerSharedWith.hashCode ^
|
isPartnerSharedWith.hashCode ^
|
||||||
hasProfileImage.hashCode ^
|
hasProfileImage.hashCode ^
|
||||||
profileChangedAt.hashCode;
|
profileChangedAt.hashCode ^
|
||||||
|
quotaSizeInBytes.hashCode ^
|
||||||
|
quotaUsageInBytes.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
class PartnerUserDto {
|
class PartnerUserDto {
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ isOnboarded: $isOnboarded,
|
|||||||
int get hashCode => isOnboarded.hashCode;
|
int get hashCode => isOnboarded.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: wait to be overwritten
|
|
||||||
class Preferences {
|
class Preferences {
|
||||||
final bool foldersEnabled;
|
final bool foldersEnabled;
|
||||||
final bool memoriesEnabled;
|
final bool memoriesEnabled;
|
||||||
@@ -133,17 +132,17 @@ class Preferences {
|
|||||||
|
|
||||||
factory Preferences.fromMap(Map<String, Object?> map) {
|
factory Preferences.fromMap(Map<String, Object?> map) {
|
||||||
return Preferences(
|
return Preferences(
|
||||||
foldersEnabled: map["folders-Enabled"] as bool? ?? false,
|
foldersEnabled: (map["folders"] as Map<String, Object?>?)?["enabled"] as bool? ?? false,
|
||||||
memoriesEnabled: map["memories-Enabled"] as bool? ?? true,
|
memoriesEnabled: (map["memories"] as Map<String, Object?>?)?["enabled"] as bool? ?? true,
|
||||||
peopleEnabled: map["people-Enabled"] as bool? ?? true,
|
peopleEnabled: (map["people"] as Map<String, Object?>?)?["enabled"] as bool? ?? true,
|
||||||
ratingsEnabled: map["ratings-Enabled"] as bool? ?? false,
|
ratingsEnabled: (map["ratings"] as Map<String, Object?>?)?["enabled"] as bool? ?? false,
|
||||||
sharedLinksEnabled: map["sharedLinks-Enabled"] as bool? ?? true,
|
sharedLinksEnabled: (map["sharedLinks"] as Map<String, Object?>?)?["enabled"] as bool? ?? true,
|
||||||
tagsEnabled: map["tags-Enabled"] as bool? ?? false,
|
tagsEnabled: (map["tags"] as Map<String, Object?>?)?["enabled"] as bool? ?? false,
|
||||||
userAvatarColor: AvatarColor.values.firstWhere(
|
userAvatarColor: AvatarColor.values.firstWhere(
|
||||||
(e) => e.value == map["avatar-Color"] as String?,
|
(e) => e.value == (map["avatar"] as Map<String, Object?>?)?["color"] as String?,
|
||||||
orElse: () => AvatarColor.primary,
|
orElse: () => AvatarColor.primary,
|
||||||
),
|
),
|
||||||
showSupportBadge: map["purchase-ShowSupportBadge"] as bool? ?? true,
|
showSupportBadge: (map["purchase"] as Map<String, Object?>?)?["showSupportBadge"] as bool? ?? true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +212,7 @@ class License {
|
|||||||
|
|
||||||
factory License.fromMap(Map<String, Object?> map) {
|
factory License.fromMap(Map<String, Object?> map) {
|
||||||
return License(
|
return License(
|
||||||
activatedAt: map["activatedAt"] as DateTime,
|
activatedAt: DateTime.parse(map["activatedAt"] as String),
|
||||||
activationKey: map["activationKey"] as String,
|
activationKey: map["activationKey"] as String,
|
||||||
licenseKey: map["licenseKey"] as String,
|
licenseKey: map["licenseKey"] as String,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,9 +17,14 @@ class AssetService {
|
|||||||
_localAssetRepository = localAssetRepository,
|
_localAssetRepository = localAssetRepository,
|
||||||
_platform = const LocalPlatform();
|
_platform = const LocalPlatform();
|
||||||
|
|
||||||
|
Future<BaseAsset?> getAsset(BaseAsset asset) {
|
||||||
|
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
||||||
|
return asset is LocalAsset ? _localAssetRepository.get(id) : _remoteAssetRepository.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
Stream<BaseAsset?> watchAsset(BaseAsset asset) {
|
Stream<BaseAsset?> watchAsset(BaseAsset asset) {
|
||||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
||||||
return asset is LocalAsset ? _localAssetRepository.watchAsset(id) : _remoteAssetRepository.watchAsset(id);
|
return asset is LocalAsset ? _localAssetRepository.watch(id) : _remoteAssetRepository.watch(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<RemoteAsset?> getRemoteAsset(String id) {
|
Future<RemoteAsset?> getRemoteAsset(String id) {
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ class User {
|
|||||||
avatarColor: dto.avatarColor,
|
avatarColor: dto.avatarColor,
|
||||||
memoryEnabled: dto.memoryEnabled,
|
memoryEnabled: dto.memoryEnabled,
|
||||||
inTimeline: dto.inTimeline,
|
inTimeline: dto.inTimeline,
|
||||||
|
quotaUsageInBytes: dto.quotaUsageInBytes,
|
||||||
|
quotaSizeInBytes: dto.quotaSizeInBytes,
|
||||||
);
|
);
|
||||||
|
|
||||||
UserDto toDto() => UserDto(
|
UserDto toDto() => UserDto(
|
||||||
|
|||||||
@@ -64,19 +64,62 @@ class RemoteImageRequest extends ImageRequest {
|
|||||||
if (_isCancelled) {
|
if (_isCancelled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final bytes = Uint8List(response.contentLength);
|
|
||||||
int offset = 0;
|
final cacheManager = this.cacheManager;
|
||||||
final subscription = response.listen((List<int> chunk) {
|
final streamController = StreamController<List<int>>(sync: true);
|
||||||
// this is important to break the response stream if the request is cancelled
|
final Stream<List<int>> stream;
|
||||||
|
cacheManager?.putStreamedFile(url, streamController.stream);
|
||||||
|
stream = response.map((chunk) {
|
||||||
if (_isCancelled) {
|
if (_isCancelled) {
|
||||||
throw StateError('Cancelled request');
|
throw StateError('Cancelled request');
|
||||||
}
|
}
|
||||||
bytes.setAll(offset, chunk);
|
if (cacheManager != null) {
|
||||||
offset += chunk.length;
|
streamController.add(chunk);
|
||||||
}, cancelOnError: true);
|
}
|
||||||
cacheManager?.putStreamedFile(url, response);
|
return chunk;
|
||||||
await subscription.asFuture();
|
});
|
||||||
return await ImmutableBuffer.fromUint8List(bytes);
|
|
||||||
|
try {
|
||||||
|
final Uint8List bytes = await _downloadBytes(stream, response.contentLength);
|
||||||
|
streamController.close();
|
||||||
|
return await ImmutableBuffer.fromUint8List(bytes);
|
||||||
|
} catch (e) {
|
||||||
|
streamController.addError(e);
|
||||||
|
streamController.close();
|
||||||
|
if (_isCancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Uint8List> _downloadBytes(Stream<List<int>> stream, int length) async {
|
||||||
|
final Uint8List bytes;
|
||||||
|
int offset = 0;
|
||||||
|
if (length > 0) {
|
||||||
|
// Known content length - use pre-allocated buffer
|
||||||
|
bytes = Uint8List(length);
|
||||||
|
await stream.listen((chunk) {
|
||||||
|
bytes.setAll(offset, chunk);
|
||||||
|
offset += chunk.length;
|
||||||
|
}, cancelOnError: true).asFuture();
|
||||||
|
} else {
|
||||||
|
// Unknown content length - collect chunks dynamically
|
||||||
|
final chunks = <List<int>>[];
|
||||||
|
int totalLength = 0;
|
||||||
|
await stream.listen((chunk) {
|
||||||
|
chunks.add(chunk);
|
||||||
|
totalLength += chunk.length;
|
||||||
|
}, cancelOnError: true).asFuture();
|
||||||
|
|
||||||
|
bytes = Uint8List(totalLength);
|
||||||
|
for (final chunk in chunks) {
|
||||||
|
bytes.setAll(offset, chunk);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ImageInfo?> _loadCachedFile(
|
Future<ImageInfo?> _loadCachedFile(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
|||||||
final Drift _db;
|
final Drift _db;
|
||||||
const DriftLocalAssetRepository(this._db) : super(_db);
|
const DriftLocalAssetRepository(this._db) : super(_db);
|
||||||
|
|
||||||
Stream<LocalAsset?> watchAsset(String id) {
|
SingleOrNullSelectable<LocalAsset?> _assetSelectable(String id) {
|
||||||
final query = _db.localAssetEntity.select().addColumns([_db.remoteAssetEntity.id]).join([
|
final query = _db.localAssetEntity.select().addColumns([_db.remoteAssetEntity.id]).join([
|
||||||
leftOuterJoin(
|
leftOuterJoin(
|
||||||
_db.remoteAssetEntity,
|
_db.remoteAssetEntity,
|
||||||
@@ -21,9 +21,13 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
|||||||
return query.map((row) {
|
return query.map((row) {
|
||||||
final asset = row.readTable(_db.localAssetEntity).toDto();
|
final asset = row.readTable(_db.localAssetEntity).toDto();
|
||||||
return asset.copyWith(remoteId: row.read(_db.remoteAssetEntity.id));
|
return asset.copyWith(remoteId: row.read(_db.remoteAssetEntity.id));
|
||||||
}).watchSingleOrNull();
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<LocalAsset?> get(String id) => _assetSelectable(id).getSingleOrNull();
|
||||||
|
|
||||||
|
Stream<LocalAsset?> watch(String id) => _assetSelectable(id).watchSingleOrNull();
|
||||||
|
|
||||||
Future<void> updateHashes(Iterable<LocalAsset> hashes) {
|
Future<void> updateHashes(Iterable<LocalAsset> hashes) {
|
||||||
if (hashes.isEmpty) {
|
if (hashes.isEmpty) {
|
||||||
return Future.value();
|
return Future.value();
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
|||||||
const DriftRemoteAlbumRepository(this._db) : super(_db);
|
const DriftRemoteAlbumRepository(this._db) : super(_db);
|
||||||
|
|
||||||
Future<List<RemoteAlbum>> getAll({Set<SortRemoteAlbumsBy> sortBy = const {SortRemoteAlbumsBy.updatedAt}}) {
|
Future<List<RemoteAlbum>> getAll({Set<SortRemoteAlbumsBy> sortBy = const {SortRemoteAlbumsBy.updatedAt}}) {
|
||||||
final assetCount = _db.remoteAlbumAssetEntity.assetId.count();
|
final assetCount = _db.remoteAlbumAssetEntity.assetId.count(distinct: true);
|
||||||
|
|
||||||
final query = _db.remoteAlbumEntity.select().join([
|
final query = _db.remoteAlbumEntity.select().join([
|
||||||
leftOuterJoin(
|
leftOuterJoin(
|
||||||
@@ -41,7 +41,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
|||||||
..where(_db.remoteAssetEntity.deletedAt.isNull())
|
..where(_db.remoteAssetEntity.deletedAt.isNull())
|
||||||
..addColumns([assetCount])
|
..addColumns([assetCount])
|
||||||
..addColumns([_db.userEntity.name])
|
..addColumns([_db.userEntity.name])
|
||||||
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
|
..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)])
|
||||||
..groupBy([_db.remoteAlbumEntity.id]);
|
..groupBy([_db.remoteAlbumEntity.id]);
|
||||||
|
|
||||||
if (sortBy.isNotEmpty) {
|
if (sortBy.isNotEmpty) {
|
||||||
@@ -62,14 +62,14 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
|||||||
.toDto(
|
.toDto(
|
||||||
assetCount: row.read(assetCount) ?? 0,
|
assetCount: row.read(assetCount) ?? 0,
|
||||||
ownerName: row.read(_db.userEntity.name)!,
|
ownerName: row.read(_db.userEntity.name)!,
|
||||||
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
|
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 2,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.get();
|
.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<RemoteAlbum?> get(String albumId) {
|
Future<RemoteAlbum?> get(String albumId) {
|
||||||
final assetCount = _db.remoteAlbumAssetEntity.assetId.count();
|
final assetCount = _db.remoteAlbumAssetEntity.assetId.count(distinct: true);
|
||||||
|
|
||||||
final query =
|
final query =
|
||||||
_db.remoteAlbumEntity.select().join([
|
_db.remoteAlbumEntity.select().join([
|
||||||
@@ -97,7 +97,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
|||||||
..where(_db.remoteAlbumEntity.id.equals(albumId) & _db.remoteAssetEntity.deletedAt.isNull())
|
..where(_db.remoteAlbumEntity.id.equals(albumId) & _db.remoteAssetEntity.deletedAt.isNull())
|
||||||
..addColumns([assetCount])
|
..addColumns([assetCount])
|
||||||
..addColumns([_db.userEntity.name])
|
..addColumns([_db.userEntity.name])
|
||||||
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
|
..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)])
|
||||||
..groupBy([_db.remoteAlbumEntity.id]);
|
..groupBy([_db.remoteAlbumEntity.id]);
|
||||||
|
|
||||||
return query
|
return query
|
||||||
@@ -107,7 +107,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
|||||||
.toDto(
|
.toDto(
|
||||||
assetCount: row.read(assetCount) ?? 0,
|
assetCount: row.read(assetCount) ?? 0,
|
||||||
ownerName: row.read(_db.userEntity.name)!,
|
ownerName: row.read(_db.userEntity.name)!,
|
||||||
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
|
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 2,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
@@ -282,7 +282,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
|||||||
])
|
])
|
||||||
..where(_db.remoteAlbumEntity.id.equals(albumId))
|
..where(_db.remoteAlbumEntity.id.equals(albumId))
|
||||||
..addColumns([_db.userEntity.name])
|
..addColumns([_db.userEntity.name])
|
||||||
..addColumns([_db.remoteAlbumUserEntity.userId.count()])
|
..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)])
|
||||||
..groupBy([_db.remoteAlbumEntity.id]);
|
..groupBy([_db.remoteAlbumEntity.id]);
|
||||||
|
|
||||||
return query.map((row) {
|
return query.map((row) {
|
||||||
@@ -290,7 +290,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
|||||||
.readTable(_db.remoteAlbumEntity)
|
.readTable(_db.remoteAlbumEntity)
|
||||||
.toDto(
|
.toDto(
|
||||||
ownerName: row.read(_db.userEntity.name)!,
|
ownerName: row.read(_db.userEntity.name)!,
|
||||||
isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2,
|
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 2,
|
||||||
);
|
);
|
||||||
return album;
|
return album;
|
||||||
}).watchSingleOrNull();
|
}).watchSingleOrNull();
|
||||||
|
|||||||
@@ -55,24 +55,6 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
|||||||
return _assetSelectable(id).getSingleOrNull();
|
return _assetSelectable(id).getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<RemoteAsset?> watchAsset(String id) {
|
|
||||||
final query =
|
|
||||||
_db.remoteAssetEntity.select().addColumns([_db.localAssetEntity.id]).join([
|
|
||||||
leftOuterJoin(
|
|
||||||
_db.localAssetEntity,
|
|
||||||
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
|
|
||||||
useColumns: false,
|
|
||||||
),
|
|
||||||
])
|
|
||||||
..where(_db.remoteAssetEntity.id.equals(id))
|
|
||||||
..limit(1);
|
|
||||||
|
|
||||||
return query.map((row) {
|
|
||||||
final asset = row.readTable(_db.remoteAssetEntity).toDto();
|
|
||||||
return asset.copyWith(localId: row.read(_db.localAssetEntity.id));
|
|
||||||
}).watchSingleOrNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<RemoteAsset>> getStackChildren(RemoteAsset asset) {
|
Future<List<RemoteAsset>> getStackChildren(RemoteAsset asset) {
|
||||||
if (asset.stackId == null) {
|
if (asset.stackId == null) {
|
||||||
return Future.value([]);
|
return Future.value([]);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart' as entity;
|
import 'package:immich_mobile/infrastructure/entities/user.entity.dart' as entity;
|
||||||
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
class IsarUserRepository extends IsarDatabaseRepository {
|
class IsarUserRepository extends IsarDatabaseRepository {
|
||||||
@@ -70,8 +72,16 @@ class DriftUserRepository extends DriftDatabaseRepository {
|
|||||||
final Drift _db;
|
final Drift _db;
|
||||||
const DriftUserRepository(super.db) : _db = db;
|
const DriftUserRepository(super.db) : _db = db;
|
||||||
|
|
||||||
Future<UserDto?> get(String id) =>
|
Future<UserDto?> get(String id) async {
|
||||||
_db.managers.userEntity.filter((user) => user.id.equals(id)).getSingleOrNull().then((user) => user?.toDto());
|
final user = await _db.managers.userEntity.filter((user) => user.id.equals(id)).getSingleOrNull();
|
||||||
|
|
||||||
|
if (user == null) return null;
|
||||||
|
|
||||||
|
final query = _db.userMetadataEntity.select()..where((e) => e.userId.equals(id));
|
||||||
|
final metadata = await query.map((row) => row.toDto()).get();
|
||||||
|
|
||||||
|
return user.toDto(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
Future<UserDto> upsert(UserDto user) async {
|
Future<UserDto> upsert(UserDto user) async {
|
||||||
await _db.userEntity.insertOnConflictUpdate(
|
await _db.userEntity.insertOnConflictUpdate(
|
||||||
@@ -87,10 +97,35 @@ class DriftUserRepository extends DriftDatabaseRepository {
|
|||||||
);
|
);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<UserDto>> getAll() async {
|
||||||
|
final users = await _db.userEntity.select().get();
|
||||||
|
final List<UserDto> result = [];
|
||||||
|
|
||||||
|
for (final user in users) {
|
||||||
|
final query = _db.userMetadataEntity.select()..where((e) => e.userId.equals(user.id));
|
||||||
|
final metadata = await query.map((row) => row.toDto()).get();
|
||||||
|
result.add(user.toDto(metadata));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on UserEntityData {
|
extension on UserEntityData {
|
||||||
UserDto toDto() {
|
UserDto toDto([List<UserMetadata>? metadata]) {
|
||||||
|
AvatarColor avatarColor = AvatarColor.primary;
|
||||||
|
bool memoryEnabled = true;
|
||||||
|
|
||||||
|
if (metadata != null) {
|
||||||
|
for (final meta in metadata) {
|
||||||
|
if (meta.key == UserMetadataKey.preferences && meta.preferences != null) {
|
||||||
|
avatarColor = meta.preferences?.userAvatarColor ?? AvatarColor.primary;
|
||||||
|
memoryEnabled = meta.preferences?.memoriesEnabled ?? true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return UserDto(
|
return UserDto(
|
||||||
id: id,
|
id: id,
|
||||||
email: email,
|
email: email,
|
||||||
@@ -99,6 +134,8 @@ extension on UserEntityData {
|
|||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
profileChangedAt: profileChangedAt,
|
profileChangedAt: profileChangedAt,
|
||||||
hasProfileImage: hasProfileImage,
|
hasProfileImage: hasProfileImage,
|
||||||
|
avatarColor: avatarColor,
|
||||||
|
memoryEnabled: memoryEnabled,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class DriftUserMetadataRepository extends DriftDatabaseRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on UserMetadataEntityData {
|
extension UserMetadataDataExtension on UserMetadataEntityData {
|
||||||
UserMetadata toDto() => switch (key) {
|
UserMetadata toDto() => switch (key) {
|
||||||
UserMetadataKey.onboarding => UserMetadata(userId: userId, key: key, onboarding: Onboarding.fromMap(value)),
|
UserMetadataKey.onboarding => UserMetadata(userId: userId, key: key, onboarding: Onboarding.fromMap(value)),
|
||||||
UserMetadataKey.preferences => UserMetadata(userId: userId, key: key, preferences: Preferences.fromMap(value)),
|
UserMetadataKey.preferences => UserMetadata(userId: userId, key: key, preferences: Preferences.fromMap(value)),
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ abstract final class UserConverter {
|
|||||||
isPartnerSharedWith: false,
|
isPartnerSharedWith: false,
|
||||||
profileChangedAt: adminDto.profileChangedAt,
|
profileChangedAt: adminDto.profileChangedAt,
|
||||||
hasProfileImage: adminDto.profileImagePath.isNotEmpty,
|
hasProfileImage: adminDto.profileImagePath.isNotEmpty,
|
||||||
|
quotaSizeInBytes: adminDto.quotaSizeInBytes ?? 0,
|
||||||
|
quotaUsageInBytes: adminDto.quotaUsageInBytes ?? 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
static UserDto fromPartnerDto(PartnerResponseDto dto) => UserDto(
|
static UserDto fromPartnerDto(PartnerResponseDto dto) => UserDto(
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ class MainTimelinePage extends ConsumerWidget {
|
|||||||
final memoryLaneProvider = ref.watch(driftMemoryFutureProvider);
|
final memoryLaneProvider = ref.watch(driftMemoryFutureProvider);
|
||||||
final memoriesEnabled = ref.watch(currentUserProvider.select((user) => user?.memoryEnabled ?? true));
|
final memoriesEnabled = ref.watch(currentUserProvider.select((user) => user?.memoryEnabled ?? true));
|
||||||
|
|
||||||
// TODO: the user preferences need to be updated
|
|
||||||
// from the server to get live hiding/showing of memory lane
|
|
||||||
|
|
||||||
return memoryLaneProvider.maybeWhen(
|
return memoryLaneProvider.maybeWhen(
|
||||||
data: (memories) {
|
data: (memories) {
|
||||||
return memories.isEmpty || !memoriesEnabled
|
return memories.isEmpty || !memoriesEnabled
|
||||||
|
|||||||
@@ -87,9 +87,10 @@ class NativeVideoViewer extends HookConsumerWidget {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final videoAsset = await ref.read(assetServiceProvider).getAsset(asset) ?? asset;
|
||||||
try {
|
try {
|
||||||
if (asset.hasLocal && asset.livePhotoVideoId == null) {
|
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||||
final id = asset is LocalAsset ? (asset as LocalAsset).id : (asset as RemoteAsset).localId!;
|
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
||||||
final file = await const StorageRepository().getFileForAsset(id);
|
final file = await const StorageRepository().getFileForAsset(id);
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
throw Exception('No file found for the video');
|
throw Exception('No file found for the video');
|
||||||
@@ -99,14 +100,14 @@ class NativeVideoViewer extends HookConsumerWidget {
|
|||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
final remoteId = (asset as RemoteAsset).id;
|
final remoteId = (videoAsset as RemoteAsset).id;
|
||||||
|
|
||||||
// Use a network URL for the video player controller
|
// Use a network URL for the video player controller
|
||||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||||
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
|
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
|
||||||
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
|
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
|
||||||
final String videoUrl = asset.livePhotoVideoId != null
|
final String videoUrl = videoAsset.livePhotoVideoId != null
|
||||||
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/$postfixUrl'
|
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
|
||||||
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
|
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
|
||||||
|
|
||||||
final source = await VideoSource.init(
|
final source = await VideoSource.init(
|
||||||
@@ -116,7 +117,7 @@ class NativeVideoViewer extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
return source;
|
return source;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.severe('Error creating video source for asset ${asset.name}: $error');
|
log.severe('Error creating video source for asset ${videoAsset.name}: $error');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
|
|||||||
imageInfo.dispose();
|
imageInfo.dispose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_fadeController.value = 1.0;
|
||||||
setState(() {
|
setState(() {
|
||||||
_providerImage = imageInfo.image;
|
_providerImage = imageInfo.image;
|
||||||
});
|
});
|
||||||
@@ -115,7 +115,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
|
|||||||
final imageStream = _imageStream = imageProvider.resolve(ImageConfiguration.empty);
|
final imageStream = _imageStream = imageProvider.resolve(ImageConfiguration.empty);
|
||||||
final imageStreamListener = _imageStreamListener = ImageStreamListener(
|
final imageStreamListener = _imageStreamListener = ImageStreamListener(
|
||||||
(ImageInfo imageInfo, bool synchronousCall) {
|
(ImageInfo imageInfo, bool synchronousCall) {
|
||||||
_stopListeningToStream();
|
_stopListeningToThumbhashStream();
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
imageInfo.dispose();
|
imageInfo.dispose();
|
||||||
return;
|
return;
|
||||||
@@ -125,7 +125,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (synchronousCall && _providerImage == null) {
|
if ((synchronousCall && _providerImage == null) || !_isVisible()) {
|
||||||
_fadeController.value = 1.0;
|
_fadeController.value = 1.0;
|
||||||
} else if (_fadeController.isAnimating) {
|
} else if (_fadeController.isAnimating) {
|
||||||
_fadeController.forward();
|
_fadeController.forward();
|
||||||
@@ -201,6 +201,15 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
|
|||||||
_loadFromThumbhashProvider();
|
_loadFromThumbhashProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isVisible() {
|
||||||
|
final renderObject = context.findRenderObject() as RenderBox?;
|
||||||
|
if (renderObject == null || !renderObject.attached) return false;
|
||||||
|
|
||||||
|
final topLeft = renderObject.localToGlobal(Offset.zero);
|
||||||
|
final bottomRight = renderObject.localToGlobal(Offset(renderObject.size.width, renderObject.size.height));
|
||||||
|
return topLeft.dy < context.height && bottomRight.dy > 0;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = context.colorScheme;
|
final colorScheme = context.colorScheme;
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ class TimelineHeader extends StatelessWidget {
|
|||||||
if (isMonthHeader)
|
if (isMonthHeader)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(_formatMonth(context, date), style: context.textTheme.labelLarge?.copyWith(fontSize: 24)),
|
Text(
|
||||||
|
toBeginningOfSentenceCase(_formatMonth(context, date)),
|
||||||
|
style: context.textTheme.labelLarge?.copyWith(fontSize: 24),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (header != HeaderType.monthAndDay) _BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
|
if (header != HeaderType.monthAndDay) _BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
|
||||||
],
|
],
|
||||||
@@ -65,7 +68,10 @@ class TimelineHeader extends StatelessWidget {
|
|||||||
if (isDayHeader)
|
if (isDayHeader)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(_formatDay(context, date), style: context.textTheme.labelLarge?.copyWith(fontSize: 15)),
|
Text(
|
||||||
|
toBeginningOfSentenceCase(_formatDay(context, date)),
|
||||||
|
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
_BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
|
_BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -38,9 +38,21 @@ abstract class RemoteCacheManager extends CacheManager {
|
|||||||
final file = await store.fileSystem.createFile(path);
|
final file = await store.fileSystem.createFile(path);
|
||||||
final sink = file.openWrite();
|
final sink = file.openWrite();
|
||||||
try {
|
try {
|
||||||
await source.pipe(sink);
|
await source.listen(sink.add, cancelOnError: true).asFuture();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
await sink.close();
|
||||||
|
await file.delete();
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe('Failed to delete incomplete cache file: $e');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sink.flush();
|
||||||
await sink.close();
|
await sink.close();
|
||||||
|
} catch (e) {
|
||||||
try {
|
try {
|
||||||
await file.delete();
|
await file.delete();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ dynamic upgradeDto(dynamic value, String targetType) {
|
|||||||
case 'AssetResponseDto':
|
case 'AssetResponseDto':
|
||||||
if (value is Map) {
|
if (value is Map) {
|
||||||
addDefault(value, 'visibility', 'timeline');
|
addDefault(value, 'visibility', 'timeline');
|
||||||
|
addDefault(value, 'createdAt', DateTime.now().toIso8601String());
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'UserAdminResponseDto':
|
case 'UserAdminResponseDto':
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class UserCircleAvatar extends ConsumerWidget {
|
|||||||
borderRadius: const BorderRadius.all(Radius.circular(50)),
|
borderRadius: const BorderRadius.all(Radius.circular(50)),
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
cacheKey: user.profileChangedAt.toIso8601String(),
|
cacheKey: '${user.id}-${user.profileChangedAt.toIso8601String()}',
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
placeholder: (_, __) => Image.memory(kTransparentImage),
|
placeholder: (_, __) => Image.memory(kTransparentImage),
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/oauth.provider.dart';
|
import 'package:immich_mobile/providers/oauth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/utils/migration.dart';
|
|
||||||
import 'package:immich_mobile/utils/provider_utils.dart';
|
import 'package:immich_mobile/utils/provider_utils.dart';
|
||||||
import 'package:immich_mobile/utils/url_helper.dart';
|
import 'package:immich_mobile/utils/url_helper.dart';
|
||||||
import 'package:immich_mobile/utils/version_compatibility.dart';
|
import 'package:immich_mobile/utils/version_compatibility.dart';
|
||||||
@@ -277,7 +276,6 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
if (isBeta) {
|
if (isBeta) {
|
||||||
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
|
||||||
await runNewSync(ref);
|
|
||||||
context.replaceRoute(const TabShellRoute());
|
context.replaceRoute(const TabShellRoute());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class _MapThemeOverrideState extends ConsumerState<MapThemeOverride> with Widget
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_theme = widget.themeMode ?? ref.read(mapStateNotifierProvider.select((v) => v.themeMode));
|
_theme = widget.themeMode ?? ref.read(immichThemeModeProvider);
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDarkTheme = checkDarkTheme();
|
_isDarkTheme = checkDarkTheme();
|
||||||
});
|
});
|
||||||
@@ -65,7 +65,7 @@ class _MapThemeOverrideState extends ConsumerState<MapThemeOverride> with Widget
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
_theme = widget.themeMode ?? ref.watch(mapStateNotifierProvider.select((v) => v.themeMode));
|
_theme = widget.themeMode ?? ref.watch(immichThemeModeProvider);
|
||||||
var appTheme = ref.watch(immichThemeProvider);
|
var appTheme = ref.watch(immichThemeProvider);
|
||||||
final locale = ref.watch(localeProvider);
|
final locale = ref.watch(localeProvider);
|
||||||
|
|
||||||
|
|||||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
|||||||
|
|
||||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||||
|
|
||||||
- API version: 1.139.2
|
- API version: 1.139.4
|
||||||
- Generator version: 7.8.0
|
- Generator version: 7.8.0
|
||||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||||
|
|
||||||
|
|||||||
@@ -13,16 +13,10 @@ part of openapi.api;
|
|||||||
class AlbumsAddAssetsResponseDto {
|
class AlbumsAddAssetsResponseDto {
|
||||||
/// Returns a new [AlbumsAddAssetsResponseDto] instance.
|
/// Returns a new [AlbumsAddAssetsResponseDto] instance.
|
||||||
AlbumsAddAssetsResponseDto({
|
AlbumsAddAssetsResponseDto({
|
||||||
required this.albumSuccessCount,
|
|
||||||
required this.assetSuccessCount,
|
|
||||||
this.error,
|
this.error,
|
||||||
required this.success,
|
required this.success,
|
||||||
});
|
});
|
||||||
|
|
||||||
int albumSuccessCount;
|
|
||||||
|
|
||||||
int assetSuccessCount;
|
|
||||||
|
|
||||||
///
|
///
|
||||||
/// Please note: This property should have been non-nullable! Since the specification file
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
/// does not include a default value (using the "default:" property), however, the generated
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
@@ -35,26 +29,20 @@ class AlbumsAddAssetsResponseDto {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is AlbumsAddAssetsResponseDto &&
|
bool operator ==(Object other) => identical(this, other) || other is AlbumsAddAssetsResponseDto &&
|
||||||
other.albumSuccessCount == albumSuccessCount &&
|
|
||||||
other.assetSuccessCount == assetSuccessCount &&
|
|
||||||
other.error == error &&
|
other.error == error &&
|
||||||
other.success == success;
|
other.success == success;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(albumSuccessCount.hashCode) +
|
|
||||||
(assetSuccessCount.hashCode) +
|
|
||||||
(error == null ? 0 : error!.hashCode) +
|
(error == null ? 0 : error!.hashCode) +
|
||||||
(success.hashCode);
|
(success.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'AlbumsAddAssetsResponseDto[albumSuccessCount=$albumSuccessCount, assetSuccessCount=$assetSuccessCount, error=$error, success=$success]';
|
String toString() => 'AlbumsAddAssetsResponseDto[error=$error, success=$success]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
json[r'albumSuccessCount'] = this.albumSuccessCount;
|
|
||||||
json[r'assetSuccessCount'] = this.assetSuccessCount;
|
|
||||||
if (this.error != null) {
|
if (this.error != null) {
|
||||||
json[r'error'] = this.error;
|
json[r'error'] = this.error;
|
||||||
} else {
|
} else {
|
||||||
@@ -73,8 +61,6 @@ class AlbumsAddAssetsResponseDto {
|
|||||||
final json = value.cast<String, dynamic>();
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
return AlbumsAddAssetsResponseDto(
|
return AlbumsAddAssetsResponseDto(
|
||||||
albumSuccessCount: mapValueOfType<int>(json, r'albumSuccessCount')!,
|
|
||||||
assetSuccessCount: mapValueOfType<int>(json, r'assetSuccessCount')!,
|
|
||||||
error: BulkIdErrorReason.fromJson(json[r'error']),
|
error: BulkIdErrorReason.fromJson(json[r'error']),
|
||||||
success: mapValueOfType<bool>(json, r'success')!,
|
success: mapValueOfType<bool>(json, r'success')!,
|
||||||
);
|
);
|
||||||
@@ -124,8 +110,6 @@ class AlbumsAddAssetsResponseDto {
|
|||||||
|
|
||||||
/// The list of required keys that must be present in a JSON.
|
/// The list of required keys that must be present in a JSON.
|
||||||
static const requiredKeys = <String>{
|
static const requiredKeys = <String>{
|
||||||
'albumSuccessCount',
|
|
||||||
'assetSuccessCount',
|
|
||||||
'success',
|
'success',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
11
mobile/openapi/lib/model/asset_response_dto.dart
generated
11
mobile/openapi/lib/model/asset_response_dto.dart
generated
@@ -14,6 +14,7 @@ class AssetResponseDto {
|
|||||||
/// Returns a new [AssetResponseDto] instance.
|
/// Returns a new [AssetResponseDto] instance.
|
||||||
AssetResponseDto({
|
AssetResponseDto({
|
||||||
required this.checksum,
|
required this.checksum,
|
||||||
|
required this.createdAt,
|
||||||
required this.deviceAssetId,
|
required this.deviceAssetId,
|
||||||
required this.deviceId,
|
required this.deviceId,
|
||||||
this.duplicateId,
|
this.duplicateId,
|
||||||
@@ -49,6 +50,9 @@ class AssetResponseDto {
|
|||||||
/// base64 encoded sha1 hash
|
/// base64 encoded sha1 hash
|
||||||
String checksum;
|
String checksum;
|
||||||
|
|
||||||
|
/// The UTC timestamp when the asset was originally uploaded to Immich.
|
||||||
|
DateTime createdAt;
|
||||||
|
|
||||||
String deviceAssetId;
|
String deviceAssetId;
|
||||||
|
|
||||||
String deviceId;
|
String deviceId;
|
||||||
@@ -142,6 +146,7 @@ class AssetResponseDto {
|
|||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
|
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
|
||||||
other.checksum == checksum &&
|
other.checksum == checksum &&
|
||||||
|
other.createdAt == createdAt &&
|
||||||
other.deviceAssetId == deviceAssetId &&
|
other.deviceAssetId == deviceAssetId &&
|
||||||
other.deviceId == deviceId &&
|
other.deviceId == deviceId &&
|
||||||
other.duplicateId == duplicateId &&
|
other.duplicateId == duplicateId &&
|
||||||
@@ -177,6 +182,7 @@ class AssetResponseDto {
|
|||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(checksum.hashCode) +
|
(checksum.hashCode) +
|
||||||
|
(createdAt.hashCode) +
|
||||||
(deviceAssetId.hashCode) +
|
(deviceAssetId.hashCode) +
|
||||||
(deviceId.hashCode) +
|
(deviceId.hashCode) +
|
||||||
(duplicateId == null ? 0 : duplicateId!.hashCode) +
|
(duplicateId == null ? 0 : duplicateId!.hashCode) +
|
||||||
@@ -209,11 +215,12 @@ class AssetResponseDto {
|
|||||||
(visibility.hashCode);
|
(visibility.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]';
|
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
json[r'checksum'] = this.checksum;
|
json[r'checksum'] = this.checksum;
|
||||||
|
json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
|
||||||
json[r'deviceAssetId'] = this.deviceAssetId;
|
json[r'deviceAssetId'] = this.deviceAssetId;
|
||||||
json[r'deviceId'] = this.deviceId;
|
json[r'deviceId'] = this.deviceId;
|
||||||
if (this.duplicateId != null) {
|
if (this.duplicateId != null) {
|
||||||
@@ -293,6 +300,7 @@ class AssetResponseDto {
|
|||||||
|
|
||||||
return AssetResponseDto(
|
return AssetResponseDto(
|
||||||
checksum: mapValueOfType<String>(json, r'checksum')!,
|
checksum: mapValueOfType<String>(json, r'checksum')!,
|
||||||
|
createdAt: mapDateTime(json, r'createdAt', r'')!,
|
||||||
deviceAssetId: mapValueOfType<String>(json, r'deviceAssetId')!,
|
deviceAssetId: mapValueOfType<String>(json, r'deviceAssetId')!,
|
||||||
deviceId: mapValueOfType<String>(json, r'deviceId')!,
|
deviceId: mapValueOfType<String>(json, r'deviceId')!,
|
||||||
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
|
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
|
||||||
@@ -371,6 +379,7 @@ class AssetResponseDto {
|
|||||||
/// The list of required keys that must be present in a JSON.
|
/// The list of required keys that must be present in a JSON.
|
||||||
static const requiredKeys = <String>{
|
static const requiredKeys = <String>{
|
||||||
'checksum',
|
'checksum',
|
||||||
|
'createdAt',
|
||||||
'deviceAssetId',
|
'deviceAssetId',
|
||||||
'deviceId',
|
'deviceId',
|
||||||
'duration',
|
'duration',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
|||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.139.2+3007
|
version: 1.139.4+3009
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.8.0 <4.0.0'
|
sdk: '>=3.8.0 <4.0.0'
|
||||||
|
|||||||
@@ -9592,7 +9592,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Immich",
|
"title": "Immich",
|
||||||
"description": "Immich API",
|
"description": "Immich API",
|
||||||
"version": "1.139.2",
|
"version": "1.139.4",
|
||||||
"contact": {}
|
"contact": {}
|
||||||
},
|
},
|
||||||
"tags": [],
|
"tags": [],
|
||||||
@@ -10007,12 +10007,6 @@
|
|||||||
},
|
},
|
||||||
"AlbumsAddAssetsResponseDto": {
|
"AlbumsAddAssetsResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"albumSuccessCount": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"assetSuccessCount": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"error": {
|
"error": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
@@ -10025,8 +10019,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"albumSuccessCount",
|
|
||||||
"assetSuccessCount",
|
|
||||||
"success"
|
"success"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -10728,6 +10720,12 @@
|
|||||||
"description": "base64 encoded sha1 hash",
|
"description": "base64 encoded sha1 hash",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"description": "The UTC timestamp when the asset was originally uploaded to Immich.",
|
||||||
|
"example": "2024-01-15T20:30:00.000Z",
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"deviceAssetId": {
|
"deviceAssetId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -10863,6 +10861,7 @@
|
|||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"checksum",
|
"checksum",
|
||||||
|
"createdAt",
|
||||||
"deviceAssetId",
|
"deviceAssetId",
|
||||||
"deviceId",
|
"deviceId",
|
||||||
"duration",
|
"duration",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.139.2",
|
"version": "1.139.4",
|
||||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./build/index.js",
|
"main": "./build/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Immich
|
* Immich
|
||||||
* 1.139.2
|
* 1.139.4
|
||||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||||
* See https://www.npmjs.com/package/oazapfts
|
* See https://www.npmjs.com/package/oazapfts
|
||||||
*/
|
*/
|
||||||
@@ -317,6 +317,8 @@ export type TagResponseDto = {
|
|||||||
export type AssetResponseDto = {
|
export type AssetResponseDto = {
|
||||||
/** base64 encoded sha1 hash */
|
/** base64 encoded sha1 hash */
|
||||||
checksum: string;
|
checksum: string;
|
||||||
|
/** The UTC timestamp when the asset was originally uploaded to Immich. */
|
||||||
|
createdAt: string;
|
||||||
deviceAssetId: string;
|
deviceAssetId: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
duplicateId?: string | null;
|
duplicateId?: string | null;
|
||||||
@@ -389,8 +391,6 @@ export type AlbumsAddAssetsDto = {
|
|||||||
assetIds: string[];
|
assetIds: string[];
|
||||||
};
|
};
|
||||||
export type AlbumsAddAssetsResponseDto = {
|
export type AlbumsAddAssetsResponseDto = {
|
||||||
albumSuccessCount: number;
|
|
||||||
assetSuccessCount: number;
|
|
||||||
error?: BulkIdErrorReason;
|
error?: BulkIdErrorReason;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ export * from './fetch-errors.js';
|
|||||||
export interface InitOptions {
|
export interface InitOptions {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const init = ({ baseUrl, apiKey }: InitOptions) => {
|
export const init = ({ baseUrl, apiKey, headers }: InitOptions) => {
|
||||||
setBaseUrl(baseUrl);
|
setBaseUrl(baseUrl);
|
||||||
setApiKey(apiKey);
|
setApiKey(apiKey);
|
||||||
|
if (headers) {
|
||||||
|
setHeaders(headers);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getBaseUrl = () => defaults.baseUrl;
|
export const getBaseUrl = () => defaults.baseUrl;
|
||||||
@@ -24,6 +28,26 @@ export const setApiKey = (apiKey: string) => {
|
|||||||
defaults.headers['x-api-key'] = apiKey;
|
defaults.headers['x-api-key'] = apiKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const setHeader = (key: string, value: string) => {
|
||||||
|
assertNoApiKey(key);
|
||||||
|
defaults.headers = defaults.headers || {};
|
||||||
|
defaults.headers[key] = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setHeaders = (headers: Record<string, string>) => {
|
||||||
|
defaults.headers = defaults.headers || {};
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
assertNoApiKey(key);
|
||||||
|
defaults.headers[key] = value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertNoApiKey = (headerKey: string) => {
|
||||||
|
if (headerKey.toLowerCase() === 'x-api-key') {
|
||||||
|
throw new Error('The API key header can only be set using setApiKey().');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const getAssetOriginalPath = (id: string) => `/assets/${id}/original`;
|
export const getAssetOriginalPath = (id: string) => `/assets/${id}/original`;
|
||||||
|
|
||||||
export const getAssetThumbnailPath = (id: string) => `/assets/${id}/thumbnail`;
|
export const getAssetThumbnailPath = (id: string) => `/assets/${id}/thumbnail`;
|
||||||
|
|||||||
144
pnpm-lock.yaml
generated
144
pnpm-lock.yaml
generated
@@ -11,7 +11,7 @@ overrides:
|
|||||||
|
|
||||||
packageExtensionsChecksum: sha256-DAYr0FTkvKYnvBH4muAER9UE1FVGKhqfRU4/QwA2xPQ=
|
packageExtensionsChecksum: sha256-DAYr0FTkvKYnvBH4muAER9UE1FVGKhqfRU4/QwA2xPQ=
|
||||||
|
|
||||||
pnpmfileChecksum: sha256-7GOLcTtuczNumtarIG1mbRinBOSpiOOVzgbeV3Xp4X4=
|
pnpmfileChecksum: sha256-AG/qwrPNpmy9q60PZwCpecoYVptglTHgH+N6RKQHOM0=
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
@@ -2124,6 +2124,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-P1ml0nvOmEFdmu0smSXOqTS1sxU5tqvnc0dA4MTKV39kye+bhQnjkIKEE18fNOvxjyB86k8esoCIFM3x4RykOQ==}
|
resolution: {integrity: sha512-P1ml0nvOmEFdmu0smSXOqTS1sxU5tqvnc0dA4MTKV39kye+bhQnjkIKEE18fNOvxjyB86k8esoCIFM3x4RykOQ==}
|
||||||
engines: {node: '>=18.0'}
|
engines: {node: '>=18.0'}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.4.5':
|
||||||
|
resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.19.12':
|
'@esbuild/aix-ppc64@0.19.12':
|
||||||
resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==}
|
resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2553,11 +2556,48 @@ packages:
|
|||||||
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
||||||
engines: {node: '>=18.18'}
|
engines: {node: '>=18.18'}
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.34.2':
|
||||||
|
resolution: {integrity: sha512-OfXHZPppddivUJnqyKoi5YVeHRkkNE2zUFT2gbpKxp/JZCFYEYubnMg+gOp6lWfasPrTS+KPosKqdI+ELYVDtg==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.34.2':
|
||||||
|
resolution: {integrity: sha512-dYvWqmjU9VxqXmjEtjmvHnGqF8GrVjM2Epj9rJ6BUIXvk8slvNDJbhGFvIoXzkDhrJC2jUxNLz/GUjjvSzfw+g==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.1.0':
|
||||||
|
resolution: {integrity: sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.1.0':
|
||||||
|
resolution: {integrity: sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-arm64@1.1.0':
|
'@img/sharp-libvips-linux-arm64@1.1.0':
|
||||||
resolution: {integrity: sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==}
|
resolution: {integrity: sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.1.0':
|
||||||
|
resolution: {integrity: sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-ppc64@1.1.0':
|
||||||
|
resolution: {integrity: sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-s390x@1.1.0':
|
||||||
|
resolution: {integrity: sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-x64@1.1.0':
|
'@img/sharp-libvips-linux-x64@1.1.0':
|
||||||
resolution: {integrity: sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==}
|
resolution: {integrity: sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
@@ -2579,6 +2619,18 @@ packages:
|
|||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.34.2':
|
||||||
|
resolution: {integrity: sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-linux-s390x@0.34.2':
|
||||||
|
resolution: {integrity: sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@img/sharp-linux-x64@0.34.2':
|
'@img/sharp-linux-x64@0.34.2':
|
||||||
resolution: {integrity: sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ==}
|
resolution: {integrity: sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
@@ -2597,6 +2649,29 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@img/sharp-wasm32@0.34.2':
|
||||||
|
resolution: {integrity: sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [wasm32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-arm64@0.34.2':
|
||||||
|
resolution: {integrity: sha512-cfP/r9FdS63VA5k0xiqaNaEoGxBg9k7uE+RQGzuK9fHt7jib4zAVVseR9LsE4gJcNWgT6APKMNnCcnyOtmSEUQ==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-ia32@0.34.2':
|
||||||
|
resolution: {integrity: sha512-QLjGGvAbj0X/FXl8n1WbtQ6iVBpWU7JO94u/P2M4a8CFYsvQi4GW2mRy/JqkRx0qpBzaOdKJKw8uc930EX2AHw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.34.2':
|
||||||
|
resolution: {integrity: sha512-aUdT6zEYtDKCaxkofmmJDJYGCf0+pJg3eU9/oBuqvEeoB9dKI6ZLc/1iLJCTuJQDO4ptntAlkUmHgGjyuobZbw==}
|
||||||
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@immich/ui@0.24.1':
|
'@immich/ui@0.24.1':
|
||||||
resolution: {integrity: sha512-phJ9BHV0+OnKsxXD+5+Te5Amnb1N4ExYpRGSJPYFqutd5WXeN7kZGKZXd3CfcQ1e31SXRy4DsHSGdM1pY7AUgA==}
|
resolution: {integrity: sha512-phJ9BHV0+OnKsxXD+5+Te5Amnb1N4ExYpRGSJPYFqutd5WXeN7kZGKZXd3CfcQ1e31SXRy4DsHSGdM1pY7AUgA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -13888,6 +13963,11 @@ snapshots:
|
|||||||
- uglify-js
|
- uglify-js
|
||||||
- webpack-cli
|
- webpack-cli
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.4.5':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.19.12':
|
'@esbuild/aix-ppc64@0.19.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -14184,9 +14264,34 @@ snapshots:
|
|||||||
|
|
||||||
'@humanwhocodes/retry@0.4.3': {}
|
'@humanwhocodes/retry@0.4.3': {}
|
||||||
|
|
||||||
|
'@img/sharp-darwin-arm64@0.34.2':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-arm64': 1.1.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-darwin-x64@0.34.2':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-darwin-x64': 1.1.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-x64@1.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-arm64@1.1.0':
|
'@img/sharp-libvips-linux-arm64@1.1.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-ppc64@1.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-s390x@1.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-x64@1.1.0':
|
'@img/sharp-libvips-linux-x64@1.1.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -14201,6 +14306,16 @@ snapshots:
|
|||||||
'@img/sharp-libvips-linux-arm64': 1.1.0
|
'@img/sharp-libvips-linux-arm64': 1.1.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.34.2':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.1.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-s390x@0.34.2':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-s390x': 1.1.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@img/sharp-linux-x64@0.34.2':
|
'@img/sharp-linux-x64@0.34.2':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@img/sharp-libvips-linux-x64': 1.1.0
|
'@img/sharp-libvips-linux-x64': 1.1.0
|
||||||
@@ -14216,6 +14331,20 @@ snapshots:
|
|||||||
'@img/sharp-libvips-linuxmusl-x64': 1.1.0
|
'@img/sharp-libvips-linuxmusl-x64': 1.1.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-wasm32@0.34.2':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/runtime': 1.4.5
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-arm64@0.34.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-ia32@0.34.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.34.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@immich/ui@0.24.1(@internationalized/date@3.8.2)(svelte@5.35.5)':
|
'@immich/ui@0.24.1(@internationalized/date@3.8.2)(svelte@5.35.5)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mdi/js': 7.4.47
|
'@mdi/js': 7.4.47
|
||||||
@@ -23758,14 +23887,27 @@ snapshots:
|
|||||||
node-gyp: 11.2.0
|
node-gyp: 11.2.0
|
||||||
semver: 7.7.2
|
semver: 7.7.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@img/sharp-darwin-arm64': 0.34.2
|
||||||
|
'@img/sharp-darwin-x64': 0.34.2
|
||||||
|
'@img/sharp-libvips-darwin-arm64': 1.1.0
|
||||||
|
'@img/sharp-libvips-darwin-x64': 1.1.0
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.1.0
|
||||||
'@img/sharp-libvips-linux-arm64': 1.1.0
|
'@img/sharp-libvips-linux-arm64': 1.1.0
|
||||||
|
'@img/sharp-libvips-linux-ppc64': 1.1.0
|
||||||
|
'@img/sharp-libvips-linux-s390x': 1.1.0
|
||||||
'@img/sharp-libvips-linux-x64': 1.1.0
|
'@img/sharp-libvips-linux-x64': 1.1.0
|
||||||
'@img/sharp-libvips-linuxmusl-arm64': 1.1.0
|
'@img/sharp-libvips-linuxmusl-arm64': 1.1.0
|
||||||
'@img/sharp-libvips-linuxmusl-x64': 1.1.0
|
'@img/sharp-libvips-linuxmusl-x64': 1.1.0
|
||||||
|
'@img/sharp-linux-arm': 0.34.2
|
||||||
'@img/sharp-linux-arm64': 0.34.2
|
'@img/sharp-linux-arm64': 0.34.2
|
||||||
|
'@img/sharp-linux-s390x': 0.34.2
|
||||||
'@img/sharp-linux-x64': 0.34.2
|
'@img/sharp-linux-x64': 0.34.2
|
||||||
'@img/sharp-linuxmusl-arm64': 0.34.2
|
'@img/sharp-linuxmusl-arm64': 0.34.2
|
||||||
'@img/sharp-linuxmusl-x64': 0.34.2
|
'@img/sharp-linuxmusl-x64': 0.34.2
|
||||||
|
'@img/sharp-wasm32': 0.34.2
|
||||||
|
'@img/sharp-win32-arm64': 0.34.2
|
||||||
|
'@img/sharp-win32-ia32': 0.34.2
|
||||||
|
'@img/sharp-win32-x64': 0.34.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<br/>
|
<br/>
|
||||||
<a href="https://opensource.org/license/agpl-v3"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: AGPLv3"></a>
|
<a href="https://opensource.org/license/agpl-v3"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="Licenza: AGPLv3"></a>
|
||||||
<a href="https://discord.immich.app">
|
<a href="https://discord.immich.app">
|
||||||
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Discord"/>
|
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" alt="Discord"/>
|
||||||
</a>
|
</a>
|
||||||
<br/>
|
<br/>
|
||||||
<br/>
|
<br/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Login With Custom URL">
|
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Accedi con url personalizzato">
|
||||||
</p>
|
</p>
|
||||||
<h3 align="center">Immich - Soluzione self-hosted ad alte prestazioni per backup di foto e video</h3>
|
<h3 align="center">Soluzione ad alte prestazioni per la gestione self-hosted di foto e video</h3>
|
||||||
<br/>
|
<br/>
|
||||||
<a href="https://immich.app">
|
<a href="https://immich.app">
|
||||||
<img src="../design/immich-screenshots.png" title="Main Screenshot">
|
<img src="../design/immich-screenshots.png" title="Screenshot Principale">
|
||||||
</a>
|
</a>
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="../README.md">English</a>
|
<a href="../README.md">English</a>
|
||||||
<a href="README_ca_ES.md">Català</a>
|
<a href="README_ca_ES.md">Català</a>
|
||||||
@@ -36,64 +37,97 @@
|
|||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Declino di responsabilità
|
## Avvertenze
|
||||||
|
|
||||||
- ⚠️ Il progetto è in una fase **molto intensa** di sviluppo.
|
- ⚠️ Il progetto è in fase di sviluppo **molto attivo**.
|
||||||
- ⚠️ Possibilità di bug e cambiamenti rilevanti.
|
- ⚠️ Possono esserci bug o cambiamenti radicali, che possono non essere retrocompatibili (breaking changes).
|
||||||
- ⚠️ **Non utilizzare l'app come unico salvataggio delle tue foto e dei tuoi video.**
|
- ⚠️ **Non usare l’app come unico modo per archiviare le tue foto e i tuoi video.**
|
||||||
- ⚠️ Utilizza sempre una tecnica [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) di backup per le foto e i video a cui tieni!
|
- ⚠️ Segui sempre la regola di backup [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) per proteggere i tuoi ricordi e le foto a cui tieni!
|
||||||
|
|
||||||
## Contenuto
|
> [!NOTE]
|
||||||
|
> La documentazione principale, comprese le guide all’installazione, si trova su https://immich.app/.
|
||||||
|
|
||||||
- [Documentazione Ufficiale](https://immich.app/docs)
|
## Link utili
|
||||||
- [Roadmap](https://github.com/orgs/immich-app/projects/1)
|
|
||||||
- [Demo](#demo)
|
- [Documentazione](https://immich.app/docs)
|
||||||
- [Funzionalità](#features)
|
- [Informazioni](https://immich.app/docs/overview/introduction)
|
||||||
- [Introduzione](https://immich.app/docs/overview/introduction)
|
|
||||||
- [Installazione](https://immich.app/docs/install/requirements)
|
- [Installazione](https://immich.app/docs/install/requirements)
|
||||||
- [Linee Guida per Contribuire](https://immich.app/docs/overview/support-the-project)
|
- [Roadmap](https://immich.app/roadmap)
|
||||||
|
- [Demo](#demo)
|
||||||
## Documentazione
|
- [Funzionalità](#funzionalità)
|
||||||
|
- [Traduzioni](https://immich.app/docs/developer/translations)
|
||||||
La documentazione ufficiale, inclusa la guida all'installazione, è disponibile qui: https://immich.app/.
|
- [Contribuire](https://immich.app/docs/overview/support-the-project)
|
||||||
|
|
||||||
## Demo
|
## Demo
|
||||||
|
|
||||||
Prova la demo del progetto https://demo.immich.app. Sull'app mobile, imposta `https://demo.immich.app` come `Server Endpoint URL`
|
Accedi alla demo [qui](https://demo.immich.app).
|
||||||
|
Per l’app mobile puoi usare `https://demo.immich.app` come `Server Endpoint URL`.
|
||||||
|
|
||||||
```bash title="Demo Credential"
|
### Credenziali di accesso
|
||||||
Credenziali di accesso
|
|
||||||
email: demo@immich.app
|
|
||||||
password: demo
|
|
||||||
```
|
|
||||||
|
|
||||||
# Funzionalità
|
| Email | Password |
|
||||||
|
| --------------- | -------- |
|
||||||
|
| demo@immich.app | demo |
|
||||||
|
|
||||||
| Funzionalità | Mobile | Web |
|
## Funzionalità
|
||||||
| ---------------------------------------------- | ------ | --- |
|
|
||||||
| Caricamento e visualizzazione di foto e video | Sì | Sì |
|
| Funzionalità | Mobile | Web |
|
||||||
| Backup automatico quando l'app è in esecuzione | Sì | N/D |
|
| :------------------------------------------ | ------ | --- |
|
||||||
| Selezione degli album per backup | Sì | N/D |
|
| Caricare e visualizzare foto e video | Sì | Sì |
|
||||||
| Download foto e video sul dispositivo | Sì | Sì |
|
| Backup automatico all’apertura dell’app | Sì | N/D |
|
||||||
| Supporto multi utente | Sì | Sì |
|
| Evita la duplicazione dei file | Sì | Sì |
|
||||||
| Album e album condivisi | Sì | Sì |
|
| Backup selettivo di album | Sì | N/D |
|
||||||
| Barra di scorrimento con trascinamento | Sì | Sì |
|
| Scaricare foto e video sul dispositivo | Sì | Sì |
|
||||||
| Supporto formati raw | Sì | Sì |
|
| Supporto multi-utente | Sì | Sì |
|
||||||
| Visualizzazione metadata (EXIF, map) | Sì | Sì |
|
| Album e album condivisi | Sì | Sì |
|
||||||
| Ricerca per metadata, oggetti, volti e CLIP | Sì | Sì |
|
| Barra di scorrimento trascinabile | Sì | Sì |
|
||||||
| Funzioni di amministrazione degli utenti | No | Sì |
|
| Supporto ai formati RAW | Sì | Sì |
|
||||||
| Backup in background | Sì | N/D |
|
| Visualizzazione metadati (EXIF, mappa) | Sì | Sì |
|
||||||
| Scroll virtuale | Sì | Sì |
|
| Ricerca per metadati, oggetti, volti, CLIP | Sì | Sì |
|
||||||
| Supporto OAuth | Sì | Sì |
|
| Funzioni amministrative (gestione utenti) | No | Sì |
|
||||||
| API Keys | N/D | Sì |
|
| Backup in background | Sì | N/D |
|
||||||
| Backup e riproduzione di LivePhoto | iOS | Sì |
|
| Scorrimento virtuale | Sì | Sì |
|
||||||
| Archiviazione impostata dall'utente | Sì | Sì |
|
| Supporto OAuth | Sì | Sì |
|
||||||
| Condivisione pubblica | No | Sì |
|
| Chiavi API | N/D | Sì |
|
||||||
| Archivio e Preferiti | Sì | Sì |
|
| Backup e riproduzione LivePhoto/MotionPhoto | Sì | Sì |
|
||||||
| Mappa globale | Sì | Sì |
|
| Supporto immagini a 360° | No | Sì |
|
||||||
| Collaborazione con utenti | Sì | Sì |
|
| Struttura di archiviazione personalizzata | Sì | Sì |
|
||||||
| Riconoscimento facciale e categorizzazione | Sì | Sì |
|
| Condivisione pubblica | Sì | Sì |
|
||||||
| Ricordi (x anni fa) | Sì | Sì |
|
| Archivio e preferiti | Sì | Sì |
|
||||||
| Supporto offline | Sì | No |
|
| Mappa globale | Sì | Sì |
|
||||||
| Galleria sola lettura | Sì | Sì |
|
| Condivisione con partner | Sì | Sì |
|
||||||
| Foto raggruppate | Sì | Sì |
|
| Riconoscimento e raggruppamento facciale | Sì | Sì |
|
||||||
|
| Ricordi (anni fa) | Sì | Sì |
|
||||||
|
| Supporto offline | Sì | No |
|
||||||
|
| Galleria in sola lettura | Sì | Sì |
|
||||||
|
| Foto impilate | Sì | Sì |
|
||||||
|
| Tag | No | Sì |
|
||||||
|
| Vista per cartelle | Sì | Sì |
|
||||||
|
|
||||||
|
## Traduzioni
|
||||||
|
|
||||||
|
Scopri di più sulle traduzioni [qui](https://immich.app/docs/developer/translations).
|
||||||
|
|
||||||
|
<a href="https://hosted.weblate.org/engage/immich/">
|
||||||
|
<img src="https://hosted.weblate.org/widget/immich/immich/multi-auto.svg" alt="Stato traduzioni" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## Attività del repository
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Cronologia delle stelle
|
||||||
|
|
||||||
|
<a href="https://star-history.com/#immich-app/immich&Date">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
||||||
|
<img alt="Grafico storico delle stelle" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## Contributori
|
||||||
|
|
||||||
|
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
||||||
|
</a>
|
||||||
|
|||||||
@@ -91,9 +91,8 @@ FROM prod-builder-base AS server-prod
|
|||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
COPY ./package* ./pnpm* .pnpmfile.cjs ./
|
COPY ./package* ./pnpm* .pnpmfile.cjs ./
|
||||||
COPY ./server ./server/
|
COPY ./server ./server/
|
||||||
# SHARP_IGNORE_GLOBAL_LIBVIPS because 'deploy' will always build sharp bindings from source
|
|
||||||
RUN SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile build && \
|
RUN SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile build && \
|
||||||
pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
|
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
|
||||||
|
|
||||||
# web production build
|
# web production build
|
||||||
FROM prod-builder-base AS web-prod
|
FROM prod-builder-base AS web-prod
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true,
|
"deleteOutDir": false,
|
||||||
"webpack": false,
|
"webpack": false,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "1.139.2",
|
"version": "1.139.4",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
79
server/src/controllers/user-admin.controller.spec.ts
Normal file
79
server/src/controllers/user-admin.controller.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { UserAdminController } from 'src/controllers/user-admin.controller';
|
||||||
|
import { UserAdminCreateDto } from 'src/dtos/user.dto';
|
||||||
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { UserAdminService } from 'src/services/user-admin.service';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { errorDto } from 'test/medium/responses';
|
||||||
|
import { factory } from 'test/small.factory';
|
||||||
|
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||||
|
|
||||||
|
describe(UserAdminController.name, () => {
|
||||||
|
let ctx: ControllerContext;
|
||||||
|
const service = mockBaseService(UserAdminService);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await controllerSetup(UserAdminController, [
|
||||||
|
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
|
||||||
|
{ provide: UserAdminService, useValue: service },
|
||||||
|
]);
|
||||||
|
return () => ctx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service.resetAllMocks();
|
||||||
|
ctx.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /admin/users', () => {
|
||||||
|
it('should be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).get('/admin/users');
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /admin/users', () => {
|
||||||
|
it('should be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).post('/admin/users');
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should not allow decimal quota`, async () => {
|
||||||
|
const dto: UserAdminCreateDto = {
|
||||||
|
email: 'user@immich.app',
|
||||||
|
password: 'test',
|
||||||
|
name: 'Test User',
|
||||||
|
quotaSizeInBytes: 1.2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { status, body } = await request(ctx.getHttpServer())
|
||||||
|
.post(`/admin/users`)
|
||||||
|
.set('Authorization', `Bearer token`)
|
||||||
|
.send(dto);
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /admin/users/:id', () => {
|
||||||
|
it('should be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).get(`/admin/users/${factory.uuid()}`);
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /admin/users/:id', () => {
|
||||||
|
it('should be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).put(`/admin/users/${factory.uuid()}`);
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should not allow decimal quota`, async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer())
|
||||||
|
.put(`/admin/users/${factory.uuid()}`)
|
||||||
|
.set('Authorization', `Bearer token`)
|
||||||
|
.send({ quotaSizeInBytes: 1.2 });
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,10 +65,6 @@ export class AlbumsAddAssetsDto {
|
|||||||
|
|
||||||
export class AlbumsAddAssetsResponseDto {
|
export class AlbumsAddAssetsResponseDto {
|
||||||
success!: boolean;
|
success!: boolean;
|
||||||
@ApiProperty({ type: 'integer' })
|
|
||||||
albumSuccessCount!: number;
|
|
||||||
@ApiProperty({ type: 'integer' })
|
|
||||||
assetSuccessCount!: number;
|
|
||||||
@ValidateEnum({ enum: BulkIdErrorReason, name: 'BulkIdErrorReason', optional: true })
|
@ValidateEnum({ enum: BulkIdErrorReason, name: 'BulkIdErrorReason', optional: true })
|
||||||
error?: BulkIdErrorReason;
|
error?: BulkIdErrorReason;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ export class SanitizedAssetResponseDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AssetResponseDto extends SanitizedAssetResponseDto {
|
export class AssetResponseDto extends SanitizedAssetResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time',
|
||||||
|
description: 'The UTC timestamp when the asset was originally uploaded to Immich.',
|
||||||
|
example: '2024-01-15T20:30:00.000Z',
|
||||||
|
})
|
||||||
|
createdAt!: Date;
|
||||||
deviceAssetId!: string;
|
deviceAssetId!: string;
|
||||||
deviceId!: string;
|
deviceId!: string;
|
||||||
ownerId!: string;
|
ownerId!: string;
|
||||||
@@ -190,6 +197,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
|
createdAt: entity.createdAt,
|
||||||
deviceAssetId: entity.deviceAssetId,
|
deviceAssetId: entity.deviceAssetId,
|
||||||
ownerId: entity.ownerId,
|
ownerId: entity.ownerId,
|
||||||
owner: entity.owner ? mapUser(entity.owner) : undefined,
|
owner: entity.owner ? mapUser(entity.owner) : undefined,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
|
import { IsEmail, IsInt, IsNotEmpty, IsString, Min } from 'class-validator';
|
||||||
import { User, UserAdmin } from 'src/database';
|
import { User, UserAdmin } from 'src/database';
|
||||||
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
||||||
import { UserMetadataItem } from 'src/types';
|
import { UserMetadataItem } from 'src/types';
|
||||||
@@ -91,7 +91,7 @@ export class UserAdminCreateDto {
|
|||||||
storageLabel?: string | null;
|
storageLabel?: string | null;
|
||||||
|
|
||||||
@Optional({ nullable: true })
|
@Optional({ nullable: true })
|
||||||
@IsNumber()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
quotaSizeInBytes?: number | null;
|
quotaSizeInBytes?: number | null;
|
||||||
@@ -137,7 +137,7 @@ export class UserAdminUpdateDto {
|
|||||||
shouldChangePassword?: boolean;
|
shouldChangePassword?: boolean;
|
||||||
|
|
||||||
@Optional({ nullable: true })
|
@Optional({ nullable: true })
|
||||||
@IsNumber()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
quotaSizeInBytes?: number | null;
|
quotaSizeInBytes?: number | null;
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ with
|
|||||||
epoch
|
epoch
|
||||||
from
|
from
|
||||||
(
|
(
|
||||||
asset."localDateTime" - asset."fileCreatedAt" at time zone 'UTC'
|
asset."localDateTime" AT TIME ZONE 'UTC' - asset."fileCreatedAt" at time zone 'UTC'
|
||||||
)
|
)
|
||||||
)::real / 3600 as "localOffsetHours",
|
)::real / 3600 as "localOffsetHours",
|
||||||
"asset"."ownerId",
|
"asset"."ownerId",
|
||||||
|
|||||||
@@ -38,7 +38,11 @@ from
|
|||||||
select
|
select
|
||||||
"album".*,
|
"album".*,
|
||||||
coalesce(
|
coalesce(
|
||||||
json_agg("assets") filter (
|
json_agg(
|
||||||
|
"assets"
|
||||||
|
order by
|
||||||
|
"assets"."fileCreatedAt" asc
|
||||||
|
) filter (
|
||||||
where
|
where
|
||||||
"assets"."id" is not null
|
"assets"."id" is not null
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -321,6 +321,14 @@ export class AlbumRepository {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Chunked({ chunkSize: 30_000 })
|
||||||
|
async addAssetIdsToAlbums(values: { albumsId: string; assetsId: string }[]): Promise<void> {
|
||||||
|
if (values.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.db.insertInto('album_asset').values(values).execute();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes sure all thumbnails for albums are updated by:
|
* Makes sure all thumbnails for albums are updated by:
|
||||||
* - Removing thumbnails from albums without assets
|
* - Removing thumbnails from albums without assets
|
||||||
|
|||||||
@@ -566,7 +566,7 @@ export class AssetRepository {
|
|||||||
sql`asset.type = 'IMAGE'`.as('isImage'),
|
sql`asset.type = 'IMAGE'`.as('isImage'),
|
||||||
sql`asset."deletedAt" is not null`.as('isTrashed'),
|
sql`asset."deletedAt" is not null`.as('isTrashed'),
|
||||||
'asset.livePhotoVideoId',
|
'asset.livePhotoVideoId',
|
||||||
sql`extract(epoch from (asset."localDateTime" - asset."fileCreatedAt" at time zone 'UTC'))::real / 3600`.as(
|
sql`extract(epoch from (asset."localDateTime" AT TIME ZONE 'UTC' - asset."fileCreatedAt" at time zone 'UTC'))::real / 3600`.as(
|
||||||
'localOffsetHours',
|
'localOffsetHours',
|
||||||
),
|
),
|
||||||
'asset.ownerId',
|
'asset.ownerId',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ExpressionBuilder, Insertable, Kysely, Selectable, sql, Updateable } from 'kysely';
|
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable } from 'kysely';
|
||||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||||
@@ -68,12 +68,6 @@ const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
|
|||||||
).as('person');
|
).as('person');
|
||||||
};
|
};
|
||||||
|
|
||||||
const withAsset = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
|
|
||||||
return jsonObjectFrom(eb.selectFrom('asset').selectAll('asset').whereRef('asset.id', '=', 'asset_face.assetId')).as(
|
|
||||||
'asset',
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
|
const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
|
||||||
return jsonObjectFrom(
|
return jsonObjectFrom(
|
||||||
eb.selectFrom('face_search').selectAll('face_search').whereRef('face_search.faceId', '=', 'asset_face.id'),
|
eb.selectFrom('face_search').selectAll('face_search').whereRef('face_search.faceId', '=', 'asset_face.id'),
|
||||||
@@ -481,7 +475,12 @@ export class PersonRepository {
|
|||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.selectAll('asset_face')
|
.selectAll('asset_face')
|
||||||
.select(withAsset)
|
.select((eb) =>
|
||||||
|
jsonObjectFrom(eb.selectFrom('asset').selectAll('asset').whereRef('asset.id', '=', 'asset_face.assetId')).as(
|
||||||
|
'asset',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.$narrowType<{ asset: NotNull }>()
|
||||||
.select(withPerson)
|
.select(withPerson)
|
||||||
.where('asset_face.assetId', 'in', assetIds)
|
.where('asset_face.assetId', 'in', assetIds)
|
||||||
.where('asset_face.personId', 'in', personIds)
|
.where('asset_face.personId', 'in', personIds)
|
||||||
|
|||||||
@@ -86,7 +86,16 @@ export class SharedLinkRepository {
|
|||||||
(join) => join.onTrue(),
|
(join) => join.onTrue(),
|
||||||
)
|
)
|
||||||
.select((eb) =>
|
.select((eb) =>
|
||||||
eb.fn.coalesce(eb.fn.jsonAgg('assets').filterWhere('assets.id', 'is not', null), sql`'[]'`).as('assets'),
|
eb.fn
|
||||||
|
.coalesce(
|
||||||
|
eb.fn
|
||||||
|
.jsonAgg('assets')
|
||||||
|
.orderBy('assets.fileCreatedAt', 'asc')
|
||||||
|
.filterWhere('assets.id', 'is not', null),
|
||||||
|
|
||||||
|
sql`'[]'`,
|
||||||
|
)
|
||||||
|
.as('assets'),
|
||||||
)
|
)
|
||||||
.select((eb) => eb.fn.toJson('owner').as('owner'))
|
.select((eb) => eb.fn.toJson('owner').as('owner'))
|
||||||
.groupBy(['album.id', sql`"owner".*`])
|
.groupBy(['album.id', sql`"owner".*`])
|
||||||
|
|||||||
@@ -778,9 +778,7 @@ describe(AlbumService.name, () => {
|
|||||||
|
|
||||||
describe('addAssetsToAlbums', () => {
|
describe('addAssetsToAlbums', () => {
|
||||||
it('should allow the owner to add assets', async () => {
|
it('should allow the owner to add assets', async () => {
|
||||||
mocks.access.album.checkOwnerAccess
|
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321']));
|
||||||
.mockResolvedValueOnce(new Set(['album-123']))
|
|
||||||
.mockResolvedValueOnce(new Set(['album-321']));
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||||
mocks.album.getById
|
mocks.album.getById
|
||||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
||||||
@@ -792,7 +790,7 @@ describe(AlbumService.name, () => {
|
|||||||
albumIds: ['album-123', 'album-321'],
|
albumIds: ['album-123', 'album-321'],
|
||||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
|
).resolves.toEqual({ success: true, error: undefined });
|
||||||
|
|
||||||
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
||||||
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||||
@@ -805,14 +803,18 @@ describe(AlbumService.name, () => {
|
|||||||
updatedAt: expect.any(Date),
|
updatedAt: expect.any(Date),
|
||||||
albumThumbnailAssetId: 'asset-1',
|
albumThumbnailAssetId: 'asset-1',
|
||||||
});
|
});
|
||||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
|
||||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
{ albumsId: 'album-123', assetsId: 'asset-1' },
|
||||||
|
{ albumsId: 'album-123', assetsId: 'asset-2' },
|
||||||
|
{ albumsId: 'album-123', assetsId: 'asset-3' },
|
||||||
|
{ albumsId: 'album-321', assetsId: 'asset-1' },
|
||||||
|
{ albumsId: 'album-321', assetsId: 'asset-2' },
|
||||||
|
{ albumsId: 'album-321', assetsId: 'asset-3' },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not set the thumbnail if the album has one already', async () => {
|
it('should not set the thumbnail if the album has one already', async () => {
|
||||||
mocks.access.album.checkOwnerAccess
|
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321']));
|
||||||
.mockResolvedValueOnce(new Set(['album-123']))
|
|
||||||
.mockResolvedValueOnce(new Set(['album-321']));
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||||
mocks.album.getById
|
mocks.album.getById
|
||||||
.mockResolvedValueOnce(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' }))
|
.mockResolvedValueOnce(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' }))
|
||||||
@@ -824,7 +826,7 @@ describe(AlbumService.name, () => {
|
|||||||
albumIds: ['album-123', 'album-321'],
|
albumIds: ['album-123', 'album-321'],
|
||||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
|
).resolves.toEqual({ success: true, error: undefined });
|
||||||
|
|
||||||
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
||||||
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||||
@@ -837,14 +839,18 @@ describe(AlbumService.name, () => {
|
|||||||
updatedAt: expect.any(Date),
|
updatedAt: expect.any(Date),
|
||||||
albumThumbnailAssetId: 'asset-id',
|
albumThumbnailAssetId: 'asset-id',
|
||||||
});
|
});
|
||||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
|
||||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
{ albumsId: 'album-123', assetsId: 'asset-1' },
|
||||||
|
{ albumsId: 'album-123', assetsId: 'asset-2' },
|
||||||
|
{ albumsId: 'album-123', assetsId: 'asset-3' },
|
||||||
|
{ albumsId: 'album-321', assetsId: 'asset-1' },
|
||||||
|
{ albumsId: 'album-321', assetsId: 'asset-2' },
|
||||||
|
{ albumsId: 'album-321', assetsId: 'asset-3' },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow a shared user to add assets', async () => {
|
it('should allow a shared user to add assets', async () => {
|
||||||
mocks.access.album.checkSharedAlbumAccess
|
mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321']));
|
||||||
.mockResolvedValueOnce(new Set(['album-123']))
|
|
||||||
.mockResolvedValueOnce(new Set(['album-321']));
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||||
mocks.album.getById
|
mocks.album.getById
|
||||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
|
||||||
@@ -856,7 +862,7 @@ describe(AlbumService.name, () => {
|
|||||||
albumIds: ['album-123', 'album-321'],
|
albumIds: ['album-123', 'album-321'],
|
||||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
|
).resolves.toEqual({ success: true, error: undefined });
|
||||||
|
|
||||||
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
||||||
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||||
@@ -869,8 +875,14 @@ describe(AlbumService.name, () => {
|
|||||||
updatedAt: expect.any(Date),
|
updatedAt: expect.any(Date),
|
||||||
albumThumbnailAssetId: 'asset-1',
|
albumThumbnailAssetId: 'asset-1',
|
||||||
});
|
});
|
||||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
|
||||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
{ albumsId: 'album-123', assetsId: 'asset-1' },
|
||||||
|
{ albumsId: 'album-123', assetsId: 'asset-2' },
|
||||||
|
{ albumsId: 'album-123', assetsId: 'asset-3' },
|
||||||
|
{ albumsId: 'album-321', assetsId: 'asset-1' },
|
||||||
|
{ albumsId: 'album-321', assetsId: 'asset-2' },
|
||||||
|
{ albumsId: 'album-321', assetsId: 'asset-3' },
|
||||||
|
]);
|
||||||
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
|
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
|
||||||
id: 'album-123',
|
id: 'album-123',
|
||||||
recipientId: 'admin_id',
|
recipientId: 'admin_id',
|
||||||
@@ -896,18 +908,14 @@ describe(AlbumService.name, () => {
|
|||||||
}),
|
}),
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
success: false,
|
success: false,
|
||||||
albumSuccessCount: 0,
|
error: BulkIdErrorReason.NO_PERMISSION,
|
||||||
assetSuccessCount: 0,
|
|
||||||
error: BulkIdErrorReason.UNKNOWN,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not allow a shared link user to add assets to multiple albums', async () => {
|
it('should not allow a shared link user to add assets to multiple albums', async () => {
|
||||||
mocks.access.album.checkSharedLinkAccess
|
mocks.access.album.checkSharedLinkAccess.mockResolvedValueOnce(new Set(['album-123']));
|
||||||
.mockResolvedValueOnce(new Set(['album-123']))
|
|
||||||
.mockResolvedValueOnce(new Set());
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||||
mocks.album.getById
|
mocks.album.getById
|
||||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
|
||||||
@@ -919,7 +927,7 @@ describe(AlbumService.name, () => {
|
|||||||
albumIds: ['album-123', 'album-321'],
|
albumIds: ['album-123', 'album-321'],
|
||||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual({ success: true, albumSuccessCount: 1, assetSuccessCount: 3 });
|
).resolves.toEqual({ success: true, error: undefined });
|
||||||
|
|
||||||
expect(mocks.album.update).toHaveBeenCalledTimes(1);
|
expect(mocks.album.update).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||||
@@ -927,22 +935,23 @@ describe(AlbumService.name, () => {
|
|||||||
updatedAt: expect.any(Date),
|
updatedAt: expect.any(Date),
|
||||||
albumThumbnailAssetId: 'asset-1',
|
albumThumbnailAssetId: 'asset-1',
|
||||||
});
|
});
|
||||||
expect(mocks.album.addAssetIds).toHaveBeenCalledTimes(1);
|
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
|
||||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
{ albumsId: 'album-123', assetsId: 'asset-1' },
|
||||||
|
{ albumsId: 'album-123', assetsId: 'asset-2' },
|
||||||
|
{ albumsId: 'album-123', assetsId: 'asset-3' },
|
||||||
|
]);
|
||||||
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
|
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
|
||||||
id: 'album-123',
|
id: 'album-123',
|
||||||
recipientId: 'user-id',
|
recipientId: 'user-id',
|
||||||
});
|
});
|
||||||
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(
|
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(
|
||||||
authStub.adminSharedLink.sharedLink?.id,
|
authStub.adminSharedLink.sharedLink?.id,
|
||||||
new Set(['album-123']),
|
new Set(['album-123', 'album-321']),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow adding assets shared via partner sharing', async () => {
|
it('should allow adding assets shared via partner sharing', async () => {
|
||||||
mocks.access.album.checkOwnerAccess
|
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321']));
|
||||||
.mockResolvedValueOnce(new Set(['album-123']))
|
|
||||||
.mockResolvedValueOnce(new Set(['album-321']));
|
|
||||||
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||||
mocks.album.getById
|
mocks.album.getById
|
||||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
||||||
@@ -954,7 +963,7 @@ describe(AlbumService.name, () => {
|
|||||||
albumIds: ['album-123', 'album-321'],
|
albumIds: ['album-123', 'album-321'],
|
||||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
|
).resolves.toEqual({ success: true, error: undefined });
|
||||||
|
|
||||||
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
||||||
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||||
@@ -967,8 +976,14 @@ describe(AlbumService.name, () => {
|
|||||||
updatedAt: expect.any(Date),
|
updatedAt: expect.any(Date),
|
||||||
albumThumbnailAssetId: 'asset-1',
|
albumThumbnailAssetId: 'asset-1',
|
||||||
});
|
});
|
||||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
|
||||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
{ albumsId: 'album-123', assetsId: 'asset-1' },
|
||||||
|
{ albumsId: 'album-123', assetsId: 'asset-2' },
|
||||||
|
{ albumsId: 'album-123', assetsId: 'asset-3' },
|
||||||
|
{ albumsId: 'album-321', assetsId: 'asset-1' },
|
||||||
|
{ albumsId: 'album-321', assetsId: 'asset-2' },
|
||||||
|
{ albumsId: 'album-321', assetsId: 'asset-3' },
|
||||||
|
]);
|
||||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(
|
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(
|
||||||
authStub.admin.user.id,
|
authStub.admin.user.id,
|
||||||
new Set(['asset-1', 'asset-2', 'asset-3']),
|
new Set(['asset-1', 'asset-2', 'asset-3']),
|
||||||
@@ -976,23 +991,21 @@ describe(AlbumService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should skip some duplicate assets', async () => {
|
it('should skip some duplicate assets', async () => {
|
||||||
mocks.access.album.checkOwnerAccess
|
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321']));
|
||||||
.mockResolvedValueOnce(new Set(['album-123']))
|
|
||||||
.mockResolvedValueOnce(new Set(['album-321']));
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||||
mocks.album.getById
|
|
||||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
|
||||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
|
|
||||||
mocks.album.getAssetIds
|
mocks.album.getAssetIds
|
||||||
.mockResolvedValueOnce(new Set(['asset-1', 'asset-2', 'asset-3']))
|
.mockResolvedValueOnce(new Set(['asset-1', 'asset-2', 'asset-3']))
|
||||||
.mockResolvedValueOnce(new Set());
|
.mockResolvedValueOnce(new Set());
|
||||||
|
mocks.album.getById
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
||||||
|
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.addAssetsToAlbums(authStub.admin, {
|
sut.addAssetsToAlbums(authStub.admin, {
|
||||||
albumIds: ['album-123', 'album-321'],
|
albumIds: ['album-123', 'album-321'],
|
||||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual({ success: true, albumSuccessCount: 1, assetSuccessCount: 3 });
|
).resolves.toEqual({ success: true, error: undefined });
|
||||||
|
|
||||||
expect(mocks.album.update).toHaveBeenCalledTimes(1);
|
expect(mocks.album.update).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-321', {
|
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-321', {
|
||||||
@@ -1000,8 +1013,11 @@ describe(AlbumService.name, () => {
|
|||||||
updatedAt: expect.any(Date),
|
updatedAt: expect.any(Date),
|
||||||
albumThumbnailAssetId: 'asset-1',
|
albumThumbnailAssetId: 'asset-1',
|
||||||
});
|
});
|
||||||
expect(mocks.album.addAssetIds).toHaveBeenCalledTimes(1);
|
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
|
||||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
{ albumsId: 'album-321', assetsId: 'asset-1' },
|
||||||
|
{ albumsId: 'album-321', assetsId: 'asset-2' },
|
||||||
|
{ albumsId: 'album-321', assetsId: 'asset-3' },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip all duplicate assets', async () => {
|
it('should skip all duplicate assets', async () => {
|
||||||
@@ -1021,8 +1037,6 @@ describe(AlbumService.name, () => {
|
|||||||
}),
|
}),
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
success: false,
|
success: false,
|
||||||
albumSuccessCount: 0,
|
|
||||||
assetSuccessCount: 0,
|
|
||||||
error: BulkIdErrorReason.DUPLICATE,
|
error: BulkIdErrorReason.DUPLICATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1046,9 +1060,7 @@ describe(AlbumService.name, () => {
|
|||||||
}),
|
}),
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
success: false,
|
success: false,
|
||||||
albumSuccessCount: 0,
|
error: BulkIdErrorReason.NO_PERMISSION,
|
||||||
assetSuccessCount: 0,
|
|
||||||
error: BulkIdErrorReason.UNKNOWN,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||||
@@ -1076,9 +1088,7 @@ describe(AlbumService.name, () => {
|
|||||||
}),
|
}),
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
success: false,
|
success: false,
|
||||||
albumSuccessCount: 0,
|
error: BulkIdErrorReason.NO_PERMISSION,
|
||||||
assetSuccessCount: 0,
|
|
||||||
error: BulkIdErrorReason.UNKNOWN,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||||
@@ -1099,9 +1109,7 @@ describe(AlbumService.name, () => {
|
|||||||
}),
|
}),
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
success: false,
|
success: false,
|
||||||
albumSuccessCount: 0,
|
error: BulkIdErrorReason.NO_PERMISSION,
|
||||||
assetSuccessCount: 0,
|
|
||||||
error: BulkIdErrorReason.UNKNOWN,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalled();
|
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -191,36 +191,57 @@ export class AlbumService extends BaseService {
|
|||||||
async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
|
async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
|
||||||
const results: AlbumsAddAssetsResponseDto = {
|
const results: AlbumsAddAssetsResponseDto = {
|
||||||
success: false,
|
success: false,
|
||||||
albumSuccessCount: 0,
|
|
||||||
assetSuccessCount: 0,
|
|
||||||
error: BulkIdErrorReason.DUPLICATE,
|
error: BulkIdErrorReason.DUPLICATE,
|
||||||
};
|
};
|
||||||
const successfulAssetIds: Set<string> = new Set();
|
|
||||||
for (const albumId of dto.albumIds) {
|
|
||||||
try {
|
|
||||||
const albumResults = await this.addAssets(auth, albumId, { ids: dto.assetIds });
|
|
||||||
|
|
||||||
let success = false;
|
const allowedAlbumIds = await this.checkAccess({
|
||||||
for (const res of albumResults) {
|
auth,
|
||||||
if (res.success) {
|
permission: Permission.AlbumAssetCreate,
|
||||||
success = true;
|
ids: dto.albumIds,
|
||||||
results.success = true;
|
});
|
||||||
results.error = undefined;
|
if (allowedAlbumIds.size === 0) {
|
||||||
successfulAssetIds.add(res.id);
|
results.error = BulkIdErrorReason.NO_PERMISSION;
|
||||||
} else if (results.error && res.error !== BulkIdErrorReason.DUPLICATE) {
|
return results;
|
||||||
results.error = BulkIdErrorReason.UNKNOWN;
|
}
|
||||||
}
|
|
||||||
}
|
const allowedAssetIds = await this.checkAccess({ auth, permission: Permission.AssetShare, ids: dto.assetIds });
|
||||||
if (success) {
|
if (allowedAssetIds.size === 0) {
|
||||||
results.albumSuccessCount++;
|
results.error = BulkIdErrorReason.NO_PERMISSION;
|
||||||
}
|
return results;
|
||||||
} catch {
|
}
|
||||||
if (results.error) {
|
|
||||||
results.error = BulkIdErrorReason.UNKNOWN;
|
const albumAssetValues: { albumsId: string; assetsId: string }[] = [];
|
||||||
}
|
const events: { id: string; recipients: string[] }[] = [];
|
||||||
|
for (const albumId of allowedAlbumIds) {
|
||||||
|
const existingAssetIds = await this.albumRepository.getAssetIds(albumId, [...allowedAssetIds]);
|
||||||
|
const notPresentAssetIds = [...allowedAssetIds].filter((id) => !existingAssetIds.has(id));
|
||||||
|
if (notPresentAssetIds.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const album = await this.findOrFail(albumId, { withAssets: false });
|
||||||
|
results.error = undefined;
|
||||||
|
results.success = true;
|
||||||
|
|
||||||
|
for (const assetId of notPresentAssetIds) {
|
||||||
|
albumAssetValues.push({ albumsId: albumId, assetsId: assetId });
|
||||||
|
}
|
||||||
|
await this.albumRepository.update(albumId, {
|
||||||
|
id: albumId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
albumThumbnailAssetId: album.albumThumbnailAssetId ?? notPresentAssetIds[0],
|
||||||
|
});
|
||||||
|
const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter(
|
||||||
|
(userId) => userId !== auth.user.id,
|
||||||
|
);
|
||||||
|
events.push({ id: albumId, recipients: allUsersExceptUs });
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.albumRepository.addAssetIdsToAlbums(albumAssetValues);
|
||||||
|
for (const event of events) {
|
||||||
|
for (const recipientId of event.recipients) {
|
||||||
|
await this.eventRepository.emit('AlbumUpdate', { id: event.id, recipientId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
results.assetSuccessCount = successfulAssetIds.size;
|
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ export class LibraryService extends BaseService {
|
|||||||
{
|
{
|
||||||
usePolling: false,
|
usePolling: false,
|
||||||
ignoreInitial: true,
|
ignoreInitial: true,
|
||||||
|
awaitWriteFinish: {
|
||||||
|
stabilityThreshold: 5000,
|
||||||
|
pollInterval: 1000,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onReady: () => _resolve(),
|
onReady: () => _resolve(),
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ describe(MediaService.name, () => {
|
|||||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.image]));
|
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.image]));
|
||||||
|
|
||||||
mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail]));
|
mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail]));
|
||||||
mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
|
||||||
|
|
||||||
await sut.handleQueueGenerateThumbnails({ force: true });
|
await sut.handleQueueGenerateThumbnails({ force: true });
|
||||||
|
|
||||||
|
|||||||
@@ -197,6 +197,10 @@ export class PersonService extends BaseService {
|
|||||||
throw new BadRequestException('Invalid assetId for feature face');
|
throw new BadRequestException('Invalid assetId for feature face');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (face.asset.isOffline) {
|
||||||
|
throw new BadRequestException('An offline asset cannot be used for feature face');
|
||||||
|
}
|
||||||
|
|
||||||
faceId = face.id;
|
faceId = face.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
server/test/fixtures/shared-link.stub.ts
vendored
1
server/test/fixtures/shared-link.stub.ts
vendored
@@ -46,6 +46,7 @@ const assetInfo: ExifResponseDto = {
|
|||||||
|
|
||||||
const assetResponse: AssetResponseDto = {
|
const assetResponse: AssetResponseDto = {
|
||||||
id: 'id_1',
|
id: 'id_1',
|
||||||
|
createdAt: today,
|
||||||
deviceAssetId: 'device_asset_id_1',
|
deviceAssetId: 'device_asset_id_1',
|
||||||
ownerId: 'user_id_1',
|
ownerId: 'user_id_1',
|
||||||
deviceId: 'device_id_1',
|
deviceId: 'device_id_1',
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
|
|||||||
import { PersonRepository } from 'src/repositories/person.repository';
|
import { PersonRepository } from 'src/repositories/person.repository';
|
||||||
import { SearchRepository } from 'src/repositories/search.repository';
|
import { SearchRepository } from 'src/repositories/search.repository';
|
||||||
import { SessionRepository } from 'src/repositories/session.repository';
|
import { SessionRepository } from 'src/repositories/session.repository';
|
||||||
|
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
|
||||||
import { StackRepository } from 'src/repositories/stack.repository';
|
import { StackRepository } from 'src/repositories/stack.repository';
|
||||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository';
|
import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository';
|
||||||
@@ -286,6 +287,7 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
|
|||||||
case PersonRepository:
|
case PersonRepository:
|
||||||
case SearchRepository:
|
case SearchRepository:
|
||||||
case SessionRepository:
|
case SessionRepository:
|
||||||
|
case SharedLinkRepository:
|
||||||
case StackRepository:
|
case StackRepository:
|
||||||
case SyncRepository:
|
case SyncRepository:
|
||||||
case SyncCheckpointRepository:
|
case SyncCheckpointRepository:
|
||||||
@@ -391,7 +393,7 @@ const assetInsert = (asset: Partial<Insertable<AssetTable>> = {}) => {
|
|||||||
checksum: randomBytes(32),
|
checksum: randomBytes(32),
|
||||||
type: AssetType.Image,
|
type: AssetType.Image,
|
||||||
originalPath: '/path/to/something.jpg',
|
originalPath: '/path/to/something.jpg',
|
||||||
ownerId: '@immich.cloud',
|
ownerId: 'not-a-valid-uuid',
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
fileCreatedAt: now,
|
fileCreatedAt: now,
|
||||||
fileModifiedAt: now,
|
fileModifiedAt: now,
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { SharedLinkType } from 'src/enum';
|
||||||
|
import { AccessRepository } from 'src/repositories/access.repository';
|
||||||
|
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||||
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
|
||||||
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
|
import { DB } from 'src/schema';
|
||||||
|
import { SharedLinkService } from 'src/services/shared-link.service';
|
||||||
|
import { newMediumService } from 'test/medium.factory';
|
||||||
|
import { factory } from 'test/small.factory';
|
||||||
|
import { getKyselyDB } from 'test/utils';
|
||||||
|
|
||||||
|
let defaultDatabase: Kysely<DB>;
|
||||||
|
|
||||||
|
const setup = (db?: Kysely<DB>) => {
|
||||||
|
return newMediumService(SharedLinkService, {
|
||||||
|
database: db || defaultDatabase,
|
||||||
|
real: [AccessRepository, DatabaseRepository, SharedLinkRepository],
|
||||||
|
mock: [LoggingRepository, StorageRepository],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
defaultDatabase = await getKyselyDB();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(SharedLinkService.name, () => {
|
||||||
|
describe('get', () => {
|
||||||
|
it('should return the correct dates on the shared link album', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const auth = factory.auth({ user });
|
||||||
|
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||||
|
|
||||||
|
const dates = ['2021-01-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z'];
|
||||||
|
|
||||||
|
for (const date of dates) {
|
||||||
|
const { asset } = await ctx.newAsset({ fileCreatedAt: date, localDateTime: date, ownerId: user.id });
|
||||||
|
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
|
||||||
|
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||||
|
|
||||||
|
const sharedLink = await sharedLinkRepo.create({
|
||||||
|
key: randomBytes(16),
|
||||||
|
id: factory.uuid(),
|
||||||
|
userId: user.id,
|
||||||
|
albumId: album.id,
|
||||||
|
allowUpload: true,
|
||||||
|
type: SharedLinkType.Album,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({
|
||||||
|
album: expect.objectContaining({
|
||||||
|
startDate: '2020-01-01T00:00:00+00:00',
|
||||||
|
endDate: '2022-01-01T00:00:00+00:00',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-web",
|
"name": "immich-web",
|
||||||
"version": "1.139.2",
|
"version": "1.139.4",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -7,12 +7,10 @@
|
|||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
||||||
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
|
||||||
import { AssetMediaSize } from '@immich/sdk';
|
import { AssetMediaSize } from '@immich/sdk';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import type { SwipeCustomEvent } from 'svelte-gestures';
|
import type { SwipeCustomEvent } from 'svelte-gestures';
|
||||||
import { swipe } from 'svelte-gestures';
|
import { swipe } from 'svelte-gestures';
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -40,7 +38,6 @@
|
|||||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||||
let isLoading = $state(true);
|
let isLoading = $state(true);
|
||||||
let assetFileUrl = $state('');
|
let assetFileUrl = $state('');
|
||||||
let forceMuted = $state(false);
|
|
||||||
let isScrubbing = $state(false);
|
let isScrubbing = $state(false);
|
||||||
let showVideo = $state(false);
|
let showVideo = $state(false);
|
||||||
|
|
||||||
@@ -49,7 +46,6 @@
|
|||||||
showVideo = true;
|
showVideo = true;
|
||||||
assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey });
|
assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey });
|
||||||
if (videoPlayer) {
|
if (videoPlayer) {
|
||||||
forceMuted = false;
|
|
||||||
videoPlayer.load();
|
videoPlayer.load();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -67,23 +63,27 @@
|
|||||||
onVideoStarted();
|
onVideoStarted();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DOMException && error.name === 'NotAllowedError' && !forceMuted) {
|
if (error instanceof DOMException && error.name === 'NotAllowedError') {
|
||||||
await tryForceMutedPlay(video);
|
await tryForceMutedPlay(video);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleError(error, $t('errors.unable_to_play_video'));
|
// auto-play failed
|
||||||
} finally {
|
} finally {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const tryForceMutedPlay = async (video: HTMLVideoElement) => {
|
const tryForceMutedPlay = async (video: HTMLVideoElement) => {
|
||||||
|
if (video.muted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
video.muted = true;
|
video.muted = true;
|
||||||
await handleCanPlay(video);
|
await handleCanPlay(video);
|
||||||
} catch (error) {
|
} catch {
|
||||||
handleError(error, $t('errors.unable_to_play_video'));
|
// muted auto-play failed
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,18 +134,14 @@
|
|||||||
onswipe={onSwipe}
|
onswipe={onSwipe}
|
||||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||||
onended={onVideoEnded}
|
onended={onVideoEnded}
|
||||||
onvolumechange={(e) => {
|
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
|
||||||
if (!forceMuted) {
|
|
||||||
$videoViewerMuted = e.currentTarget.muted;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onseeking={() => (isScrubbing = true)}
|
onseeking={() => (isScrubbing = true)}
|
||||||
onseeked={() => (isScrubbing = false)}
|
onseeked={() => (isScrubbing = false)}
|
||||||
onplaying={(e) => {
|
onplaying={(e) => {
|
||||||
e.currentTarget.focus();
|
e.currentTarget.focus();
|
||||||
}}
|
}}
|
||||||
onclose={() => onClose()}
|
onclose={() => onClose()}
|
||||||
muted={forceMuted || $videoViewerMuted}
|
muted={$videoViewerMuted}
|
||||||
bind:volume={$videoViewerVolume}
|
bind:volume={$videoViewerVolume}
|
||||||
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||||
src={assetFileUrl}
|
src={assetFileUrl}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="dark w-1/2 aspect-square bg-light rounded-3xl dark:border-[3px] dark:border-immich-dark-primary border border-transparent"
|
class="w-1/2 aspect-square bg-dark dark:bg-light rounded-3xl transition-all shadow-sm hover:shadow-xl dark:border-[3px] dark:border-immich-dark-primary border border-transparent"
|
||||||
onclick={() => themeManager.setTheme(Theme.DARK)}
|
onclick={() => themeManager.setTheme(Theme.DARK)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const oninput = () => {
|
const oninput = () => {
|
||||||
if (!value) {
|
// value can be 0
|
||||||
|
if (value === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -419,14 +419,22 @@ export class TimelineManager {
|
|||||||
if (!this.isInitialized) {
|
if (!this.isInitialized) {
|
||||||
await this.initTask.waitUntilCompletion();
|
await this.initTask.waitUntilCompletion();
|
||||||
}
|
}
|
||||||
|
|
||||||
let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {};
|
let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {};
|
||||||
if (monthGroup) {
|
if (monthGroup) {
|
||||||
return monthGroup;
|
return monthGroup;
|
||||||
}
|
}
|
||||||
const asset = toTimelineAsset(await getAssetInfo({ ...authManager.params, id }));
|
|
||||||
|
const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null);
|
||||||
|
if (!response) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = toTimelineAsset(response);
|
||||||
if (!asset || this.isExcluded(asset)) {
|
if (!asset || this.isExcluded(asset)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
monthGroup = await this.#loadMonthGroupAtTime(asset.localDateTime, { cancelable: false });
|
monthGroup = await this.#loadMonthGroupAtTime(asset.localDateTime, { cancelable: false });
|
||||||
if (monthGroup?.findAssetById({ id })) {
|
if (monthGroup?.findAssetById({ id })) {
|
||||||
return monthGroup;
|
return monthGroup;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
} from '$lib/components/shared-components/album-selection/album-selection-utils';
|
} from '$lib/components/shared-components/album-selection/album-selection-utils';
|
||||||
import { albumViewSettings } from '$lib/stores/preferences.store';
|
import { albumViewSettings } from '$lib/stores/preferences.store';
|
||||||
import { createAlbum, getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
|
import { createAlbum, getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
|
||||||
import { Button, Modal, ModalBody } from '@immich/ui';
|
import { Button, Icon, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
|
||||||
|
import { mdiKeyboardReturn } from '@mdi/js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import AlbumListItem from '../components/asset-viewer/album-list-item.svelte';
|
import AlbumListItem from '../components/asset-viewer/album-list-item.svelte';
|
||||||
@@ -74,9 +75,9 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMultiSubmit = () => {
|
const handleMultiSubmit = () => {
|
||||||
const albums = new Set(albumModalRows.filter((row) => row.multiSelected).map(({ album }) => album!));
|
const selectedAlbums = new Set(albums.filter(({ id }) => multiSelectedAlbumIds.includes(id)));
|
||||||
if (albums.size > 0) {
|
if (selectedAlbums.size > 0) {
|
||||||
onClose([...albums]);
|
onClose([...selectedAlbums]);
|
||||||
} else {
|
} else {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
@@ -133,7 +134,7 @@
|
|||||||
await onEnter();
|
await onEnter();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'm': {
|
case 'Control': {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleMultiSelect();
|
handleMultiSelect();
|
||||||
break;
|
break;
|
||||||
@@ -199,4 +200,22 @@
|
|||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<div class="flex justify-around w-full">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="flex gap-1 place-items-center">
|
||||||
|
<span class="bg-gray-300 dark:bg-gray-500 rounded p-1">
|
||||||
|
<Icon icon={mdiKeyboardReturn} size="1rem" />
|
||||||
|
</span>
|
||||||
|
<Text size="tiny">{$t('to_select')}</Text>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 place-items-center">
|
||||||
|
<span class="bg-gray-300 dark:bg-gray-500 rounded p-1">
|
||||||
|
<Text size="tiny">CTRL</Text>
|
||||||
|
</span>
|
||||||
|
<Text size="tiny">{$t('to_multi_select')}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -122,7 +122,7 @@
|
|||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label={$t('admin.quota_size_gib')}>
|
<Field label={$t('admin.quota_size_gib')}>
|
||||||
<Input bind:value={quotaSize} type="number" placeholder={$t('unlimited')} min="0" />
|
<Input bind:value={quotaSize} type="number" placeholder={$t('unlimited')} min="0" step="1" />
|
||||||
{#if quotaSizeWarning}
|
{#if quotaSizeWarning}
|
||||||
<HelperText color="danger">{$t('errors.quota_higher_than_disk_size')}</HelperText>
|
<HelperText color="danger">{$t('errors.quota_higher_than_disk_size')}</HelperText>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
name="quotaSize"
|
name="quotaSize"
|
||||||
placeholder={$t('unlimited')}
|
placeholder={$t('unlimited')}
|
||||||
type="number"
|
type="number"
|
||||||
|
step="1"
|
||||||
min="0"
|
min="0"
|
||||||
bind:value={quotaSize}
|
bind:value={quotaSize}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const sleep = (ms: number) => {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
};
|
||||||
|
|
||||||
export const uploadRequest = async <T>(options: UploadRequestOptions): Promise<{ data: T; status: number }> => {
|
export const uploadRequest = async <T>(options: UploadRequestOptions): Promise<{ data: T; status: number }> => {
|
||||||
const { onUploadProgress: onProgress, data, url } = options;
|
const { onUploadProgress: onProgress, data, url } = options;
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
|||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
import { downloadRequest, withError } from '$lib/utils';
|
import { downloadRequest, sleep, withError } from '$lib/utils';
|
||||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
@@ -278,7 +278,12 @@ export const downloadFile = async (asset: AssetResponseDto) => {
|
|||||||
|
|
||||||
const queryParams = asQueryString(authManager.params);
|
const queryParams = asQueryString(authManager.params);
|
||||||
|
|
||||||
for (const { filename, id } of assets) {
|
for (const [i, { filename, id }] of assets.entries()) {
|
||||||
|
if (i !== 0) {
|
||||||
|
// play nice with Safari
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
notificationController.show({
|
notificationController.show({
|
||||||
type: NotificationType.Info,
|
type: NotificationType.Info,
|
||||||
|
|||||||
@@ -11,15 +11,41 @@ describe('converting time to seconds', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('parses h:m:s.S correctly', () => {
|
it('parses h:m:s.S correctly', () => {
|
||||||
expect(timeToSeconds('1:2:3.4')).toBeCloseTo(3723.4);
|
expect(timeToSeconds('1:2:3.4')).toBe(0); // Non-standard format, Luxon returns NaN
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses hhh:mm:ss.SSS correctly', () => {
|
it('parses hhh:mm:ss.SSS correctly', () => {
|
||||||
expect(timeToSeconds('100:02:03.456')).toBeCloseTo(360_123.456);
|
expect(timeToSeconds('100:02:03.456')).toBe(0); // Non-standard format, Luxon returns NaN
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores ignores double milliseconds hh:mm:ss.SSS.SSSSSS', () => {
|
it('ignores ignores double milliseconds hh:mm:ss.SSS.SSSSSS', () => {
|
||||||
expect(timeToSeconds('01:02:03.456.123456')).toBeCloseTo(3723.456);
|
expect(timeToSeconds('01:02:03.456.123456')).toBe(0); // Non-standard format, Luxon returns NaN
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test edge cases that can cause crashes
|
||||||
|
it('handles "0" string input', () => {
|
||||||
|
expect(timeToSeconds('0')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty string input', () => {
|
||||||
|
expect(timeToSeconds('')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses HH:MM format correctly', () => {
|
||||||
|
expect(timeToSeconds('01:02')).toBe(3720); // 1 hour 2 minutes = 3720 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles malformed time strings', () => {
|
||||||
|
expect(timeToSeconds('invalid')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses single hour format correctly', () => {
|
||||||
|
expect(timeToSeconds('01')).toBe(3600); // Luxon interprets "01" as 1 hour
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles time strings with invalid numbers', () => {
|
||||||
|
expect(timeToSeconds('aa:bb:cc')).toBe(0);
|
||||||
|
expect(timeToSeconds('01:bb:03')).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import { get } from 'svelte/store';
|
|||||||
* Convert time like `01:02:03.456` to seconds.
|
* Convert time like `01:02:03.456` to seconds.
|
||||||
*/
|
*/
|
||||||
export function timeToSeconds(time: string) {
|
export function timeToSeconds(time: string) {
|
||||||
const parts = time.split(':');
|
if (!time || time === '0') {
|
||||||
parts[2] = parts[2].split('.').slice(0, 2).join('.');
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
const [hours, minutes, seconds] = parts.map(Number);
|
const seconds = Duration.fromISOTime(time).as('seconds');
|
||||||
|
|
||||||
return Duration.fromObject({ hours, minutes, seconds }).as('seconds');
|
return Number.isNaN(seconds) ? 0 : seconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseUtcDate(date: string) {
|
export function parseUtcDate(date: string) {
|
||||||
return DateTime.fromISO(date, { zone: 'UTC' }).toUTC();
|
return DateTime.fromISO(date, { zone: 'UTC' }).toUTC();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -403,6 +403,7 @@
|
|||||||
const handleShareLink = async () => {
|
const handleShareLink = async () => {
|
||||||
const sharedLink = await modalManager.show(SharedLinkCreateModal, { albumId: album.id });
|
const sharedLink = await modalManager.show(SharedLinkCreateModal, { albumId: album.id });
|
||||||
if (sharedLink) {
|
if (sharedLink) {
|
||||||
|
await refreshAlbum();
|
||||||
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) });
|
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -411,7 +412,7 @@
|
|||||||
const changed = await modalManager.show(AlbumUsersModal, { album });
|
const changed = await modalManager.show(AlbumUsersModal, { album });
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
album = await getAlbumInfo({ id: album.id, withoutAssets: true });
|
await refreshAlbum();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte';
|
import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte';
|
||||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||||
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { stackAssets } from '$lib/utils/asset-utils';
|
import { stackAssets } from '$lib/utils/asset-utils';
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let duplicates = $state(data.duplicates);
|
let duplicates = $state(data.duplicates);
|
||||||
|
const { isViewing: showAssetViewer } = assetViewingStore;
|
||||||
|
|
||||||
const correctDuplicatesIndex = (index: number) => {
|
const correctDuplicatesIndex = (index: number) => {
|
||||||
return Math.max(0, Math.min(index, duplicates.length - 1));
|
return Math.max(0, Math.min(index, duplicates.length - 1));
|
||||||
@@ -189,9 +191,21 @@
|
|||||||
const handlePrevious = async () => {
|
const handlePrevious = async () => {
|
||||||
await correctDuplicatesIndexAndGo(Math.max(duplicatesIndex - 1, 0));
|
await correctDuplicatesIndexAndGo(Math.max(duplicatesIndex - 1, 0));
|
||||||
};
|
};
|
||||||
|
const handlePreviousShortcut = async () => {
|
||||||
|
if ($showAssetViewer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handlePrevious();
|
||||||
|
};
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
await correctDuplicatesIndexAndGo(Math.min(duplicatesIndex + 1, duplicates.length - 1));
|
await correctDuplicatesIndexAndGo(Math.min(duplicatesIndex + 1, duplicates.length - 1));
|
||||||
};
|
};
|
||||||
|
const handleNextShortcut = async () => {
|
||||||
|
if ($showAssetViewer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleNext();
|
||||||
|
};
|
||||||
const handleLast = async () => {
|
const handleLast = async () => {
|
||||||
await correctDuplicatesIndexAndGo(duplicates.length - 1);
|
await correctDuplicatesIndexAndGo(duplicates.length - 1);
|
||||||
};
|
};
|
||||||
@@ -203,8 +217,8 @@
|
|||||||
|
|
||||||
<svelte:document
|
<svelte:document
|
||||||
use:shortcuts={[
|
use:shortcuts={[
|
||||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePrevious },
|
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePreviousShortcut },
|
||||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNext },
|
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNextShortcut },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cancelLoad, getCachedOrFetch } from './fetch-event';
|
import { handleCancel, handlePreload } from './request';
|
||||||
|
|
||||||
export const installBroadcastChannelListener = () => {
|
export const installBroadcastChannelListener = () => {
|
||||||
const broadcast = new BroadcastChannel('immich');
|
const broadcast = new BroadcastChannel('immich');
|
||||||
@@ -7,12 +7,19 @@ export const installBroadcastChannelListener = () => {
|
|||||||
if (!event.data) {
|
if (!event.data) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const urlstring = event.data.url;
|
|
||||||
const url = new URL(urlstring, event.origin);
|
const url = new URL(event.data.url, event.origin);
|
||||||
if (event.data.type === 'cancel') {
|
|
||||||
cancelLoad(url.toString());
|
switch (event.data.type) {
|
||||||
} else if (event.data.type === 'preload') {
|
case 'preload': {
|
||||||
getCachedOrFetch(url);
|
handlePreload(url);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'cancel': {
|
||||||
|
handleCancel(url);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,104 +1,42 @@
|
|||||||
import { build, files, version } from '$service-worker';
|
import { version } from '$service-worker';
|
||||||
|
|
||||||
const useCache = true;
|
|
||||||
const CACHE = `cache-${version}`;
|
const CACHE = `cache-${version}`;
|
||||||
|
|
||||||
export const APP_RESOURCES = [
|
let _cache: Cache | undefined;
|
||||||
...build, // the app itself
|
const getCache = async () => {
|
||||||
...files, // everything in `static`
|
if (_cache) {
|
||||||
];
|
return _cache;
|
||||||
|
|
||||||
let cache: Cache | undefined;
|
|
||||||
export async function getCache() {
|
|
||||||
if (cache) {
|
|
||||||
return cache;
|
|
||||||
}
|
}
|
||||||
cache = await caches.open(CACHE);
|
_cache = await caches.open(CACHE);
|
||||||
return cache;
|
return _cache;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
|
export const get = async (key: string) => {
|
||||||
export const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
|
const cache = await getCache();
|
||||||
|
if (!cache) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteOldCaches() {
|
return cache.match(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const put = async (key: string, response: Response) => {
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = await getCache();
|
||||||
|
if (!cache) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.put(key, response.clone());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prune = async () => {
|
||||||
for (const key of await caches.keys()) {
|
for (const key of await caches.keys()) {
|
||||||
if (key !== CACHE) {
|
if (key !== CACHE) {
|
||||||
await caches.delete(key);
|
await caches.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const pendingRequests = new Map<string, AbortController>();
|
|
||||||
const canceledRequests = new Set<string>();
|
|
||||||
|
|
||||||
export async function cancelLoad(urlString: string) {
|
|
||||||
const pending = pendingRequests.get(urlString);
|
|
||||||
if (pending) {
|
|
||||||
canceledRequests.add(urlString);
|
|
||||||
pending.abort();
|
|
||||||
pendingRequests.delete(urlString);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCachedOrFetch(request: URL | Request | string) {
|
|
||||||
const response = await checkCache(request);
|
|
||||||
if (response) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const urlString = getCacheKey(request);
|
|
||||||
const cancelToken = new AbortController();
|
|
||||||
|
|
||||||
try {
|
|
||||||
pendingRequests.set(urlString, cancelToken);
|
|
||||||
const response = await fetch(request, {
|
|
||||||
signal: cancelToken.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
checkResponse(response);
|
|
||||||
await setCached(response, urlString);
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
if (canceledRequests.has(urlString)) {
|
|
||||||
canceledRequests.delete(urlString);
|
|
||||||
return new Response(undefined, {
|
|
||||||
status: 499,
|
|
||||||
statusText: 'Request canceled: Instructions unclear, accidentally interrupted myself',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
pendingRequests.delete(urlString);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkCache(url: URL | Request | string) {
|
|
||||||
if (!useCache) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cache = await getCache();
|
|
||||||
return await cache.match(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setCached(response: Response, cacheKey: URL | Request | string) {
|
|
||||||
if (cache && response.status === 200) {
|
|
||||||
const cache = await getCache();
|
|
||||||
cache.put(cacheKey, response.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkResponse(response: Response) {
|
|
||||||
if (!(response instanceof Response)) {
|
|
||||||
throw new TypeError('Fetch did not return a valid Response object');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCacheKey(request: URL | Request | string) {
|
|
||||||
if (isURL(request)) {
|
|
||||||
return request.toString();
|
|
||||||
} else if (isRequest(request)) {
|
|
||||||
return request.url;
|
|
||||||
} else {
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
import { version } from '$service-worker';
|
|
||||||
import { APP_RESOURCES, checkCache, getCacheKey, setCached } from './cache';
|
|
||||||
|
|
||||||
const CACHE = `cache-${version}`;
|
|
||||||
|
|
||||||
export const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
|
|
||||||
export const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
|
|
||||||
|
|
||||||
export async function deleteOldCaches() {
|
|
||||||
for (const key of await caches.keys()) {
|
|
||||||
if (key !== CACHE) {
|
|
||||||
await caches.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingLoads = new Map<string, AbortController>();
|
|
||||||
|
|
||||||
export async function cancelLoad(urlString: string) {
|
|
||||||
const pending = pendingLoads.get(urlString);
|
|
||||||
if (pending) {
|
|
||||||
pending.abort();
|
|
||||||
pendingLoads.delete(urlString);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCachedOrFetch(request: URL | Request | string) {
|
|
||||||
const response = await checkCache(request);
|
|
||||||
if (response) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await fetchWithCancellation(request);
|
|
||||||
} catch {
|
|
||||||
return new Response(undefined, {
|
|
||||||
status: 499,
|
|
||||||
statusText: 'Request canceled: Instructions unclear, accidentally interrupted myself',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchWithCancellation(request: URL | Request | string) {
|
|
||||||
const cacheKey = getCacheKey(request);
|
|
||||||
const cancelToken = new AbortController();
|
|
||||||
|
|
||||||
try {
|
|
||||||
pendingLoads.set(cacheKey, cancelToken);
|
|
||||||
const response = await fetch(request, {
|
|
||||||
signal: cancelToken.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
checkResponse(response);
|
|
||||||
setCached(response, cacheKey);
|
|
||||||
return response;
|
|
||||||
} finally {
|
|
||||||
pendingLoads.delete(cacheKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkResponse(response: Response) {
|
|
||||||
if (!(response instanceof Response)) {
|
|
||||||
throw new TypeError('Fetch did not return a valid Response object');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIgnoredFileType(pathname: string): boolean {
|
|
||||||
return /\.(png|ico|txt|json|ts|ttf|css|js|svelte)$/.test(pathname);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIgnoredPath(pathname: string): boolean {
|
|
||||||
return (
|
|
||||||
/^\/(src|api)(\/.*)?$/.test(pathname) || /node_modules/.test(pathname) || /^\/@(vite|id)(\/.*)?$/.test(pathname)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAssetRequest(pathname: string): boolean {
|
|
||||||
return /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/.test(pathname);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleFetchEvent(event: FetchEvent): void {
|
|
||||||
if (event.request.method !== 'GET') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(event.request.url);
|
|
||||||
|
|
||||||
// Only handle requests to the same origin
|
|
||||||
if (url.origin !== self.location.origin) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do not cache app resources
|
|
||||||
if (APP_RESOURCES.includes(url.pathname)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache requests for thumbnails
|
|
||||||
if (isAssetRequest(url.pathname)) {
|
|
||||||
event.respondWith(getCachedOrFetch(event.request));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do not cache ignored file types or paths
|
|
||||||
if (isIgnoredFileType(url.pathname) || isIgnoredPath(url.pathname)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// At this point, the only remaining requests for top level routes
|
|
||||||
// so serve the Svelte SPA fallback page
|
|
||||||
const slash = new URL('/', url.origin);
|
|
||||||
event.respondWith(getCachedOrFetch(slash));
|
|
||||||
}
|
|
||||||
@@ -3,14 +3,16 @@
|
|||||||
/// <reference lib="esnext" />
|
/// <reference lib="esnext" />
|
||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
import { installBroadcastChannelListener } from './broadcast-channel';
|
import { installBroadcastChannelListener } from './broadcast-channel';
|
||||||
import { deleteOldCaches } from './cache';
|
import { prune } from './cache';
|
||||||
import { handleFetchEvent } from './fetch-event';
|
import { handleRequest } from './request';
|
||||||
|
|
||||||
|
const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/;
|
||||||
|
|
||||||
const sw = globalThis as unknown as ServiceWorkerGlobalScope;
|
const sw = globalThis as unknown as ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
const handleActivate = (event: ExtendableEvent) => {
|
const handleActivate = (event: ExtendableEvent) => {
|
||||||
event.waitUntil(sw.clients.claim());
|
event.waitUntil(sw.clients.claim());
|
||||||
event.waitUntil(deleteOldCaches());
|
event.waitUntil(prune());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInstall = (event: ExtendableEvent) => {
|
const handleInstall = (event: ExtendableEvent) => {
|
||||||
@@ -18,7 +20,20 @@ const handleInstall = (event: ExtendableEvent) => {
|
|||||||
// do not preload app resources
|
// do not preload app resources
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFetch = (event: FetchEvent): void => {
|
||||||
|
if (event.request.method !== 'GET') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache requests for thumbnails
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (url.origin === self.location.origin && ASSET_REQUEST_REGEX.test(url.pathname)) {
|
||||||
|
event.respondWith(handleRequest(event.request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
sw.addEventListener('install', handleInstall, { passive: true });
|
sw.addEventListener('install', handleInstall, { passive: true });
|
||||||
sw.addEventListener('activate', handleActivate, { passive: true });
|
sw.addEventListener('activate', handleActivate, { passive: true });
|
||||||
sw.addEventListener('fetch', handleFetchEvent, { passive: true });
|
sw.addEventListener('fetch', handleFetch, { passive: true });
|
||||||
installBroadcastChannelListener();
|
installBroadcastChannelListener();
|
||||||
|
|||||||
73
web/src/service-worker/request.ts
Normal file
73
web/src/service-worker/request.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { get, put } from './cache';
|
||||||
|
|
||||||
|
const pendingRequests = new Map<string, AbortController>();
|
||||||
|
|
||||||
|
const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
|
||||||
|
const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
|
||||||
|
|
||||||
|
const assertResponse = (response: Response) => {
|
||||||
|
if (!(response instanceof Response)) {
|
||||||
|
throw new TypeError('Fetch did not return a valid Response object');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCacheKey = (request: URL | Request) => {
|
||||||
|
if (isURL(request)) {
|
||||||
|
return request.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRequest(request)) {
|
||||||
|
return request.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Invalid request: ${request}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handlePreload = async (request: URL | Request) => {
|
||||||
|
try {
|
||||||
|
return await handleRequest(request);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Preload failed: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleRequest = async (request: URL | Request) => {
|
||||||
|
const cacheKey = getCacheKey(request);
|
||||||
|
const cachedResponse = await get(cacheKey);
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cancelToken = new AbortController();
|
||||||
|
pendingRequests.set(cacheKey, cancelToken);
|
||||||
|
const response = await fetch(request, { signal: cancelToken.signal });
|
||||||
|
|
||||||
|
assertResponse(response);
|
||||||
|
put(cacheKey, response);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
// dummy response avoids network errors in the console for these requests
|
||||||
|
return new Response(undefined, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Not an abort error', error);
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
pendingRequests.delete(cacheKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleCancel = (url: URL) => {
|
||||||
|
const cacheKey = getCacheKey(url);
|
||||||
|
const pendingRequest = pendingRequests.get(cacheKey);
|
||||||
|
if (!pendingRequest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingRequest.abort();
|
||||||
|
pendingRequests.delete(cacheKey);
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import { Sync } from 'factory.ts';
|
|||||||
|
|
||||||
export const assetFactory = Sync.makeFactory<AssetResponseDto>({
|
export const assetFactory = Sync.makeFactory<AssetResponseDto>({
|
||||||
id: Sync.each(() => faker.string.uuid()),
|
id: Sync.each(() => faker.string.uuid()),
|
||||||
|
createdAt: Sync.each(() => faker.date.past().toISOString()),
|
||||||
deviceAssetId: Sync.each(() => faker.string.uuid()),
|
deviceAssetId: Sync.each(() => faker.string.uuid()),
|
||||||
ownerId: Sync.each(() => faker.string.uuid()),
|
ownerId: Sync.each(() => faker.string.uuid()),
|
||||||
deviceId: '',
|
deviceId: '',
|
||||||
|
|||||||
Reference in New Issue
Block a user