Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Tran
8879790054 skeleton 2025-01-23 15:34:39 -06:00
128 changed files with 756 additions and 764 deletions

View File

@@ -62,10 +62,6 @@ Instead of these experimental features, we recommend using the URL switching fea
We are not actively developing these features and will not be able to provide support, but welcome contributions to improve them.
Please discuss any large PRs with our dev team to ensure your time is not wasted.
### Why isn't the mobile app updated yet?
The app stores can take a few days to approve new builds of the app. If you're impatient, android APKs can be downloaded from the GitHub releases.
---
## Assets

View File

@@ -27,7 +27,7 @@ The script will perform the following actions:
1. Download [docker-compose.yml](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml), and the [.env](https://github.com/immich-app/immich/releases/latest/download/example.env) file from the main branch of the [repository](https://github.com/immich-app/immich).
2. Start the containers.
The web application and mobile app will be available at `http://<machine-ip-address>:2283`
The web application will be available at `http://<machine-ip-address>:2283`, and the server URL for the mobile app will be `http://<machine-ip-address>:2283/api`
The directory which is used to store the library files is `./immich-app` relative to the current directory.

View File

@@ -1,3 +1,3 @@
Login to the mobile app with the server endpoint URL at `http://<machine-ip-address>:2283`
Login to the mobile app with the server endpoint URL at `http://<machine-ip-address>:2283/api`
<img src={require('./img/sign-in-phone.webp').default} width='50%' title='Mobile App Sign In' />

View File

@@ -3,11 +3,11 @@ import {
AssetMediaStatus,
AssetResponseDto,
AssetTypeEnum,
LoginResponseDto,
SharedLinkType,
getAssetInfo,
getConfig,
getMyUser,
LoginResponseDto,
SharedLinkType,
updateConfig,
} from '@immich/sdk';
import { exiftool } from 'exiftool-vendored';
@@ -19,7 +19,7 @@ import { Socket } from 'socket.io-client';
import { createUserDto, uuidDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, tempDir, TEN_TIMES, testAssetDir, utils } from 'src/utils';
import { app, asBearerAuth, tempDir, testAssetDir, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
@@ -41,6 +41,8 @@ const makeUploadDto = (options?: { omit: string }): Record<string, any> => {
return dto;
};
const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
const ratingAssetFilepath = `${testAssetDir}/metadata/rating/mongolels.jpg`;
const facesAssetFilepath = `${testAssetDir}/metadata/faces/portrait.jpg`;

View File

@@ -1,10 +1,10 @@
import { AssetMediaResponseDto, AssetResponseDto, deleteAssets, LoginResponseDto, updateAsset } from '@immich/sdk';
import { AssetMediaResponseDto, LoginResponseDto, deleteAssets, updateAsset } from '@immich/sdk';
import { DateTime } from 'luxon';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Socket } from 'socket.io-client';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, TEN_TIMES, testAssetDir, utils } from 'src/utils';
import { app, asBearerAuth, testAssetDir, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const today = DateTime.now();
@@ -462,55 +462,6 @@ describe('/search', () => {
});
});
describe('POST /search/random', () => {
beforeAll(async () => {
await Promise.all([
utils.createAsset(admin.accessToken),
utils.createAsset(admin.accessToken),
utils.createAsset(admin.accessToken),
utils.createAsset(admin.accessToken),
utils.createAsset(admin.accessToken),
utils.createAsset(admin.accessToken),
]);
await utils.waitForQueueFinish(admin.accessToken, 'thumbnailGeneration');
});
it('should require authentication', async () => {
const { status, body } = await request(app).post('/search/random').send({ size: 1 });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it.each(TEN_TIMES)('should return 1 random assets', async () => {
const { status, body } = await request(app)
.post('/search/random')
.send({ size: 1 })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
const assets: AssetResponseDto[] = body;
expect(assets.length).toBe(1);
expect(assets[0].ownerId).toBe(admin.userId);
});
it.each(TEN_TIMES)('should return 2 random assets', async () => {
const { status, body } = await request(app)
.post('/search/random')
.send({ size: 2 })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
const assets: AssetResponseDto[] = body;
expect(assets.length).toBe(2);
expect(assets[0].ownerId).toBe(admin.userId);
expect(assets[1].ownerId).toBe(admin.userId);
});
});
describe('GET /search/explore', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/search/explore');

View File

@@ -76,7 +76,6 @@ export const immichCli = (args: string[]) =>
export const immichAdmin = (args: string[]) =>
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const executeCommand = (command: string, args: string[]) => {
let _resolve: (value: CommandResponse) => void;

View File

@@ -53,7 +53,7 @@ show_friendly_message() {
ip_address=$(hostname -I | awk '{print $1}')
cat <<EOF
Successfully deployed Immich!
You can access the website or the mobile app at http://$ip_address:2283
You can access the website at http://$ip_address:2283 and the server URL for the mobile app is http://$ip_address:2283/api
---------------------------------------------------
If you want to configure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc.

View File

@@ -336,9 +336,9 @@
"login_form_back_button_text": "الرجوع للخلف",
"login_form_button_text": "تسجيل الدخول",
"login_form_email_hint": "yoursemail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port",
"login_form_endpoint_hint": "http: // your-server-ip: port/api",
"login_form_endpoint_url": "url نقطة نهاية الخادم",
"login_form_err_http": "يرجى تحديد http:// أو https://",
"login_form_err_http": "يرجى تحديد http: // أو https: //",
"login_form_err_invalid_email": "بريد إلكتروني خاطئ",
"login_form_err_invalid_url": "URL غير صالح",
"login_form_err_leading_whitespace": "قيادة المساحة البيضاء",
@@ -670,4 +670,4 @@
"viewer_unstack": "فك الكومه",
"wifi_name": "WiFi Name",
"your_wifi_name": "Your WiFi name"
}
}

View File

@@ -238,7 +238,7 @@
"login_form_back_button_text": "Back",
"login_form_button_text": "Entra",
"login_form_email_hint": "elteu@correu.cat",
"login_form_endpoint_hint": "http://ip-del-servidor:port",
"login_form_endpoint_hint": "http://ip-del-servidor:port/api",
"login_form_endpoint_url": "URL del servidor",
"login_form_err_http": "Especifica http:// o https://",
"login_form_err_invalid_email": "Adreça de correu electrònic no vàlida",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Zpět",
"login_form_button_text": "Přihlásit se",
"login_form_email_hint": "tvůje-mail@email.com",
"login_form_endpoint_hint": "http://ip-tvého-serveru:port",
"login_form_endpoint_hint": "http://ip-tvého-serveru:port/api",
"login_form_endpoint_url": "URL adresa serveru",
"login_form_err_http": "Prosím, uveďte http:// nebo https://",
"login_form_err_invalid_email": "Neplatný e-mail",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Tilbage",
"login_form_button_text": "Log ind",
"login_form_email_hint": "din-e-mail@e-mail.com",
"login_form_endpoint_hint": "http://din-server-ip:port",
"login_form_endpoint_hint": "http://din-server-ip:port/api",
"login_form_endpoint_url": "Server Endpoint URL",
"login_form_err_http": "Angiv venligst http:// eller https://",
"login_form_err_invalid_email": "Ugyldig e-mail",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Zurück",
"login_form_button_text": "Anmelden",
"login_form_email_hint": "deine@email.de",
"login_form_endpoint_hint": "http://deine-server-ip:port",
"login_form_endpoint_hint": "http://deine-server-ip:port/api",
"login_form_endpoint_url": "Server-URL",
"login_form_err_http": "Bitte gebe http:// oder https:// an",
"login_form_err_invalid_email": "Ungültige E-Mail",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Πίσω",
"login_form_button_text": "Σύνδεση",
"login_form_email_hint": "to-email-sou@email.com",
"login_form_endpoint_hint": "http://ip-tou-server-sou:porta",
"login_form_endpoint_hint": "http://ip-tou-server-sou:porta/api",
"login_form_endpoint_url": "URL τελικού σημείου διακομιστή",
"login_form_err_http": "Προσδιορίστε http:// ή https://",
"login_form_err_invalid_email": "Μη έγκυρο email",

View File

@@ -16,7 +16,7 @@
"advanced_settings_proxy_headers_subtitle": "Define proxy headers Immich should send with each network request",
"advanced_settings_proxy_headers_title": "Proxy Headers",
"advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.",
"advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates (EXPERIMENTAL)",
"advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates",
"advanced_settings_tile_subtitle": "Advanced user's settings",
"advanced_settings_tile_title": "Advanced",
"advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
@@ -175,7 +175,7 @@
"client_cert_remove": "Remove",
"client_cert_remove_msg": "Client certificate is removed",
"client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login",
"client_cert_title": "SSL Client Certificate (EXPERIMENTAL)",
"client_cert_title": "SSL Client Certificate",
"common_add_to_album": "Add to album",
"common_change_password": "Change Password",
"common_create_new_album": "Create new album",
@@ -281,7 +281,7 @@
"header_settings_field_validator_msg": "Value cannot be empty",
"header_settings_header_name_input": "Header name",
"header_settings_header_value_input": "Header value",
"header_settings_page_title": "Proxy Headers (EXPERIMENTAL)",
"header_settings_page_title": "Proxy Headers",
"headers_settings_tile_subtitle": "Define proxy headers the app should send with each network request",
"headers_settings_tile_title": "Custom proxy headers",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
@@ -336,7 +336,7 @@
"login_form_back_button_text": "Back",
"login_form_button_text": "Login",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port",
"login_form_endpoint_hint": "http://your-server-ip:port/api",
"login_form_endpoint_url": "Server Endpoint URL",
"login_form_err_http": "Please specify http:// or https://",
"login_form_err_invalid_email": "Invalid Email",
@@ -670,4 +670,4 @@
"viewer_unstack": "Un-Stack",
"wifi_name": "WiFi Name",
"your_wifi_name": "Your WiFi name"
}
}

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Atrás",
"login_form_button_text": "Iniciar Sesión",
"login_form_email_hint": "tucorreo@correo.com",
"login_form_endpoint_hint": "http://tu-ip-de-servidor:puerto",
"login_form_endpoint_hint": "http://tu-ip-de-servidor:puerto/api",
"login_form_endpoint_url": "URL del servidor",
"login_form_err_http": "Por favor, especifique http:// o https://",
"login_form_err_invalid_email": "Correo electrónico no válido",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Back",
"login_form_button_text": "Iniciar sesión",
"login_form_email_hint": "tucorreo@correo.com",
"login_form_endpoint_hint": "http://la-ip-de-tu-servidor:puerto",
"login_form_endpoint_hint": "http://la-ip-de-tu-servidor:puerto/api",
"login_form_endpoint_url": "URL del servidor",
"login_form_err_http": "Por favor, especifique http:// o https://",
"login_form_err_invalid_email": "Correo electrónico inválido",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Back",
"login_form_button_text": "Iniciar sesión",
"login_form_email_hint": "tucorreo@correo.com",
"login_form_endpoint_hint": "http://la-ip-de-tu-servidor:puerto",
"login_form_endpoint_hint": "http://la-ip-de-tu-servidor:puerto/api",
"login_form_endpoint_url": "URL del servidor",
"login_form_err_http": "Por favor, especifique http:// o https://",
"login_form_err_invalid_email": "Correo electrónico inválido",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Back",
"login_form_button_text": "Iniciar sesión",
"login_form_email_hint": "tucorreo@correo.com",
"login_form_endpoint_hint": "http://ip-de-tu-servidor:puerto",
"login_form_endpoint_hint": "http://ip-de-tu-servidor:puerto/api",
"login_form_endpoint_url": "URL del servidor",
"login_form_err_http": "Por favor, especifique http:// o https://",
"login_form_err_invalid_email": "Correo electrónico inválido",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Takaisin",
"login_form_button_text": "Kirjaudu",
"login_form_email_hint": "sahkopostisi@esimerkki.fi",
"login_form_endpoint_hint": "http://palvelimesi-osoite:portti",
"login_form_endpoint_hint": "http://palvelimesi-osoite:portti/api",
"login_form_endpoint_url": "Palvelimen URL",
"login_form_err_http": "Lisää http:// tai https://",
"login_form_err_invalid_email": "Virheellinen sähköpostiosoite",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Back",
"login_form_button_text": "Connexion",
"login_form_email_hint": "votrecourriel@email.com",
"login_form_endpoint_hint": "http://adresse-ip-serveur:port",
"login_form_endpoint_hint": "http://adresse-ip-serveur:port/api",
"login_form_endpoint_url": "URL du point d'accès au serveur",
"login_form_err_http": "Veuillez préciser http:// ou https://",
"login_form_err_invalid_email": "Courriel invalide",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Retour",
"login_form_button_text": "Connexion",
"login_form_email_hint": "votreemail@email.com",
"login_form_endpoint_hint": "http://adresse-ip-serveur:port",
"login_form_endpoint_hint": "http://adresse-ip-serveur:port/api",
"login_form_endpoint_url": "URL du point d'accès au serveur",
"login_form_err_http": "Veuillez préciser http:// ou https://",
"login_form_err_invalid_email": "E-mail invalide",

View File

@@ -336,9 +336,9 @@
"login_form_back_button_text": "חזרה",
"login_form_button_text": "התחברות",
"login_form_email_hint": "yourmail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port",
"login_form_endpoint_hint": "http://your-server-ip:port/API",
"login_form_endpoint_url": "כתובת נקודת קצה השרת",
"login_form_err_http": "נא לציין //:http או //:https",
"login_form_err_http": "נא לציין //:htttp או //:https",
"login_form_err_invalid_email": "דוא\"ל שגוי",
"login_form_err_invalid_url": "כתובת לא חוקית",
"login_form_err_leading_whitespace": "רווח לבן מוביל",
@@ -670,4 +670,4 @@
"viewer_unstack": "ביטול ערימה",
"wifi_name": "שם אינטרנט אלחוטי",
"your_wifi_name": "שם אינטרנט אלחוטי שלך"
}
}

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Back",
"login_form_button_text": "Login",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port",
"login_form_endpoint_hint": "http://your-server-ip:port/api",
"login_form_endpoint_url": "Server Endpoint URL",
"login_form_err_http": "Please specify http:// or https://",
"login_form_err_invalid_email": "Invalid Email",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Vissza",
"login_form_button_text": "Bejelentkezés",
"login_form_email_hint": "email@cimed.hu",
"login_form_endpoint_hint": "http://szerver-címe:port",
"login_form_endpoint_hint": "http(s)://szerver-címe:port/api",
"login_form_endpoint_url": "Szerver címe",
"login_form_err_http": "Kérjük, hogy egy http:// vagy https:// címet adj meg",
"login_form_err_invalid_email": "Érvénytelen email cím",
@@ -670,4 +670,4 @@
"viewer_unstack": "Csoport Megszűntetése",
"wifi_name": "WiFi Name",
"your_wifi_name": "Your WiFi name"
}
}

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Kembali",
"login_form_button_text": "Masuk",
"login_form_email_hint": "emailmu@email.com",
"login_form_endpoint_hint": "http://ip-server-anda:port",
"login_form_endpoint_hint": "http://ip-server-anda:port/api",
"login_form_endpoint_url": "URL Endpoint Server",
"login_form_err_http": "Harap tentukan http:// atau https://",
"login_form_err_invalid_email": "Email Tidak Valid",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Indietro",
"login_form_button_text": "Login",
"login_form_email_hint": "tuaemail@email.com",
"login_form_endpoint_hint": "http://ip-del-tuo-server:port",
"login_form_endpoint_hint": "http://ip-del-tuo-server:port/api",
"login_form_endpoint_url": "Server Endpoint URL",
"login_form_err_http": "Per favore specificare http:// o https://",
"login_form_err_invalid_email": "Email non valida",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "戻る",
"login_form_button_text": "ログイン",
"login_form_email_hint": "hoge@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port",
"login_form_endpoint_hint": "https://example.com:port/api",
"login_form_endpoint_url": "サーバーのエンドポイントURL",
"login_form_err_http": "http://かhttps://かを指定してください",
"login_form_err_invalid_email": "メールアドレスが無効です",
@@ -670,4 +670,4 @@
"viewer_unstack": "スタックを解除",
"wifi_name": "Wi-Fiの名前(SSID)",
"your_wifi_name": "Wi-Fiの名前(SSID)"
}
}

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "뒤로",
"login_form_button_text": "로그인",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port",
"login_form_endpoint_hint": "http://your-server-ip:port/api",
"login_form_endpoint_url": "서버 엔드포인트 URL",
"login_form_err_http": "http:// 또는 https://로 시작해야 합니다.",
"login_form_err_invalid_email": "유효하지 않은 이메일",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Back",
"login_form_button_text": "Login",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port",
"login_form_endpoint_hint": "http://your-server-ip:port/api",
"login_form_endpoint_url": "Server Endpoint URL",
"login_form_err_http": "Please specify http:// or https://",
"login_form_err_invalid_email": "Invalid Email",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Atpakaļ",
"login_form_button_text": "Pieteikties",
"login_form_email_hint": "jūsuepasts@email.com",
"login_form_endpoint_hint": "http://jūsu-servera-ip:ports",
"login_form_endpoint_hint": "http://jūsu-servera-ip:ports/api",
"login_form_endpoint_url": "Servera Galapunkta URL",
"login_form_err_http": "Lūdzu norādiet http:// vai https://",
"login_form_err_invalid_email": "Nederīgs e-pasts",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Back",
"login_form_button_text": "Login",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port",
"login_form_endpoint_hint": "http://your-server-ip:port/api",
"login_form_endpoint_url": "Server Endpoint URL",
"login_form_err_http": "Please specify http:// or https://",
"login_form_err_invalid_email": "Invalid Email",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Tilbake",
"login_form_button_text": "Logg inn",
"login_form_email_hint": "dinepost@epost.no",
"login_form_endpoint_hint": "http://din-server-ip:port",
"login_form_endpoint_hint": "http://din-server-ip:port/api",
"login_form_endpoint_url": "Serverendepunkt-URL",
"login_form_err_http": "Vennligst spesifiser http:// eller https://",
"login_form_err_invalid_email": "Ugyldig e-postadresse",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Terug",
"login_form_button_text": "Inloggen",
"login_form_email_hint": "jouwemail@email.nl",
"login_form_endpoint_hint": "http://jouw-server-ip:poort",
"login_form_endpoint_hint": "http://jouw-server-ip:poort/api",
"login_form_endpoint_url": "Server-URL",
"login_form_err_http": "Voer http:// of https:// in",
"login_form_err_invalid_email": "Ongeldig e-mailadres",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Cofnij",
"login_form_button_text": "Login",
"login_form_email_hint": "twojmail@email.com",
"login_form_endpoint_hint": "http://ip-twojego-serwera:port",
"login_form_endpoint_hint": "http://ip-twojego-serwera:port/api",
"login_form_endpoint_url": "URL Serwera",
"login_form_err_http": "Proszę określić http:// lub https://",
"login_form_err_invalid_email": "Niepoprawny Email",

