Compare commits

...

22 Commits

Author SHA1 Message Date
CJPeckover 6e0005acfd - add toggle to show/hide asset owner names 2025-08-25 19:55:25 -04:00
CJPeckover 55a196bfa0 Merge branch 'immich-main' into feat/shared-album-owner-labels 2025-08-25 18:27:51 -04:00
Jason Rasmussen d04675fb41 fix: dev-scripts (#21270)
fix/dev-scripts
2025-08-25 17:10:59 -04:00
Jason Rasmussen acfd40b77a fix: album start/end dates on shared links (#21268) 2025-08-25 17:10:31 -04:00
Jason Rasmussen 840e43430c fix: docs typo (#21269) 2025-08-25 22:43:37 +02:00
Jason Rasmussen a3e0c6cef5 fix: ignore abort errors (#21262) 2025-08-25 16:42:30 -04:00
Jason Rasmussen 63088b22e0 fix(web): handle multiple downloads in safari (#21259) 2025-08-25 12:59:59 -05:00
xCJPECKOVERx d9d8beb92f fix(web): Duplicate arrow shortcuts go to next/previous duplicate when viewing assets (#21200)
- get assetviewer state and don't handle next/previous duplicate if isViewing
2025-08-25 13:33:48 -04:00
Jason Rasmussen 38a8a67be9 fix(web): allow numeric input fields to be zero (#21258) 2025-08-25 13:31:32 -04:00
Jason Rasmussen 7531ffcbfb refactor: service worker (#21250) 2025-08-25 11:52:57 -05:00
xCJPECKOVERx d5f3629c49 fix(web): Album multi-select 'm' shortcut prevents typing m in title box (#21249)
change album multi-select shortcut to ctrl
2025-08-25 11:52:26 -05:00
Alex be5b4cb1d1 chore: patch createdAt in AssetResponseDto (#21254) 2025-08-25 16:33:21 +00:00
Wingy 5fb8d651ec feat: expose createdAt in getAssetInfo (#21184)
* Expose createdAt in getAssetInfo

* Add missing createdAt fields
2025-08-25 10:27:21 -05:00
Luke Hagar c2313f7a99 feat: add support for custom headers to TS SDK (#21205)
* Add support for custom headers

* fix: added assertNoApiKey function
2025-08-25 10:25:21 -05:00
Min Idzelis 59627e2b4c fix: devcontainer after pnpm changes (#21227) 2025-08-25 10:24:31 -05:00
gablilli 530bf059ad docs: update italian README: better wording, add some important sections, fixed links and alt texts (#21221) 2025-08-25 15:15:39 +00:00
CJPeckover d0b49846dc format 2025-08-23 01:12:37 -04:00
CJPeckover 8896b2dbf5 fix lint 2025-08-23 01:11:10 -04:00
CJPeckover 251e644b2a - cleanup albumUsers creation
- use font-light for the user's name
2025-08-23 01:09:51 -04:00
CJPeckover a02635f9a5 cleanup 2025-08-23 00:57:50 -04:00
CJPeckover 104f3dfcc3 - change owner to their name in white text instead of the avatar 2025-08-23 00:54:50 -04:00
CJPeckover b7e3b48a44 - pass available album users along to the thumbnail through the asset-date-group
- show a small user-avatar in bottom right of thumbnail
2025-08-22 17:29:03 -04:00
30 changed files with 455 additions and 301 deletions
@@ -12,8 +12,7 @@ services:
- ${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 - pnpm-store:/usr/src/app/.pnpm-store
- server-node-modules:/usr/src/app/server/node_modules - server-node_modules:/usr/src/app/server/node_modules
- server-dist:/usr/src/app/server/dist
- web-node_modules:/usr/src/app/web/node_modules - web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules - github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules - cli-node_modules:/usr/src/app/cli/node_modules
@@ -25,6 +24,9 @@ services:
- coverage:/usr/src/app/web/coverage - 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 -8
View File
@@ -33,8 +33,7 @@ services:
- ${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 - pnpm-store:/usr/src/app/.pnpm-store
- server-node-modules:/usr/src/app/server/node_modules - server-node_modules:/usr/src/app/server/node_modules
- server-dist:/usr/src/app/server/dist
- web-node_modules:/usr/src/app/web/node_modules - web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules - github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules - cli-node_modules:/usr/src/app/cli/node_modules
@@ -97,8 +96,7 @@ services:
volumes: volumes:
- ..:/usr/src/app - ..:/usr/src/app
- pnpm-store:/usr/src/app/.pnpm-store - pnpm-store:/usr/src/app/.pnpm-store
- server-node-modules:/usr/src/app/server/node_modules - server-node_modules:/usr/src/app/server/node_modules
- server-dist:/usr/src/app/server/dist
- web-node_modules:/usr/src/app/web/node_modules - web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules - github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules - cli-node_modules:/usr/src/app/cli/node_modules
@@ -194,8 +192,7 @@ services:
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' 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: volumes:
- pnpm-store:/usr/src/app/.pnpm-store - pnpm-store:/usr/src/app/.pnpm-store
- server-node-modules:/usr/src/app/server/node_modules - server-node_modules:/usr/src/app/server/node_modules
- server-dist:/usr/src/app/server/dist
- web-node_modules:/usr/src/app/web/node_modules - web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules - github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules - cli-node_modules:/usr/src/app/cli/node_modules
@@ -210,8 +207,7 @@ volumes:
prometheus-data: prometheus-data:
grafana-data: grafana-data:
pnpm-store: pnpm-store:
server-node-modules: server-node_modules:
server-dist:
web-node_modules: web-node_modules:
github-node_modules: github-node_modules:
cli-node_modules: cli-node_modules:
+1 -1
View File
@@ -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
+1
View File
@@ -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':
+10 -1
View File
@@ -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',
+7
View File
@@ -10720,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"
}, },
@@ -10855,6 +10861,7 @@
}, },
"required": [ "required": [
"checksum", "checksum",
"createdAt",
"deviceAssetId", "deviceAssetId",
"deviceId", "deviceId",
"duration", "duration",
@@ -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;
+25 -1
View File
@@ -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`;
+90 -56
View File
@@ -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 lapp 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 allinstallazione, 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 lapp 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 | | | | Funzionalità | Mobile | Web |
| Backup automatico quando l'app è in esecuzione | Sì | N/D | | :------------------------------------------ | ------ | --- |
| Selezione degli album per backup | | N/D | | Caricare e visualizzare foto e video | Sì | Sì |
| Download foto e video sul dispositivo | | Sì | | Backup automatico allapertura dellapp | Sì | N/D |
| Supporto multi utente | Sì | Sì | | Evita la duplicazione dei file | Sì | Sì |
| Album e album condivisi | | Sì | | Backup selettivo di album | | 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ì |
| Funzioni di amministrazione degli utenti | No | Sì | | Supporto ai formati RAW | | Sì |
| Backup in background | | N/D | | Visualizzazione metadati (EXIF, mappa) | Sì | Sì |
| Scroll virtuale | Sì | Sì | | Ricerca per metadati, oggetti, volti, CLIP | Sì | Sì |
| Supporto OAuth | | 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ì | | 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ì | |
| 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
![Attività](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Immagine analisi repobeats")
## 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>
+8
View File
@@ -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,
@@ -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
), ),
@@ -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".*`])
+1
View File
@@ -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',
+3 -1
View File
@@ -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',
}),
});
});
});
});
@@ -5,7 +5,7 @@
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { timeToSeconds } from '$lib/utils/date-time'; import { timeToSeconds } from '$lib/utils/date-time';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize, AssetVisibility } from '@immich/sdk'; import { AssetMediaSize, AssetVisibility, type UserResponseDto } from '@immich/sdk';
import { import {
mdiArchiveArrowDownOutline, mdiArchiveArrowDownOutline,
mdiCameraBurst, mdiCameraBurst,
@@ -45,6 +45,7 @@
imageClass?: ClassValue; imageClass?: ClassValue;
brokenAssetClass?: ClassValue; brokenAssetClass?: ClassValue;
dimmed?: boolean; dimmed?: boolean;
albumUsers?: UserResponseDto[];
onClick?: (asset: TimelineAsset) => void; onClick?: (asset: TimelineAsset) => void;
onSelect?: (asset: TimelineAsset) => void; onSelect?: (asset: TimelineAsset) => void;
onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void; onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void;
@@ -63,6 +64,7 @@
readonly = false, readonly = false,
showArchiveIcon = false, showArchiveIcon = false,
showStackedIcon = true, showStackedIcon = true,
albumUsers = [],
onClick = undefined, onClick = undefined,
onSelect = undefined, onSelect = undefined,
onMouseEvent = undefined, onMouseEvent = undefined,
@@ -84,6 +86,8 @@
let width = $derived(thumbnailSize || thumbnailWidth || 235); let width = $derived(thumbnailSize || thumbnailWidth || 235);
let height = $derived(thumbnailSize || thumbnailHeight || 235); let height = $derived(thumbnailSize || thumbnailHeight || 235);
let assetOwner = $derived(albumUsers?.find((user) => user.id === asset.ownerId) ?? null);
const onIconClickedHandler = (e?: MouseEvent) => { const onIconClickedHandler = (e?: MouseEvent) => {
e?.stopPropagation(); e?.stopPropagation();
e?.preventDefault(); e?.preventDefault();
@@ -267,6 +271,12 @@
</div> </div>
{/if} {/if}
{#if !!assetOwner}
<div class="absolute bottom-0 end-1">
<span class="text-white font-light text-sm">{assetOwner.name}</span>
</div>
{/if}
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive} {#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}> <div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
<Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" /> <Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" />
@@ -9,6 +9,7 @@
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { uploadAssetsStore } from '$lib/stores/upload'; import { uploadAssetsStore } from '$lib/stores/upload';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import type { UserResponseDto } from '@immich/sdk';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js'; import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
@@ -25,7 +26,7 @@
monthGroup: MonthGroup; monthGroup: MonthGroup;
timelineManager: TimelineManager; timelineManager: TimelineManager;
assetInteraction: AssetInteraction; assetInteraction: AssetInteraction;
albumUsers?: UserResponseDto[];
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void; onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
onSelectAssets: (asset: TimelineAsset) => void; onSelectAssets: (asset: TimelineAsset) => void;
onSelectAssetCandidates: (asset: TimelineAsset | null) => void; onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
@@ -40,6 +41,7 @@
monthGroup = $bindable(), monthGroup = $bindable(),
assetInteraction, assetInteraction,
timelineManager, timelineManager,
albumUsers = [],
onSelect, onSelect,
onSelectAssets, onSelectAssets,
onSelectAssetCandidates, onSelectAssetCandidates,
@@ -189,6 +191,7 @@
showStackedIcon={withStacked} showStackedIcon={withStacked}
{showArchiveIcon} {showArchiveIcon}
{asset} {asset}
{albumUsers}
{groupIndex} {groupIndex}
onClick={(asset) => onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset)} onClick={(asset) => onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset)}
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)} onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
@@ -30,7 +30,13 @@
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils'; import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { getTimes, toTimelineAsset, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util'; import { getTimes, toTimelineAsset, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk'; import {
AssetVisibility,
getAssetInfo,
type AlbumResponseDto,
type PersonResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { modalManager } from '@immich/ui'; import { modalManager } from '@immich/ui';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { onMount, type Snippet } from 'svelte'; import { onMount, type Snippet } from 'svelte';
@@ -59,6 +65,7 @@
showArchiveIcon?: boolean; showArchiveIcon?: boolean;
isShared?: boolean; isShared?: boolean;
album?: AlbumResponseDto | null; album?: AlbumResponseDto | null;
albumUsers?: UserResponseDto[];
person?: PersonResponseDto | null; person?: PersonResponseDto | null;
isShowDeleteConfirmation?: boolean; isShowDeleteConfirmation?: boolean;
onSelect?: (asset: TimelineAsset) => void; onSelect?: (asset: TimelineAsset) => void;
@@ -78,6 +85,7 @@
showArchiveIcon = false, showArchiveIcon = false,
isShared = false, isShared = false,
album = null, album = null,
albumUsers = [],
person = null, person = null,
isShowDeleteConfirmation = $bindable(false), isShowDeleteConfirmation = $bindable(false),
onSelect = () => {}, onSelect = () => {},
@@ -88,6 +96,12 @@
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget, mutex } = assetViewingStore; let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget, mutex } = assetViewingStore;
// const albumUsers = $derived(
// album?.shared && album.albumUsers.some(({ role }) => role === AlbumUserRole.Editor)
// ? [album.owner, ...album.albumUsers.map(({ user }) => user)]
// : [],
// );
let element: HTMLElement | undefined = $state(); let element: HTMLElement | undefined = $state();
let timelineElement: HTMLElement | undefined = $state(); let timelineElement: HTMLElement | undefined = $state();
@@ -936,6 +950,7 @@
{isSelectionMode} {isSelectionMode}
{singleSelect} {singleSelect}
{monthGroup} {monthGroup}
{albumUsers}
onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)} onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)}
onSelectAssetCandidates={handleSelectAssetCandidates} onSelectAssetCandidates={handleSelectAssetCandidates}
onSelectAssets={handleSelectAssets} onSelectAssets={handleSelectAssets}
@@ -25,7 +25,8 @@
}: Props = $props(); }: Props = $props();
const oninput = () => { const oninput = () => {
if (!value) { // value can be 0
if (value === undefined) {
return; return;
} }
+1 -1
View File
@@ -133,7 +133,7 @@
await onEnter(); await onEnter();
break; break;
} }
case 'm': { case 'Control': {
e.preventDefault(); e.preventDefault();
handleMultiSelect(); handleMultiSelect();
break; break;
+4
View File
@@ -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;
+7 -2
View File
@@ -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,
@@ -71,6 +71,7 @@
} from '@immich/sdk'; } from '@immich/sdk';
import { Button, IconButton, modalManager } from '@immich/ui'; import { Button, IconButton, modalManager } from '@immich/ui';
import { import {
mdiAccountEyeOutline,
mdiArrowLeft, mdiArrowLeft,
mdiCogOutline, mdiCogOutline,
mdiDeleteOutline, mdiDeleteOutline,
@@ -104,6 +105,7 @@
let isCreatingSharedAlbum = $state(false); let isCreatingSharedAlbum = $state(false);
let isShowActivity = $state(false); let isShowActivity = $state(false);
let albumOrder: AssetOrder | undefined = $state(data.album.order); let albumOrder: AssetOrder | undefined = $state(data.album.order);
let showAlbumUsers = $state(false);
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction();
const timelineInteraction = new AssetInteraction(); const timelineInteraction = new AssetInteraction();
@@ -321,6 +323,11 @@
let album = $derived(data.album); let album = $derived(data.album);
let albumId = $derived(album.id); let albumId = $derived(album.id);
const albumUsers = $derived(
showAlbumUsers && album?.shared && album.albumUsers.some(({ role }) => role === AlbumUserRole.Editor)
? [album.owner, ...album.albumUsers.map(({ user }) => user)]
: [],
);
$effect(() => { $effect(() => {
if (!album.isActivityEnabled && activityManager.commentCount === 0) { if (!album.isActivityEnabled && activityManager.commentCount === 0) {
@@ -445,6 +452,7 @@
<AssetGrid <AssetGrid
enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true} enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true}
{album} {album}
{albumUsers}
{timelineManager} {timelineManager}
assetInteraction={currentAssetIntersection} assetInteraction={currentAssetIntersection}
{isShared} {isShared}
@@ -615,6 +623,15 @@
{#snippet trailing()} {#snippet trailing()}
<CastButton /> <CastButton />
<IconButton
variant="ghost"
shape="round"
color="secondary"
aria-label="view asset owners"
icon={mdiAccountEyeOutline}
onclick={() => (showAlbumUsers = !showAlbumUsers)}
/>
{#if isEditor} {#if isEditor}
<IconButton <IconButton
variant="ghost" variant="ghost"
@@ -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 },
]} ]}
/> />
+14 -7
View File
@@ -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;
}
} }
}; };
}; };
+31 -93
View File
@@ -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;
}
}
-113
View File
@@ -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));
}
+19 -4
View File
@@ -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
View 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: '',