Compare commits
14 Commits
v1.139.4
...
test-fix-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da7fbcbb46 | ||
|
|
b471e190a0 | ||
|
|
7672c8c6e0 | ||
|
|
386a6bb377 | ||
|
|
63088b22e0 | ||
|
|
d9d8beb92f | ||
|
|
38a8a67be9 | ||
|
|
7531ffcbfb | ||
|
|
d5f3629c49 | ||
|
|
be5b4cb1d1 | ||
|
|
5fb8d651ec | ||
|
|
c2313f7a99 | ||
|
|
59627e2b4c | ||
|
|
530bf059ad |
@@ -25,6 +25,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:
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
|||||||
@@ -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)
|
|
||||||
- [Funzionalità](#features)
|
|
||||||
- [Introduzione](https://immich.app/docs/overview/introduction)
|
|
||||||
- [Installazione](https://immich.app/docs/install/requirements)
|
|
||||||
- [Linee Guida per Contribuire](https://immich.app/docs/overview/support-the-project)
|
|
||||||
|
|
||||||
## Documentazione
|
- [Documentazione](https://immich.app/docs)
|
||||||
|
- [Informazioni](https://immich.app/docs/overview/introduction)
|
||||||
La documentazione ufficiale, inclusa la guida all'installazione, è disponibile qui: https://immich.app/.
|
- [Installazione](https://immich.app/docs/install/requirements)
|
||||||
|
- [Roadmap](https://immich.app/roadmap)
|
||||||
|
- [Demo](#demo)
|
||||||
|
- [Funzionalità](#funzionalità)
|
||||||
|
- [Traduzioni](https://immich.app/docs/developer/translations)
|
||||||
|
- [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,7 +91,11 @@ 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/
|
||||||
RUN SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile build && \
|
## Build server with sharp linked against system (global) libvips instead of vendored copy.
|
||||||
|
## Using SHARP_IGNORE_GLOBAL_LIBVIPS previously caused arm64 (e.g. Raspberry Pi) illegal instruction
|
||||||
|
## crashes due to the prebuilt vendored libvips targeting newer ARM features. Force global libvips
|
||||||
|
## during build so the already-present distro libvips (built with conservative flags) is used.
|
||||||
|
RUN SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile build && \
|
||||||
SHARP_FORCE_GLOBAL_LIBVIPS=true 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
|
||||||
|
|||||||
@@ -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
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',
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const oninput = () => {
|
const oninput = () => {
|
||||||
if (!value) {
|
// value can be 0
|
||||||
|
if (value === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@
|
|||||||
await onEnter();
|
await onEnter();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'm': {
|
case 'Control': {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleMultiSelect();
|
handleMultiSelect();
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -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,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 { cancelRequest, handleRequest } from './request';
|
||||||
|
|
||||||
export const installBroadcastChannelListener = () => {
|
export const installBroadcastChannelListener = () => {
|
||||||
const broadcast = new BroadcastChannel('immich');
|
const broadcast = new BroadcastChannel('immich');
|
||||||
@@ -7,12 +7,12 @@ export const installBroadcastChannelListener = () => {
|
|||||||
if (!event.data) {
|
if (!event.data) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const urlstring = event.data.url;
|
const urlString = event.data.url;
|
||||||
const url = new URL(urlstring, event.origin);
|
const url = new URL(urlString, event.origin);
|
||||||
if (event.data.type === 'cancel') {
|
if (event.data.type === 'cancel') {
|
||||||
cancelLoad(url.toString());
|
cancelRequest(url);
|
||||||
} else if (event.data.type === 'preload') {
|
} else if (event.data.type === 'preload') {
|
||||||
getCachedOrFetch(url);
|
handleRequest(url);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
63
web/src/service-worker/request.ts
Normal file
63
web/src/service-worker/request.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { get, put } from './cache';
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingRequests = new Map<string, AbortController>();
|
||||||
|
|
||||||
|
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) {
|
||||||
|
console.log(error);
|
||||||
|
return new Response(undefined, {
|
||||||
|
status: 499,
|
||||||
|
statusText: `Request canceled: Instructions unclear, accidentally interrupted myself (${error})`,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
pendingRequests.delete(cacheKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cancelRequest = (url: URL) => {
|
||||||
|
const cacheKey = getCacheKey(url);
|
||||||
|
const pending = pendingRequests.get(cacheKey);
|
||||||
|
if (!pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.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