View File

@@ -74,7 +74,7 @@
"exif_bottom_sheet_location": "LOCALIZAÇÃO",
"login_form_button_text": "Login",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port",
"login_form_endpoint_hint": "http://your-server-ip:port/api",
"login_form_endpoint_url": "Server Endpoint URL",
"login_form_err_http": "Please specify http:// or https://",
"login_form_err_invalid_email": "E-mail inválido",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Voltar",
"login_form_button_text": "Login",
"login_form_email_hint": "seuemail@email.com",
"login_form_endpoint_hint": "http://ip-do-seu-servidor:porta",
"login_form_endpoint_hint": "http://ip-do-seu-servidor:porta/api",
"login_form_endpoint_url": "URL do servidor",
"login_form_err_http": "Por favor especifique http:// ou https://",
"login_form_err_invalid_email": "Email Inválido",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Înapoi",
"login_form_button_text": "Conectare",
"login_form_email_hint": "email-ultau@email.com",
"login_form_endpoint_hint": "http://ip-server:port",
"login_form_endpoint_hint": "http://ip-server:port/api",
"login_form_endpoint_url": "URL-ul destinației sever-ului",
"login_form_err_http": "Te rugăm specifică http:// sau https://",
"login_form_err_invalid_email": "Email invalid",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Назад",
"login_form_button_text": "Войти",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port",
"login_form_endpoint_hint": "http://your-server-ip:port/api",
"login_form_endpoint_url": "URL-aдрес сервера",
"login_form_err_http": "Пожалуйста, укажите протокол http:// или https://",
"login_form_err_invalid_email": "Некорректный адрес электронной почты",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Späť",
"login_form_button_text": "Prihlásiť sa",
"login_form_email_hint": "tvojmail@email.com",
"login_form_endpoint_hint": "http://ip-tvojho-servera:port",
"login_form_endpoint_hint": "http://ip-tvojho-servera:port/api",
"login_form_endpoint_url": "URL adresa servera",
"login_form_err_http": "Prosím, uveďte http:// alebo https://",
"login_form_err_invalid_email": "Neplatný e-mail",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Nazaj",
"login_form_button_text": "Prijava",
"login_form_email_hint": "vašemail@email.com",
"login_form_endpoint_hint": "http://ip-vašega-strežnika:vrata",
"login_form_endpoint_hint": "http://ip-vašega-strežnika:vrata/api",
"login_form_endpoint_url": "URL končne točke strežnika",
"login_form_err_http": "Navedi http:// ali https://",
"login_form_err_invalid_email": "Neveljaven e-poštni naslov",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Back",
"login_form_button_text": "Login",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port",
"login_form_endpoint_hint": "http://your-server-ip:port/api",
"login_form_endpoint_url": "Server Endpoint URL",
"login_form_err_http": "Please specify http:// or https://",
"login_form_err_invalid_email": "Invalid Email",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Back",
"login_form_button_text": "Prijavi se",
"login_form_email_hint": "vašemail@email.com",
"login_form_endpoint_hint": "http://ip-vašeg-servera:port",
"login_form_endpoint_hint": "http://ip-vašeg-servera:port/api",
"login_form_endpoint_url": "URL Servera",
"login_form_err_http": "Dopiši http:// ili https://",
"login_form_err_invalid_email": "Nevažeći Email",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Back",
"login_form_button_text": "Login",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port",
"login_form_endpoint_hint": "http://your-server-ip:port/api",
"login_form_endpoint_url": "Server Endpoint URL",
"login_form_err_http": "Please specify http:// or https://",
"login_form_err_invalid_email": "Invalid Email",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Bakåt",
"login_form_button_text": "Logga in",
"login_form_email_hint": "din.email@email.com",
"login_form_endpoint_hint": "http://din-server-ip:port",
"login_form_endpoint_hint": "http://din-server-ip:port/api",
"login_form_endpoint_url": "Server Endpoint URL",
"login_form_err_http": "Var god ange http:// eller https://",
"login_form_err_invalid_email": "Ogiltig email",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "กลับ",
"login_form_button_text": "เข้าสู่ระบบ",
"login_form_email_hint": "อีเมลคุณ@อีเมล.com",
"login_form_endpoint_hint": "http://ไอพีเชอร์ฟเวอร์คุณ:พอร์ต",
"login_form_endpoint_hint": "http://ไอพีเชอร์ฟเวอร์คุณ:พอร์ต/api",
"login_form_endpoint_url": "URL ปลายทางของเซิร์ฟเวอร์",
"login_form_err_http": "โปรดระบุ http:// หรือ https://",
"login_form_err_invalid_email": "อีเมลไม่ถูกต้อง",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Geri",
"login_form_button_text": "Giriş",
"login_form_email_hint": "mail@adresiniz.com",
"login_form_endpoint_hint": "http://sunucu-ip:port",
"login_form_endpoint_hint": "http://sunucu-ip:port/api",
"login_form_endpoint_url": "Sunucu Uç Nokta URL",
"login_form_err_http": "Lütfen http:// veya https:// olarak belirtin",
"login_form_err_invalid_email": "Geçersiz E-posta",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Назад",
"login_form_button_text": "Увійти",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port",
"login_form_endpoint_hint": "http://your-server-ip:port/api",
"login_form_endpoint_url": "Адреса точки досупу на сервері",
"login_form_err_http": "Вкажіть http:// або https://",
"login_form_err_invalid_email": "Хибний імейл",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "Quay lại",
"login_form_button_text": "Đăng nhập",
"login_form_email_hint": "emailcuaban@email.com",
"login_form_endpoint_hint": "http://địa-chỉ-ip-máy-chủ-bạn:cổng",
"login_form_endpoint_hint": "http://địa-chỉ-ip-máy-chủ-bạn:cổng/api",
"login_form_endpoint_url": "Địa chỉ máy chủ",
"login_form_err_http": "Vui lòng xác định http:// hoặc https://",
"login_form_err_invalid_email": "Email không hợp lệ",

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "后退",
"login_form_button_text": "登录",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://您的服务器地址:端口",
"login_form_endpoint_hint": "http(s)://您的服务器地址:端口/api",
"login_form_endpoint_url": "服务器链接地址",
"login_form_err_http": "请注明 http:// 或 https://",
"login_form_err_invalid_email": "无效的电子邮箱",
@@ -670,4 +670,4 @@
"viewer_unstack": "取消堆叠",
"wifi_name": "Wi-Fi 名称",
"your_wifi_name": "您的 Wi-Fi 名称"
}
}

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "后退",
"login_form_button_text": "登录",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://您的服务器地址:端口",
"login_form_endpoint_hint": "http(s)://您的服务器地址:端口/api",
"login_form_endpoint_url": "服务器链接地址",
"login_form_err_http": "请注明 http:// 或 https://",
"login_form_err_invalid_email": "无效的电子邮箱",
@@ -670,4 +670,4 @@
"viewer_unstack": "取消堆叠",
"wifi_name": "Wi-Fi 名称",
"your_wifi_name": "您的 Wi-Fi 名称"
}
}

View File

@@ -336,7 +336,7 @@
"login_form_back_button_text": "後退",
"login_form_button_text": "登入",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://您的伺服器地址:端口",
"login_form_endpoint_hint": "http(s)://您的伺服器地址:端口/api",
"login_form_endpoint_url": "伺服器鏈接地址",
"login_form_err_http": "請注明 http:// 或 https://",
"login_form_err_invalid_email": "電郵無效",
@@ -670,4 +670,4 @@
"viewer_unstack": "取消堆疊",
"wifi_name": "WiFi Name",
"your_wifi_name": "Your WiFi name"
}
}

View File

@@ -541,7 +541,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 187;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -685,7 +685,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 187;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -715,7 +715,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 187;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -748,7 +748,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 1;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -791,7 +791,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 1;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -831,7 +831,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 1;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;

View File

@@ -78,7 +78,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.125.1</string>
<string>1.124.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -93,7 +93,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>189</string>
<string>187</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -0,0 +1,7 @@
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/interfaces/database.interface.dart';
import 'package:isar/isar.dart';
abstract interface class IArchiveRepository implements IDatabaseRepository {
QueryBuilder<Asset, Asset, QAfterSortBy> getTimelineQuery(int userIsarId);
}

View File

@@ -262,11 +262,6 @@ class GalleryViewerPage extends HookConsumerWidget {
PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) {
var newAsset = loadAsset(index);
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(currentAssetProvider.notifier).set(newAsset);
});
final stackId = newAsset.stackId;
if (stackId != null && currentIndex.value == index) {
final stackElements =

View File

@@ -83,18 +83,11 @@ class PhotosPage extends HookConsumerWidget {
Future<void> refreshAssets() async {
final fullRefresh = refreshCount.value > 0;
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
if (fullRefresh) {
Future.wait([
ref.read(assetProvider.notifier).getAllAsset(clear: true),
ref.read(albumProvider.notifier).refreshRemoteAlbums(),
]);
// refresh was forced: user requested another refresh within 2 seconds
refreshCount.value = 0;
} else {
await ref.read(assetProvider.notifier).getAllAsset(clear: false);
refreshCount.value++;
// set counter back to 0 if user does not request refresh again
Timer(const Duration(seconds: 4), () => refreshCount.value = 0);

View File

@@ -0,0 +1,25 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/interfaces/archive.interface.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/database.repository.dart';
import 'package:isar/isar.dart';
final archiveRepositoryProvider =
Provider((ref) => ArchiveRepository(ref.watch(dbProvider)));
class ArchiveRepository extends DatabaseRepository
implements IArchiveRepository {
ArchiveRepository(super.db);
@override
QueryBuilder<Asset, Asset, QAfterSortBy> getTimelineQuery(int userIsarId) {
return db.assets
.where()
.ownerIdEqualToAnyChecksum(userIsarId)
.filter()
.isArchivedEqualTo(true)
.isTrashedEqualTo(false)
.sortByFileCreatedAtDesc();
}
}

View File

@@ -0,0 +1,15 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/repositories/archive.repository.dart';
final archiveServiceProvider = Provider((ref) {
return ArchiveService(ref.watch(archiveRepositoryProvider));
});
class ArchiveService {
final ArchiveRepository repository;
ArchiveService(this.repository);
getTimelineQuery(int userIsarId) {
return repository.getTimelineQuery(userIsarId);
}
}

View File

@@ -102,7 +102,7 @@ class LocalNetworkPreference extends HookConsumerWidget {
final localEndpoint = await _showEditDialog(
context,
"server_endpoint".tr(),
"http://local-ip:2283",
"http://local-ip:2283/api",
localEndpointText.value,
);
@@ -212,7 +212,7 @@ class LocalNetworkPreference extends HookConsumerWidget {
leading: const Icon(Icons.lan_rounded),
title: Text("server_endpoint".tr()),
subtitle: localEndpointText.value.isEmpty
? const Text("http://local-ip:2283")
? const Text("http://local-ip:2283/api")
: Text(
localEndpointText.value,
style: context.textTheme.labelLarge?.copyWith(

View File

@@ -1,7 +1,3 @@
@Skip('currently failing due to mock HTTP client to download ISAR binaries')
@Tags(['pages'])
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -122,4 +118,72 @@ void main() {
captured = verify(() => mockSearchApi.searchAssets(captureAny())).captured;
expect(captured.first, emptyTextSearch);
});
// COME BACK LATER
// testWidgets('contextual search with text combined with media type',
// (tester) async {
// await tester.pumpConsumerWidget(
// const SearchPage(),
// overrides: overrides,
// );
// await tester.pumpAndSettle();
// expect(
// find.byIcon(Icons.abc_rounded),
// findsOneWidget,
// reason: 'Should have contextual search icon',
// );
// final searchField = find.byKey(const Key('search_text_field'));
// expect(searchField, findsOneWidget);
// await tester.enterText(searchField, 'test');
// await tester.testTextInput.receiveAction(TextInputAction.search);
// var captured = verify(
// () => mockSearchApi.searchSmart(captureAny()),
// ).captured;
// expect(
// captured.first,
// isA<SmartSearchDto>().having((s) => s.query, 'query', 'test'),
// );
// await tester.dragUntilVisible(
// find.byKey(const Key('media_type_chip')),
// find.byKey(const Key('search_filter_chip_list')),
// const Offset(-100, 0),
// );
// await tester.pumpAndSettle();
// await tester.tap(find.byKey(const Key('media_type_chip')));
// await tester.pumpAndSettle();
// await tester.tap(find.byKey(const Key('search_filter_media_type_image')));
// await tester.pumpAndSettle();
// await tester.tap(find.byKey(const Key('search_filter_apply')));
// await tester.pumpAndSettle();
// captured = verify(() => mockSearchApi.searchSmart(captureAny())).captured;
// expect(
// captured.first,
// isA<SmartSearchDto>()
// .having((s) => s.query, 'query', 'test')
// .having((s) => s.type, 'type', AssetTypeEnum.IMAGE),
// );
// await tester.enterText(searchField, '');
// await tester.testTextInput.receiveAction(TextInputAction.search);
// captured = verify(() => mockSearchApi.searchAssets(captureAny())).captured;
// expect(
// captured.first,
// isA<MetadataSearchDto>()
// .having((s) => s.originalFileName, 'originalFileName', null)
// .having((s) => s.type, 'type', AssetTypeEnum.IMAGE),
// );
// });
}

View File

@@ -13,6 +13,7 @@ import { entities } from 'src/entities';
import { ImmichWorker } from 'src/enum';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository } from 'src/interfaces/job.interface';
import { ITelemetryRepository } from 'src/interfaces/telemetry.interface';
import { AuthGuard } from 'src/middleware/auth.guard';
import { ErrorInterceptor } from 'src/middleware/error.interceptor';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
@@ -21,7 +22,7 @@ import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
import { providers, repositories } from 'src/repositories';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
import { teardownTelemetry } from 'src/repositories/telemetry.repository';
import { services } from 'src/services';
import { CliService } from 'src/services/cli.service';
import { DatabaseService } from 'src/services/database.service';
@@ -66,7 +67,7 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
logger: LoggingRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
private telemetryRepository: TelemetryRepository,
@Inject(ITelemetryRepository) private telemetryRepository: ITelemetryRepository,
) {
logger.setAppName(this.worker);
}

View File

@@ -3,8 +3,8 @@ import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { TemplateDto, TemplateResponseDto, TestEmailResponseDto } from 'src/dtos/notification.dto';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { EmailTemplate } from 'src/interfaces/notification.interface';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { EmailTemplate } from 'src/repositories/notification.repository';
import { NotificationService } from 'src/services/notification.service';
@ApiTags('Notifications')

View File

@@ -2,7 +2,7 @@ import { Img, Link, Section, Text } from '@react-email/components';
import * as React from 'react';
import { ImmichButton } from 'src/emails/components/button.component';
import ImmichLayout from 'src/emails/components/immich.layout';
import { AlbumInviteEmailProps } from 'src/repositories/notification.repository';
import { AlbumInviteEmailProps } from 'src/interfaces/notification.interface';
import { replaceTemplateTags } from 'src/utils/replace-template-tags';
export const AlbumInviteEmail = ({

View File

@@ -2,7 +2,7 @@ import { Img, Link, Section, Text } from '@react-email/components';
import * as React from 'react';
import { ImmichButton } from 'src/emails/components/button.component';
import ImmichLayout from 'src/emails/components/immich.layout';
import { AlbumUpdateEmailProps } from 'src/repositories/notification.repository';
import { AlbumUpdateEmailProps } from 'src/interfaces/notification.interface';
import { replaceTemplateTags } from 'src/utils/replace-template-tags';
export const AlbumUpdateEmail = ({

View File

@@ -1,7 +1,7 @@
import { Link, Row, Text } from '@react-email/components';
import * as React from 'react';
import ImmichLayout from 'src/emails/components/immich.layout';
import { TestEmailProps } from 'src/repositories/notification.repository';
import { TestEmailProps } from 'src/interfaces/notification.interface';
export const TestEmail = ({ baseUrl, displayName }: TestEmailProps) => (
<ImmichLayout preview="This is a test email from Immich.">

View File

@@ -2,7 +2,7 @@ import { Link, Section, Text } from '@react-email/components';
import * as React from 'react';
import { ImmichButton } from 'src/emails/components/button.component';
import ImmichLayout from 'src/emails/components/immich.layout';
import { WelcomeEmailProps } from 'src/repositories/notification.repository';
import { WelcomeEmailProps } from 'src/interfaces/notification.interface';
import { replaceTemplateTags } from 'src/utils/replace-template-tags';
export const WelcomeEmail = ({ baseUrl, displayName, username, password, customTemplate }: WelcomeEmailProps) => {

View File

@@ -0,0 +1,18 @@
import { Insertable, Selectable, Updateable } from 'kysely';
import { AlbumsSharedUsersUsers } from 'src/db';
export const IAlbumUserRepository = 'IAlbumUserRepository';
export type AlbumPermissionId = {
albumsId: string;
usersId: string;
};
export interface IAlbumUserRepository {
create(albumUser: Insertable<AlbumsSharedUsersUsers>): Promise<Selectable<AlbumsSharedUsersUsers>>;
update(
id: AlbumPermissionId,
albumPermission: Updateable<AlbumsSharedUsersUsers>,
): Promise<Selectable<AlbumsSharedUsersUsers>>;
delete(id: AlbumPermissionId): Promise<void>;
}

View File

@@ -0,0 +1,20 @@
export const ICronRepository = 'ICronRepository';
type CronBase = {
name: string;
start?: boolean;
};
export type CronCreate = CronBase & {
expression: string;
onTick: () => void;
};
export type CronUpdate = CronBase & {
expression?: string;
};
export interface ICronRepository {
create(cron: CronCreate): void;
update(cron: CronUpdate): void;
}

View File

@@ -1,5 +1,5 @@
import { ClassConstructor } from 'class-transformer';
import { EmailImageAttachment } from 'src/repositories/notification.repository';
import { EmailImageAttachment } from 'src/interfaces/notification.interface';
export enum QueueName {
THUMBNAIL_GENERATION = 'thumbnailGeneration',

View File

@@ -0,0 +1,31 @@
export const IMapRepository = 'IMapRepository';
export interface MapMarkerSearchOptions {
isArchived?: boolean;
isFavorite?: boolean;
fileCreatedBefore?: Date;
fileCreatedAfter?: Date;
}
export interface GeoPoint {
latitude: number;
longitude: number;
}
export interface ReverseGeocodeResult {
country: string | null;
state: string | null;
city: string | null;
}
export interface MapMarker extends ReverseGeocodeResult {
id: string;
lat: number;
lon: number;
}
export interface IMapRepository {
init(): Promise<void>;
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult>;
getMapMarkers(ownerIds: string[], albumIds: string[], options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
}

View File

@@ -0,0 +1,71 @@
import { BinaryField, Tags } from 'exiftool-vendored';
export const IMetadataRepository = 'IMetadataRepository';
export interface ExifDuration {
Value: number;
Scale?: number;
}
type StringOrNumber = string | number;
type TagsWithWrongTypes =
| 'FocalLength'
| 'Duration'
| 'Description'
| 'ImageDescription'
| 'RegionInfo'
| 'TagsList'
| 'Keywords'
| 'HierarchicalSubject'
| 'ISO';
export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
ContentIdentifier?: string;
MotionPhoto?: number;
MotionPhotoVersion?: number;
MotionPhotoPresentationTimestampUs?: number;
MediaGroupUUID?: string;
ImagePixelDepth?: string;
FocalLength?: number;
Duration?: number | string | ExifDuration;
EmbeddedVideoType?: string;
EmbeddedVideoFile?: BinaryField;
MotionPhotoVideo?: BinaryField;
TagsList?: StringOrNumber[];
HierarchicalSubject?: StringOrNumber[];
Keywords?: StringOrNumber | StringOrNumber[];
ISO?: number | number[];
// Type is wrong, can also be number.
Description?: StringOrNumber;
ImageDescription?: StringOrNumber;
// Extended properties for image regions, such as faces
RegionInfo?: {
AppliedToDimensions: {
W: number;
H: number;
Unit: string;
};
RegionList: {
Area: {
// (X,Y) // center of the rectangle
X: number;
Y: number;
W: number;
H: number;
Unit: string;
};
Rotation?: number;
Type?: string;
Name?: string;
}[];
};
}
export interface IMetadataRepository {
teardown(): Promise<void>;
readTags(path: string): Promise<ImmichTags>;
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
}

View File

@@ -0,0 +1,101 @@
export const INotificationRepository = 'INotificationRepository';
export type EmailImageAttachment = {
filename: string;
path: string;
cid: string;
};
export type SendEmailOptions = {
from: string;
to: string;
replyTo?: string;
subject: string;
html: string;
text: string;
imageAttachments?: EmailImageAttachment[];
smtp: SmtpOptions;
};
export type SmtpOptions = {
host: string;
port?: number;
username?: string;
password?: string;
ignoreCert?: boolean;
};
export enum EmailTemplate {
TEST_EMAIL = 'test',
// AUTH
WELCOME = 'welcome',
RESET_PASSWORD = 'reset-password',
// ALBUM
ALBUM_INVITE = 'album-invite',
ALBUM_UPDATE = 'album-update',
}
interface BaseEmailProps {
baseUrl: string;
customTemplate?: string;
}
export interface TestEmailProps extends BaseEmailProps {
displayName: string;
}
export interface WelcomeEmailProps extends BaseEmailProps {
displayName: string;
username: string;
password?: string;
}
export interface AlbumInviteEmailProps extends BaseEmailProps {
albumName: string;
albumId: string;
senderName: string;
recipientName: string;
cid?: string;
}
export interface AlbumUpdateEmailProps extends BaseEmailProps {
albumName: string;
albumId: string;
recipientName: string;
cid?: string;
}
export type EmailRenderRequest =
| {
template: EmailTemplate.TEST_EMAIL;
data: TestEmailProps;
customTemplate: string;
}
| {
template: EmailTemplate.WELCOME;
data: WelcomeEmailProps;
customTemplate: string;
}
| {
template: EmailTemplate.ALBUM_INVITE;
data: AlbumInviteEmailProps;
customTemplate: string;
}
| {
template: EmailTemplate.ALBUM_UPDATE;
data: AlbumUpdateEmailProps;
customTemplate: string;
};
export type SendEmailResponse = {
messageId: string;
response: any;
};
export interface INotificationRepository {
renderEmail(request: EmailRenderRequest): Promise<{ html: string; text: string }>;
sendEmail(options: SendEmailOptions): Promise<SendEmailResponse>;
verifySmtp(options: SmtpOptions): Promise<true>;
}

View File

@@ -0,0 +1,22 @@
import { UserinfoResponse } from 'openid-client';
export const IOAuthRepository = 'IOAuthRepository';
export type OAuthConfig = {
clientId: string;
clientSecret: string;
issuerUrl: string;
mobileOverrideEnabled: boolean;
mobileRedirectUri: string;
profileSigningAlgorithm: string;
scope: string;
signingAlgorithm: string;
};
export type OAuthProfile = UserinfoResponse;
export interface IOAuthRepository {
init(): void;
authorize(config: OAuthConfig, redirectUrl: string): Promise<string>;
getLogoutEndpoint(config: OAuthConfig): Promise<string | undefined>;
getProfile(config: OAuthConfig, url: string, redirectUrl: string): Promise<OAuthProfile>;
}

View File

@@ -0,0 +1,24 @@
export interface GitHubRelease {
id: number;
url: string;
tag_name: string;
name: string;
created_at: string;
published_at: string;
body: string;
}
export interface ServerBuildVersions {
nodejs: string;
ffmpeg: string;
libvips: string;
exiftool: string;
imagemagick: string;
}
export const IServerInfoRepository = 'IServerInfoRepository';
export interface IServerInfoRepository {
getGitHubRelease(): Promise<GitHubRelease>;
getBuildVersions(): Promise<ServerBuildVersions>;
}

View File

@@ -0,0 +1,23 @@
import { MetricOptions } from '@opentelemetry/api';
import { ClassConstructor } from 'class-transformer';
export const ITelemetryRepository = 'ITelemetryRepository';
export interface MetricGroupOptions {
enabled: boolean;
}
export interface IMetricGroupRepository {
addToCounter(name: string, value: number, options?: MetricOptions): void;
addToGauge(name: string, value: number, options?: MetricOptions): void;
addToHistogram(name: string, value: number, options?: MetricOptions): void;
configure(options: MetricGroupOptions): this;
}
export interface ITelemetryRepository {
setup(options: { repositories: ClassConstructor<unknown>[] }): void;
api: IMetricGroupRepository;
host: IMetricGroupRepository;
jobs: IMetricGroupRepository;
repo: IMetricGroupRepository;
}

View File

@@ -0,0 +1,8 @@
export const ITrashRepository = 'ITrashRepository';
export interface ITrashRepository {
empty(userId: string): Promise<number>;
restore(userId: string): Promise<number>;
restoreAll(assetIds: string[]): Promise<number>;
getDeletedIds(): AsyncIterableIterator<{ id: string }>;
}

View File

@@ -0,0 +1,9 @@
import { VersionHistoryEntity } from 'src/entities/version-history.entity';
export const IVersionHistoryRepository = 'IVersionHistoryRepository';
export interface IVersionHistoryRepository {
create(version: Omit<VersionHistoryEntity, 'id' | 'createdAt'>): Promise<VersionHistoryEntity>;
getAll(): Promise<VersionHistoryEntity[]>;
getLatest(): Promise<VersionHistoryEntity | undefined>;
}

View File

@@ -1,102 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUpdatedAtTriggers1737672307560 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
create function updated_at()
returns trigger as $$
begin
new."updatedAt" = now();
return new;
end;
$$ language 'plpgsql'`);
await queryRunner.query(`
create trigger activity_updated_at
before update on activity
for each row execute procedure updated_at()
`);
await queryRunner.query(`
create trigger albums_updated_at
before update on albums
for each row execute procedure updated_at()
`);
await queryRunner.query(`
create trigger api_keys_updated_at
before update on api_keys
for each row execute procedure updated_at()
`);
await queryRunner.query(`
create trigger asset_files_updated_at
before update on asset_files
for each row execute procedure updated_at()
`);
await queryRunner.query(`
create trigger assets_updated_at
before update on assets
for each row execute procedure updated_at()
`);
await queryRunner.query(`
create trigger libraries_updated_at
before update on libraries
for each row execute procedure updated_at()
`);
await queryRunner.query(`
create trigger memories_updated_at
before update on memories
for each row execute procedure updated_at()
`);
await queryRunner.query(`
create trigger partners_updated_at
before update on partners
for each row execute procedure updated_at()
`);
await queryRunner.query(`
create trigger person_updated_at
before update on person
for each row execute procedure updated_at()
`);
await queryRunner.query(`
create trigger sessions_updated_at
before update on sessions
for each row execute procedure updated_at()
`);
await queryRunner.query(`
create trigger tags_updated_at
before update on tags
for each row execute procedure updated_at()
`);
await queryRunner.query(`
create trigger users_updated_at
before update on users
for each row execute procedure updated_at()
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`drop trigger activity_updated_at on activity`);
await queryRunner.query(`drop trigger albums_updated_at on albums`);
await queryRunner.query(`drop trigger api_keys_updated_at on api_keys`);
await queryRunner.query(`drop trigger asset_files_updated_at on asset_files`);
await queryRunner.query(`drop trigger assets_updated_at on assets`);
await queryRunner.query(`drop trigger libraries_updated_at on libraries`);
await queryRunner.query(`drop trigger memories_updated_at on memories`);
await queryRunner.query(`drop trigger partners_updated_at on partners`);
await queryRunner.query(`drop trigger person_updated_at on person`);
await queryRunner.query(`drop trigger sessions_updated_at on sessions`);
await queryRunner.query(`drop trigger tags_updated_at on tags`);
await queryRunner.query(`drop trigger users_updated_at on users`);
await queryRunner.query(`drop function updated_at_trigger`);
}
}

View File

@@ -306,26 +306,17 @@ order by
with
"duplicates" as (
select
"assets"."duplicateId",
jsonb_agg("asset") as "assets"
"duplicateId",
jsonb_agg("assets") as "assets"
from
"assets"
left join lateral (
select
"assets".*,
"exif" as "exifInfo"
from
"exif"
where
"exif"."assetId" = "assets"."id"
) as "asset" on true
where
"assets"."ownerId" = $1::uuid
and "assets"."duplicateId" is not null
and "assets"."deletedAt" is null
and "assets"."isVisible" = $2
"ownerId" = $1::uuid
and "duplicateId" is not null
and "deletedAt" is null
and "isVisible" = $2
group by
"assets"."duplicateId"
"duplicateId"
),
"unique" as (
select

View File

@@ -60,8 +60,6 @@ union all
limit
$14
)
limit
$15
-- SearchRepository.searchSmart
select

View File

@@ -4,14 +4,10 @@ import { InjectKysely } from 'nestjs-kysely';
import { AlbumsSharedUsersUsers, DB } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserRole } from 'src/enum';
export type AlbumPermissionId = {
albumsId: string;
usersId: string;
};
import { AlbumPermissionId, IAlbumUserRepository } from 'src/interfaces/album-user.interface';
@Injectable()
export class AlbumUserRepository {
export class AlbumUserRepository implements IAlbumUserRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }] })
@@ -20,7 +16,10 @@ export class AlbumUserRepository {
}
@GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }, { role: AlbumUserRole.VIEWER }] })
update({ usersId, albumsId }: AlbumPermissionId, dto: Updateable<AlbumsSharedUsersUsers>) {
update(
{ usersId, albumsId }: AlbumPermissionId,
dto: Updateable<AlbumsSharedUsersUsers>,
): Promise<Selectable<AlbumsSharedUsersUsers>> {
return this.db
.updateTable('albums_shared_users_users')
.set(dto)

View File

@@ -43,8 +43,8 @@ import {
WithProperty,
WithoutProperty,
} from 'src/interfaces/asset.interface';
import { MapMarker, MapMarkerSearchOptions } from 'src/interfaces/map.interface';
import { AssetSearchOptions, SearchExploreItem, SearchExploreItemSet } from 'src/interfaces/search.interface';
import { MapMarker, MapMarkerSearchOptions } from 'src/repositories/map.repository';
import { anyUuid, asUuid, mapUpsertColumns } from 'src/utils/database';
import { Paginated, PaginationOptions, paginationHelper } from 'src/utils/pagination';
@@ -430,9 +430,9 @@ export class AssetRepository implements IAssetRepository {
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | undefined> {
const { ownerId, otherAssetId, livePhotoCID, type } = options;
return this.db
.selectFrom('assets')
.select('assets.id')
.innerJoin('exif', 'assets.id', 'exif.assetId')
.where('id', '!=', asUuid(otherAssetId))
.where('ownerId', '=', asUuid(ownerId))
@@ -677,23 +677,13 @@ export class AssetRepository implements IAssetRepository {
.with('duplicates', (qb) =>
qb
.selectFrom('assets')
.leftJoinLateral(
(qb) =>
qb
.selectFrom('exif')
.selectAll('assets')
.select((eb) => eb.table('exif').as('exifInfo'))
.whereRef('exif.assetId', '=', 'assets.id')
.as('asset'),
(join) => join.onTrue(),
)
.select('assets.duplicateId')
.select((eb) => eb.fn('jsonb_agg', [eb.table('asset')]).as('assets'))
.where('assets.ownerId', '=', asUuid(userId))
.where('assets.duplicateId', 'is not', null)
.where('assets.deletedAt', 'is', null)
.where('assets.isVisible', '=', true)
.groupBy('assets.duplicateId'),
.select('duplicateId')
.select((eb) => eb.fn<Assets[]>('jsonb_agg', [eb.table('assets')]).as('assets'))
.where('ownerId', '=', asUuid(userId))
.where('duplicateId', 'is not', null)
.where('deletedAt', 'is', null)
.where('isVisible', '=', true)
.groupBy('duplicateId'),
)
.with('unique', (qb) =>
qb

View File

@@ -1,24 +1,11 @@
import { Injectable } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob, CronTime } from 'cron';
import { CronCreate, CronUpdate, ICronRepository } from 'src/interfaces/cron.interface';
import { LoggingRepository } from 'src/repositories/logging.repository';
type CronBase = {
name: string;
start?: boolean;
};
export type CronCreate = CronBase & {
expression: string;
onTick: () => void;
};
export type CronUpdate = CronBase & {
expression?: string;
};
@Injectable()
export class CronRepository {
export class CronRepository implements ICronRepository {
constructor(
private schedulerRegistry: SchedulerRegistry,
private logger: LoggingRepository,

View File

@@ -1,23 +1,33 @@
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ICronRepository } from 'src/interfaces/cron.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository } from 'src/interfaces/job.interface';
import { ILibraryRepository } from 'src/interfaces/library.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IMapRepository } from 'src/interfaces/map.interface';
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { INotificationRepository } from 'src/interfaces/notification.interface';
import { IOAuthRepository } from 'src/interfaces/oauth.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IProcessRepository } from 'src/interfaces/process.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { IStackRepository } from 'src/interfaces/stack.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { ITelemetryRepository } from 'src/interfaces/telemetry.interface';
import { ITrashRepository } from 'src/interfaces/trash.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
@@ -61,44 +71,44 @@ import { ViewRepository } from 'src/repositories/view-repository';
export const repositories = [
AccessRepository,
ActivityRepository,
AlbumUserRepository,
AuditRepository,
ApiKeyRepository,
ConfigRepository,
CronRepository,
LoggingRepository,
MapRepository,
MediaRepository,
MemoryRepository,
MetadataRepository,
NotificationRepository,
OAuthRepository,
ServerInfoRepository,
TelemetryRepository,
TrashRepository,
ViewRepository,
VersionHistoryRepository,
];
export const providers = [
{ provide: IAlbumRepository, useClass: AlbumRepository },
{ provide: IAlbumUserRepository, useClass: AlbumUserRepository },
{ provide: IAssetRepository, useClass: AssetRepository },
{ provide: ICronRepository, useClass: CronRepository },
{ provide: ICryptoRepository, useClass: CryptoRepository },
{ provide: IDatabaseRepository, useClass: DatabaseRepository },
{ provide: IEventRepository, useClass: EventRepository },
{ provide: IJobRepository, useClass: JobRepository },
{ provide: ILibraryRepository, useClass: LibraryRepository },
{ provide: IMachineLearningRepository, useClass: MachineLearningRepository },
{ provide: IMapRepository, useClass: MapRepository },
{ provide: IMetadataRepository, useClass: MetadataRepository },
{ provide: IMoveRepository, useClass: MoveRepository },
{ provide: INotificationRepository, useClass: NotificationRepository },
{ provide: IOAuthRepository, useClass: OAuthRepository },
{ provide: IPartnerRepository, useClass: PartnerRepository },
{ provide: IPersonRepository, useClass: PersonRepository },
{ provide: IProcessRepository, useClass: ProcessRepository },
{ provide: ISearchRepository, useClass: SearchRepository },
{ provide: IServerInfoRepository, useClass: ServerInfoRepository },
{ provide: ISessionRepository, useClass: SessionRepository },
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
{ provide: IStackRepository, useClass: StackRepository },
{ provide: IStorageRepository, useClass: StorageRepository },
{ provide: ISystemMetadataRepository, useClass: SystemMetadataRepository },
{ provide: ITagRepository, useClass: TagRepository },
{ provide: ITelemetryRepository, useClass: TelemetryRepository },
{ provide: ITrashRepository, useClass: TrashRepository },
{ provide: IUserRepository, useClass: UserRepository },
{ provide: IVersionHistoryRepository, useClass: VersionHistoryRepository },
];

View File

@@ -11,41 +11,24 @@ import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db';
import { AssetEntity, withExif } from 'src/entities/asset.entity';
import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity';
import { LogLevel, SystemMetadataKey } from 'src/enum';
import {
GeoPoint,
IMapRepository,
MapMarker,
MapMarkerSearchOptions,
ReverseGeocodeResult,
} from 'src/interfaces/map.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
export interface MapMarkerSearchOptions {
isArchived?: boolean;
isFavorite?: boolean;
fileCreatedBefore?: Date;
fileCreatedAfter?: Date;
}
export interface GeoPoint {
latitude: number;
longitude: number;
}
export interface ReverseGeocodeResult {
country: string | null;
state: string | null;
city: string | null;
}
export interface MapMarker extends ReverseGeocodeResult {
id: string;
lat: number;
lon: number;
}
interface MapDB extends DB {
geodata_places_tmp: GeodataPlaces;
naturalearth_countries_tmp: NaturalearthCountries;
}
@Injectable()
export class MapRepository {
export class MapRepository implements IMapRepository {
constructor(
private configRepository: ConfigRepository,
@Inject(ISystemMetadataRepository) private metadataRepository: ISystemMetadataRepository,

View File

@@ -1,72 +1,11 @@
import { Injectable } from '@nestjs/common';
import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
import { DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
import geotz from 'geo-tz';
import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface';
import { LoggingRepository } from 'src/repositories/logging.repository';
interface ExifDuration {
Value: number;
Scale?: number;
}
type StringOrNumber = string | number;
type TagsWithWrongTypes =
| 'FocalLength'
| 'Duration'
| 'Description'
| 'ImageDescription'
| 'RegionInfo'
| 'TagsList'
| 'Keywords'
| 'HierarchicalSubject'
| 'ISO';
export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
ContentIdentifier?: string;
MotionPhoto?: number;
MotionPhotoVersion?: number;
MotionPhotoPresentationTimestampUs?: number;
MediaGroupUUID?: string;
ImagePixelDepth?: string;
FocalLength?: number;
Duration?: number | string | ExifDuration;
EmbeddedVideoType?: string;
EmbeddedVideoFile?: BinaryField;
MotionPhotoVideo?: BinaryField;
TagsList?: StringOrNumber[];
HierarchicalSubject?: StringOrNumber[];
Keywords?: StringOrNumber | StringOrNumber[];
ISO?: number | number[];
// Type is wrong, can also be number.
Description?: StringOrNumber;
ImageDescription?: StringOrNumber;
// Extended properties for image regions, such as faces
RegionInfo?: {
AppliedToDimensions: {
W: number;
H: number;
Unit: string;
};
RegionList: {
Area: {
// (X,Y) // center of the rectangle
X: number;
Y: number;
W: number;
H: number;
Unit: string;
};
Rotation?: number;
Type?: string;
Name?: string;
}[];
};
}
@Injectable()
export class MetadataRepository {
export class MetadataRepository implements IMetadataRepository {
private exiftool = new ExifTool({
defaultVideosToUTC: true,
backfillTimezones: true,

View File

@@ -1,5 +1,6 @@
import { EmailRenderRequest, EmailTemplate } from 'src/interfaces/notification.interface';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { EmailRenderRequest, EmailTemplate, NotificationRepository } from 'src/repositories/notification.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { ILoggingRepository } from 'src/types';
import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock';
import { Mocked } from 'vitest';

View File

@@ -6,104 +6,18 @@ import { AlbumInviteEmail } from 'src/emails/album-invite.email';
import { AlbumUpdateEmail } from 'src/emails/album-update.email';
import { TestEmail } from 'src/emails/test.email';
import { WelcomeEmail } from 'src/emails/welcome.email';
import {
EmailRenderRequest,
EmailTemplate,
INotificationRepository,
SendEmailOptions,
SendEmailResponse,
SmtpOptions,
} from 'src/interfaces/notification.interface';
import { LoggingRepository } from 'src/repositories/logging.repository';
export type EmailImageAttachment = {
filename: string;
path: string;
cid: string;
};
export type SendEmailOptions = {
from: string;
to: string;
replyTo?: string;
subject: string;
html: string;
text: string;
imageAttachments?: EmailImageAttachment[];
smtp: SmtpOptions;
};
export type SmtpOptions = {
host: string;
port?: number;
username?: string;
password?: string;
ignoreCert?: boolean;
};
export enum EmailTemplate {
TEST_EMAIL = 'test',
// AUTH
WELCOME = 'welcome',
RESET_PASSWORD = 'reset-password',
// ALBUM
ALBUM_INVITE = 'album-invite',
ALBUM_UPDATE = 'album-update',
}
interface BaseEmailProps {
baseUrl: string;
customTemplate?: string;
}
export interface TestEmailProps extends BaseEmailProps {
displayName: string;
}
export interface WelcomeEmailProps extends BaseEmailProps {
displayName: string;
username: string;
password?: string;
}
export interface AlbumInviteEmailProps extends BaseEmailProps {
albumName: string;
albumId: string;
senderName: string;
recipientName: string;
cid?: string;
}
export interface AlbumUpdateEmailProps extends BaseEmailProps {
albumName: string;
albumId: string;
recipientName: string;
cid?: string;
}
export type EmailRenderRequest =
| {
template: EmailTemplate.TEST_EMAIL;
data: TestEmailProps;
customTemplate: string;
}
| {
template: EmailTemplate.WELCOME;
data: WelcomeEmailProps;
customTemplate: string;
}
| {
template: EmailTemplate.ALBUM_INVITE;
data: AlbumInviteEmailProps;
customTemplate: string;
}
| {
template: EmailTemplate.ALBUM_UPDATE;
data: AlbumUpdateEmailProps;
customTemplate: string;
};
export type SendEmailResponse = {
messageId: string;
response: any;
};
@Injectable()
export class NotificationRepository {
export class NotificationRepository implements INotificationRepository {
constructor(private logger: LoggingRepository) {
this.logger.setContext(NotificationRepository.name);
}

View File

@@ -1,21 +1,10 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { custom, generators, Issuer, UserinfoResponse } from 'openid-client';
import { custom, generators, Issuer } from 'openid-client';
import { IOAuthRepository, OAuthConfig, OAuthProfile } from 'src/interfaces/oauth.interface';
import { LoggingRepository } from 'src/repositories/logging.repository';
export type OAuthConfig = {
clientId: string;
clientSecret: string;
issuerUrl: string;
mobileOverrideEnabled: boolean;
mobileRedirectUri: string;
profileSigningAlgorithm: string;
scope: string;
signingAlgorithm: string;
};
export type OAuthProfile = UserinfoResponse;
@Injectable()
export class OAuthRepository {
export class OAuthRepository implements IOAuthRepository {
constructor(private logger: LoggingRepository) {
this.logger.setContext(OAuthRepository.name);
}

View File

@@ -133,6 +133,10 @@ export class PersonRepository implements IPersonRepository {
)
.where('person.ownerId', '=', userId)
.orderBy('person.isHidden', 'asc')
.orderBy(sql`NULLIF(person.name, '') is null`, 'asc')
.orderBy((eb) => eb.fn.count('asset_faces.assetId'), 'desc')
.orderBy(sql`NULLIF(person.name, '')`, sql`asc nulls last`)
.orderBy('person.createdAt')
.having((eb) =>
eb.or([
eb('person.name', '!=', ''),
@@ -157,13 +161,6 @@ export class PersonRepository implements IPersonRepository {
),
),
)
.$if(!options?.closestFaceAssetId, (qb) =>
qb
.orderBy(sql`NULLIF(person.name, '') is null`, 'asc')
.orderBy((eb) => eb.fn.count('asset_faces.assetId'), 'desc')
.orderBy(sql`NULLIF(person.name, '')`, sql`asc nulls last`)
.orderBy('person.createdAt'),
)
.$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false))
.offset(pagination.skip ?? 0)
.limit(pagination.take + 1)

View File

@@ -69,13 +69,12 @@ export class SearchRepository implements ISearchRepository {
},
],
})
async searchRandom(size: number, options: AssetSearchOptions): Promise<AssetEntity[]> {
searchRandom(size: number, options: AssetSearchOptions): Promise<AssetEntity[]> {
const uuid = randomUUID();
const builder = searchAssetBuilder(this.db, options);
const lessThan = builder.where('assets.id', '<', uuid).orderBy('assets.id').limit(size);
const greaterThan = builder.where('assets.id', '>', uuid).orderBy('assets.id').limit(size);
const { rows } = await sql`${lessThan} union all ${greaterThan} limit ${size}`.execute(this.db);
return rows as any as AssetEntity[];
return sql`${lessThan} union all ${greaterThan}`.execute(this.db) as any as Promise<AssetEntity[]>;
}
@GenerateSql({

View File

@@ -4,27 +4,10 @@ import { exec as execCallback } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { promisify } from 'node:util';
import sharp from 'sharp';
import { GitHubRelease, IServerInfoRepository, ServerBuildVersions } from 'src/interfaces/server-info.interface';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
export interface GitHubRelease {
id: number;
url: string;
tag_name: string;
name: string;
created_at: string;
published_at: string;
body: string;
}
export interface ServerBuildVersions {
nodejs: string;
ffmpeg: string;
libvips: string;
exiftool: string;
imagemagick: string;
}
const exec = promisify(execCallback);
const maybeFirstLine = async (command: string): Promise<string> => {
try {
@@ -51,7 +34,7 @@ const getLockfileVersion = (name: string, lockfile?: BuildLockfile) => {
};
@Injectable()
export class ServerInfoRepository {
export class ServerInfoRepository implements IServerInfoRepository {
constructor(
private configRepository: ConfigRepository,
private logger: LoggingRepository,

View File

@@ -15,12 +15,11 @@ import { MetricService } from 'nestjs-otel';
import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils';
import { serverVersion } from 'src/constants';
import { ImmichTelemetry, MetadataKey } from 'src/enum';
import { IMetricGroupRepository, ITelemetryRepository, MetricGroupOptions } from 'src/interfaces/telemetry.interface';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
type MetricGroupOptions = { enabled: boolean };
export class MetricGroupRepository {
class MetricGroupRepository implements IMetricGroupRepository {
private enabled = false;
constructor(private metricService: MetricService) {}
@@ -87,7 +86,7 @@ export const teardownTelemetry = async () => {
};
@Injectable()
export class TelemetryRepository {
export class TelemetryRepository implements ITelemetryRepository {
api: MetricGroupRepository;
host: MetricGroupRepository;
jobs: MetricGroupRepository;

View File

@@ -3,8 +3,9 @@ import { InjectKysely } from 'nestjs-kysely';
import { DB } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetStatus } from 'src/enum';
import { ITrashRepository } from 'src/interfaces/trash.interface';
export class TrashRepository {
export class TrashRepository implements ITrashRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
getDeletedIds(): AsyncIterableIterator<{ id: string }> {

View File

@@ -3,23 +3,25 @@ import { Insertable, Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DB, VersionHistory } from 'src/db';
import { GenerateSql } from 'src/decorators';
import { VersionHistoryEntity } from 'src/entities/version-history.entity';
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
@Injectable()
export class VersionHistoryRepository {
export class VersionHistoryRepository implements IVersionHistoryRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql()
getAll() {
getAll(): Promise<VersionHistoryEntity[]> {
return this.db.selectFrom('version_history').selectAll().orderBy('createdAt', 'desc').execute();
}
@GenerateSql()
getLatest() {
getLatest(): Promise<VersionHistoryEntity | undefined> {
return this.db.selectFrom('version_history').selectAll().orderBy('createdAt', 'desc').executeTakeFirst();
}
@GenerateSql({ params: [{ version: 'v1.123.0' }] })
create(version: Insertable<VersionHistory>) {
create(version: Insertable<VersionHistory>): Promise<VersionHistoryEntity> {
return this.db.insertInto('version_history').values(version).returningAll().executeTakeFirstOrThrow();
}
}

View File

@@ -2,11 +2,11 @@ import { BadRequestException } from '@nestjs/common';
import _ from 'lodash';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { AlbumUserRole } from 'src/enum';
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { AlbumService } from 'src/services/album.service';
import { IAlbumUserRepository } from 'src/types';
import { albumStub } from 'test/fixtures/album.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub';

View File

@@ -5,12 +5,13 @@ import { UserEntity } from 'src/entities/user.entity';
import { AuthType, Permission } from 'src/enum';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IOAuthRepository } from 'src/interfaces/oauth.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { AuthService } from 'src/services/auth.service';
import { IApiKeyRepository, IOAuthRepository } from 'src/types';
import { IApiKeyRepository } from 'src/types';
import { keyStub } from 'test/fixtures/api-key.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { sessionStub } from 'test/fixtures/session.stub';

View File

@@ -19,7 +19,7 @@ import {
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { UserEntity } from 'src/entities/user.entity';
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum';
import { OAuthProfile } from 'src/repositories/oauth.repository';
import { OAuthProfile } from 'src/interfaces/oauth.interface';
import { BaseService } from 'src/services/base.service';
import { AuthApiKey } from 'src/types';
import { isGranted } from 'src/utils/access';

View File

@@ -2,13 +2,14 @@ import { PassThrough } from 'node:stream';
import { defaults, SystemConfig } from 'src/config';
import { StorageCore } from 'src/cores/storage.core';
import { ImmichWorker, StorageFolder } from 'src/enum';
import { ICronRepository } from 'src/interfaces/cron.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { JobStatus } from 'src/interfaces/job.interface';
import { IProcessRepository } from 'src/interfaces/process.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BackupService } from 'src/services/backup.service';
import { IConfigRepository, ICronRepository } from 'src/types';
import { IConfigRepository } from 'src/types';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { mockSpawn, newTestService } from 'test/utils';
import { describe, Mocked } from 'vitest';

View File

@@ -6,44 +6,44 @@ import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { Users } from 'src/db';
import { UserEntity } from 'src/entities/user.entity';
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ICronRepository } from 'src/interfaces/cron.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository } from 'src/interfaces/job.interface';
import { ILibraryRepository } from 'src/interfaces/library.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IMapRepository } from 'src/interfaces/map.interface';
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { INotificationRepository } from 'src/interfaces/notification.interface';
import { IOAuthRepository } from 'src/interfaces/oauth.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IProcessRepository } from 'src/interfaces/process.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { IStackRepository } from 'src/interfaces/stack.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { ITelemetryRepository } from 'src/interfaces/telemetry.interface';
import { ITrashRepository } from 'src/interfaces/trash.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AuditRepository } from 'src/repositories/audit.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CronRepository } from 'src/repositories/cron.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MapRepository } from 'src/repositories/map.repository';
import { MediaRepository } from 'src/repositories/media.repository';
import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { ServerInfoRepository } from 'src/repositories/server-info.repository';
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { ViewRepository } from 'src/repositories/view-repository';
import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access';
import { getConfig, updateConfig } from 'src/utils/config';
@@ -57,10 +57,10 @@ export class BaseService {
protected activityRepository: ActivityRepository,
protected auditRepository: AuditRepository,
@Inject(IAlbumRepository) protected albumRepository: IAlbumRepository,
protected albumUserRepository: AlbumUserRepository,
@Inject(IAlbumUserRepository) protected albumUserRepository: IAlbumUserRepository,
@Inject(IAssetRepository) protected assetRepository: IAssetRepository,
protected configRepository: ConfigRepository,
protected cronRepository: CronRepository,
@Inject(ICronRepository) protected cronRepository: ICronRepository,
@Inject(ICryptoRepository) protected cryptoRepository: ICryptoRepository,
@Inject(IDatabaseRepository) protected databaseRepository: IDatabaseRepository,
@Inject(IEventRepository) protected eventRepository: IEventRepository,
@@ -68,28 +68,28 @@ export class BaseService {
protected keyRepository: ApiKeyRepository,
@Inject(ILibraryRepository) protected libraryRepository: ILibraryRepository,
@Inject(IMachineLearningRepository) protected machineLearningRepository: IMachineLearningRepository,
protected mapRepository: MapRepository,
@Inject(IMapRepository) protected mapRepository: IMapRepository,
protected mediaRepository: MediaRepository,
protected memoryRepository: MemoryRepository,
protected metadataRepository: MetadataRepository,
@Inject(IMetadataRepository) protected metadataRepository: IMetadataRepository,
@Inject(IMoveRepository) protected moveRepository: IMoveRepository,
protected notificationRepository: NotificationRepository,
protected oauthRepository: OAuthRepository,
@Inject(INotificationRepository) protected notificationRepository: INotificationRepository,
@Inject(IOAuthRepository) protected oauthRepository: IOAuthRepository,
@Inject(IPartnerRepository) protected partnerRepository: IPartnerRepository,
@Inject(IPersonRepository) protected personRepository: IPersonRepository,
@Inject(IProcessRepository) protected processRepository: IProcessRepository,
@Inject(ISearchRepository) protected searchRepository: ISearchRepository,
protected serverInfoRepository: ServerInfoRepository,
@Inject(IServerInfoRepository) protected serverInfoRepository: IServerInfoRepository,
@Inject(ISessionRepository) protected sessionRepository: ISessionRepository,
@Inject(ISharedLinkRepository) protected sharedLinkRepository: ISharedLinkRepository,
@Inject(IStackRepository) protected stackRepository: IStackRepository,
@Inject(IStorageRepository) protected storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) protected systemMetadataRepository: ISystemMetadataRepository,
@Inject(ITagRepository) protected tagRepository: ITagRepository,
protected telemetryRepository: TelemetryRepository,
protected trashRepository: TrashRepository,
@Inject(ITelemetryRepository) protected telemetryRepository: ITelemetryRepository,
@Inject(ITrashRepository) protected trashRepository: ITrashRepository,
@Inject(IUserRepository) protected userRepository: IUserRepository,
protected versionRepository: VersionHistoryRepository,
@Inject(IVersionHistoryRepository) protected versionRepository: IVersionHistoryRepository,
protected viewRepository: ViewRepository,
) {
this.logger.setContext(this.constructor.name);

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