chore: merge main

This commit is contained in:
Jason Rasmussen 2026-08-21 10:23:51 -04:00
commit 39ad4e6704
No known key found for this signature in database
GPG key ID: 2EF24B77EAFA4A41
555 changed files with 17686 additions and 11043 deletions

View file

@ -225,6 +225,13 @@ jobs:
github_token: ${{ steps.token.outputs.token }}
working_directory: ./mobile
- name: Setup Ruby
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: ./mobile/ios
- name: Install dependencies
run: mise //mobile:install:ci
@ -235,18 +242,6 @@ jobs:
working-directory: ./mobile
run: flutter build ios --config-only --no-codesign
- name: Setup Ruby
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: ./mobile/ios
- name: Install CocoaPods dependencies
working-directory: ./mobile/ios
run: |
pod install
- name: Create API Key
env:
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}

View file

@ -25,7 +25,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@b7c3adeb54330db1903d27c61db520e5661ad55b # v0.1.11
uses: oasdiff/oasdiff-action/breaking@033c15c845bef10f148afb0fa781bf1b2a7fe1bf # v0.1.12
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json

View file

@ -58,7 +58,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@ -71,7 +71,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@ -84,6 +84,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: '/language:${{matrix.language}}'

View file

@ -38,7 +38,7 @@ jobs:
run: pnpm --recursive install && pnpm run --recursive --if-present --parallel format:fix
- name: Commit and push
uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 # v10.0.0
uses: EndBug/add-and-commit@645ecc0dd0a57f4d86d26c0aa5fc42c0a856fbca # v11.0.0
with:
default_author: github_actions
message: 'chore: fix formatting'

View file

@ -70,7 +70,7 @@ jobs:
# TODO move to mise
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
- name: Bump version
env:
@ -88,7 +88,7 @@ jobs:
- name: Commit and tag
id: push-tag
uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 # v10.0.0
uses: EndBug/add-and-commit@645ecc0dd0a57f4d86d26c0aa5fc42c0a856fbca # v11.0.0
with:
default_author: github_actions
message: 'chore: version ${{ steps.output.outputs.version }}'

View file

@ -734,7 +734,7 @@ jobs:
github_token: ${{ steps.token.outputs.token }}
- name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
run: pnpm --filter immich install --frozen-lockfile
- name: Run API generation
run: mise //:open-api
working-directory: open-api
@ -792,7 +792,7 @@ jobs:
github_token: ${{ steps.token.outputs.token }}
- name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile
- name: Build plugins
run: mise //:plugins

View file

@ -156,7 +156,7 @@ services:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3acc0687f2a2e1091fae6450d7842dd658c941338cf0a873ddd9e14b9e4ea4dd
healthcheck:
test: redis-cli ping || exit 1
test: redis-cli ping | grep -q PONG || exit 1
database:
container_name: immich_postgres

View file

@ -58,7 +58,7 @@ services:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3acc0687f2a2e1091fae6450d7842dd658c941338cf0a873ddd9e14b9e4ea4dd
healthcheck:
test: redis-cli ping || exit 1
test: redis-cli ping | grep -q PONG || exit 1
restart: always
database:
@ -97,7 +97,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.4.7-ubuntu@sha256:d327f509400334fbea74a282d287f20c3187222741ec99e76f253cac16c90129
image: grafana/grafana:12.4.8-ubuntu@sha256:473cfc1712694135a5d9dc012dc573f2a48e51efb9ac0da50418918f3cbd8ad3
volumes:
- grafana-data:/var/lib/grafana

View file

@ -68,7 +68,7 @@ services:
volumes:
- ./redis:/data
healthcheck:
test: redis-cli ping || exit 1
test: redis-cli ping | grep -q PONG || exit 1
restart: always
database:

View file

@ -51,7 +51,7 @@ services:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:3acc0687f2a2e1091fae6450d7842dd658c941338cf0a873ddd9e14b9e4ea4dd
healthcheck:
test: redis-cli ping || exit 1
test: redis-cli ping | grep -q PONG || exit 1
restart: always
database:

View file

@ -154,9 +154,10 @@ identity_providers:
- client_id: 'immich'
client_name: 'Immich'
# https://www.authelia.com/integration/openid-connect/frequently-asked-questions/#how-do-i-generate-a-client-identifier-or-client-secret
client_secret: $pbkdf2-sha512$310000$c8p78n7pUMln0jzvd4aK4Q$JNRBzwAo0ek5qKn50cFzzvE9RXV88h1wJn5KGiHrD0YKtZaR/nCb2CJPOsKaPK0hjf.9yHxzQGZziziccp6Yng'
client_secret: '$pbkdf2-sha512$310000$c8p78n7pUMln0jzvd4aK4Q$JNRBzwAo0ek5qKn50cFzzvE9RXV88h1wJn5KGiHrD0YKtZaR/nCb2CJPOsKaPK0hjf.9yHxzQGZziziccp6Yng'
public: false
require_pkce: false
require_pkce: true
pkce_challenge_method: 'S256'
redirect_uris:
- 'https://example.immich.app/auth/login'
- 'https://example.immich.app/user-settings'
@ -180,7 +181,7 @@ Configuration of OAuth in Immich System Settings
| Setting | Value |
| ---------------------------------- | ------------------------------------------------------------------- |
| Issuer URL | `https://example.immich.app/.well-known/openid-configuration` |
| Issuer URL | `https://auth.example.com` |
| Client ID | immich |
| Client Secret | 0v89FXkQOWO\***\*\*\*\*\***\*\*\***\*\*\*\*\***mprbvXD549HH6s1iw... |
| Token Endpoint Auth Method | client_secret_post |
@ -212,21 +213,21 @@ Configuration of Authorised redirect URIs (Authentik OAuth2/OpenID Provider)
Configuration of OAuth in Immich System Settings
| Setting | Value |
| ---------------------------- | ---------------------------------------------------------------------------------- |
| Issuer URL | `https://example.immich.app/application/o/immich/.well-known/openid-configuration` |
| Client ID | AFCj2rM1f4rps**\*\*\*\***\***\*\*\*\***lCLEum6hH9... |
| Client Secret | 0v89FXkQOWO\***\*\*\*\*\***\*\*\***\*\*\*\*\***mprbvXD549HH6s1iw... |
| Scope | openid email profile |
| Signing Algorithm | RS256 |
| Storage Label Claim | preferred_username |
| Storage Quota Claim | immich_quota |
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
| Button Text | Sign in with Authentik (optional) |
| Auto Register | Enabled (optional) |
| Auto Launch | Enabled (optional) |
| Mobile Redirect URI Override | Disable |
| Mobile Redirect URI | |
| Setting | Value |
| ---------------------------- | ------------------------------------------------------------------- |
| Issuer URL | `https://authentik.example.com/application/o/immich/` |
| Client ID | AFCj2rM1f4rps**\*\*\*\***\***\*\*\*\***lCLEum6hH9... |
| Client Secret | 0v89FXkQOWO\***\*\*\*\*\***\*\*\***\*\*\*\*\***mprbvXD549HH6s1iw... |
| Scope | openid email profile |
| Signing Algorithm | RS256 |
| Storage Label Claim | preferred_username |
| Storage Quota Claim | immich_quota |
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
| Button Text | Sign in with Authentik (optional) |
| Auto Register | Enabled (optional) |
| Auto Launch | Enabled (optional) |
| Mobile Redirect URI Override | Disable |
| Mobile Redirect URI | |
</details>

View file

@ -15,7 +15,7 @@ If you are looking to import your Google Photos takeout, we recommend this commu
## Requirements
- Node.js 20 or above
- Node.js 22 or above
- Npm
If you can't install node/npm, there is also a Docker version available below.

View file

@ -52,9 +52,9 @@ REMOTE_BACKUP_PATH="/path/to/remote/backup/directory"
### Local
# Backup Immich database
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=<DB_USERNAME> > "$UPLOAD_LOCATION"/database-backup/immich-database.sql
docker exec -t immich_postgres pg_dump --clean --if-exists --dbname <DB_DATABASE_NAME> --username=<DB_USERNAME> > "$UPLOAD_LOCATION"/database-backup/immich-database.sql
# For deduplicating backup programs such as Borg or Restic, compressing the content can increase backup size by making it harder to deduplicate. If you are using a different program or still prefer to compress, you can use the following command instead:
# docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=<DB_USERNAME> | /usr/bin/gzip --rsyncable > "$UPLOAD_LOCATION"/database-backup/immich-database.sql.gz
# docker exec -t immich_postgres pg_dump --clean --if-exists --dbname <DB_DATABASE_NAME> --username=<DB_USERNAME> | /usr/bin/gzip --rsyncable > "$UPLOAD_LOCATION"/database-backup/immich-database.sql.gz
### Append to local Borg repository
borg create "$BACKUP_PATH/immich-borg::{now}" "$UPLOAD_LOCATION" --exclude "$UPLOAD_LOCATION"/thumbs/ --exclude "$UPLOAD_LOCATION"/encoded-video/

View file

@ -28,4 +28,4 @@ run = "prettier --write ."
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
[tools]
wrangler = "4.119.0"
wrangler = "4.122.0"

View file

@ -2,6 +2,8 @@ import { useWindowSize } from '@docusaurus/theme-common';
import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem';
import React, { useEffect, useState } from 'react';
const ARCHIVED_VERSIONS_URL = 'https://version.immich.cloud/v1/docs/versions';
export default function VersionSwitcher(): JSX.Element {
const [versions, setVersions] = useState([]);
const [activeLabel, setLabel] = useState('Versions');
@ -11,12 +13,7 @@ export default function VersionSwitcher(): JSX.Element {
useEffect(() => {
async function getVersions() {
try {
let baseUrl = 'https://docs.immich.app';
if (window.location.origin === 'http://localhost:3005') {
baseUrl = window.location.origin;
}
const response = await fetch(`${baseUrl}/archived-versions.json`);
const response = await fetch(ARCHIVED_VERSIONS_URL);
const archiveVersions = await response.json();

View file

@ -1,3 +1,4 @@
/archived-versions.json https://version.immich.cloud/v1/docs/versions 307
/ /overview/quick-start 307
/mobile-app-beta-program /features/mobile-app 307
/contribution-guidelines /overview/support-the-project#contributing 307

View file

@ -1,265 +0,0 @@
[
{
"label": "v3.1.0",
"url": "https://docs.v3.1.0.archive.immich.app"
},
{
"label": "v3.0.3",
"url": "https://docs.v3.0.3.archive.immich.app"
},
{
"label": "v2.7.5",
"url": "https://docs.v2.7.5.archive.immich.app"
},
{
"label": "v2.6.3",
"url": "https://docs.v2.6.3.archive.immich.app"
},
{
"label": "v2.5.6",
"url": "https://docs.v2.5.6.archive.immich.app"
},
{
"label": "v2.4.1",
"url": "https://docs.v2.4.1.archive.immich.app"
},
{
"label": "v2.3.1",
"url": "https://docs.v2.3.1.archive.immich.app"
},
{
"label": "v2.2.3",
"url": "https://docs.v2.2.3.archive.immich.app"
},
{
"label": "v2.1.0",
"url": "https://docs.v2.1.0.archive.immich.app"
},
{
"label": "v2.0.1",
"url": "https://docs.v2.0.1.archive.immich.app"
},
{
"label": "v1.144.1",
"url": "https://docs.v1.144.1.archive.immich.app"
},
{
"label": "v1.143.1",
"url": "https://docs.v1.143.1.archive.immich.app"
},
{
"label": "v1.142.1",
"url": "https://v1.142.1.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.141.1",
"url": "https://v1.141.1.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.140.1",
"url": "https://v1.140.1.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.139.4",
"url": "https://v1.139.4.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.138.1",
"url": "https://v1.138.1.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.137.3",
"url": "https://v1.137.3.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.136.0",
"url": "https://v1.136.0.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.135.3",
"url": "https://v1.135.3.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.134.0",
"url": "https://v1.134.0.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.133.1",
"url": "https://v1.133.1.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.132.3",
"url": "https://v1.132.3.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.131.3",
"url": "https://v1.131.3.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.130.3",
"url": "https://v1.130.3.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.129.0",
"url": "https://v1.129.0.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.128.0",
"url": "https://v1.128.0.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.127.0",
"url": "https://v1.127.0.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.126.1",
"url": "https://v1.126.1.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.125.7",
"url": "https://v1.125.7.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.124.2",
"url": "https://v1.124.2.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.123.0",
"url": "https://v1.123.0.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.122.3",
"url": "https://v1.122.3.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.121.0",
"url": "https://v1.121.0.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.120.2",
"url": "https://v1.120.2.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.119.1",
"url": "https://v1.119.1.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.118.2",
"url": "https://v1.118.2.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.117.0",
"url": "https://v1.117.0.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.116.2",
"url": "https://v1.116.2.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.115.0",
"url": "https://v1.115.0.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.114.0",
"url": "https://v1.114.0.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.113.1",
"url": "https://v1.113.1.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.112.1",
"url": "https://v1.112.1.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.111.0",
"url": "https://v1.111.0.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.110.0",
"url": "https://v1.110.0.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.109.2",
"url": "https://v1.109.2.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.108.0",
"url": "https://v1.108.0.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.107.2",
"url": "https://v1.107.2.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.106.4",
"url": "https://v1.106.4.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.105.1",
"url": "https://v1.105.1.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.104.0",
"url": "https://v1.104.0.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.103.1",
"url": "https://v1.103.1.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.102.3",
"url": "https://v1.102.3.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.101.0",
"url": "https://v1.101.0.archive.immich.app",
"rootPath": "/docs"
},
{
"label": "v1.100.0",
"url": "https://v1.100.0.archive.immich.app",
"rootPath": "/docs"
}
]

View file

@ -16,6 +16,7 @@ services:
context: ../
dockerfile: server/Dockerfile
cache_from:
# hash is the sha256 of the build-args in docker.yml
- type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-amd64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main
- type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-arm64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main
args:

View file

@ -2,13 +2,8 @@ import js from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
import globals from 'globals';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import typescriptEslint from 'typescript-eslint';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default typescriptEslint.config([
eslintPluginUnicorn.configs.recommended,
eslintPluginPrettierRecommended,
@ -29,7 +24,7 @@ export default typescriptEslint.config([
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
tsconfigRootDir: import.meta.dirname,
},
},
@ -50,6 +45,7 @@ export default typescriptEslint.config([
'unicorn/prefer-promise-with-resolvers': 'off',
'unicorn/no-declarations-before-early-exit': 'off',
'unicorn/prefer-simple-condition-first': 'off',
'unicorn/single-line-block-comment-style': ['error', 'single-line'],
curly: 2,
'prettier/prettier': 0,
'unicorn/name-replacements': 'off',

View file

@ -40,7 +40,7 @@
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^72.0.0",
"eslint-plugin-unicorn": "^73.0.0",
"exiftool-vendored": "^35.0.0",
"globals": "^17.0.0",
"luxon": "^3.4.4",
@ -48,7 +48,7 @@
"pngjs": "^7.0.0",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.34.5",
"sharp": "^0.35.3",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"@typescript/native": "npm:typescript@^7.0.2",

View file

@ -1,651 +0,0 @@
import { LoginResponseDto } from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe('/duplicates', () => {
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
[user1, user2] = await Promise.all([
utils.userSetup(admin.accessToken, createUserDto.user1),
utils.userSetup(admin.accessToken, createUserDto.user2),
]);
});
beforeEach(async () => {
// Reset assets, albums, tags, and stacks between tests to ensure clean state for repeated test runs
// Note: We don't reset users since they're set up once in beforeAll
// Stack must be reset before asset due to foreign key constraint
await utils.resetDatabase(['stack', 'asset', 'album', 'tag']);
});
describe('GET /duplicates', () => {
it('should return empty array when no duplicates', async () => {
const { status, body } = await request(app)
.get('/duplicates')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([]);
});
it('should return duplicate groups with suggestedKeepAssetIds', async () => {
// Create assets with different file sizes for duplicate detection
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Manually set duplicateId on both assets to create a duplicate group
const duplicateId = '00000000-0000-4000-8000-000000000001';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.get('/duplicates')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([
{
duplicateId,
assets: expect.arrayContaining([
expect.objectContaining({ id: asset1.id }),
expect.objectContaining({ id: asset2.id }),
]),
suggestedKeepAssetIds: expect.any(Array),
},
]);
expect(body[0].suggestedKeepAssetIds.length).toBe(1);
});
});
describe('POST /duplicates/resolve', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.post('/duplicates/resolve')
.send({
groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should return failure for non-existent duplicate group', async () => {
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }],
});
expect(status).toBe(200);
expect(body).toEqual({
status: 'COMPLETED',
results: [
{
duplicateId: uuidDto.dummy,
status: 'FAILED',
reason: expect.stringContaining('not found or access denied'),
},
],
});
});
it('should resolve duplicate group with keepers', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000002';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body).toEqual({
status: 'COMPLETED',
results: [
{
duplicateId,
status: 'SUCCESS',
},
],
});
// Verify side effects: duplicateId cleared on kept asset
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.duplicateId).toBeNull();
// Verify side effects: trashed asset is trashed and duplicateId cleared
const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
expect(trashedAsset.isTrashed).toBe(true);
expect(trashedAsset.duplicateId).toBeNull();
});
it('should reject when keepAssetIds and trashAssetIds overlap', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000003';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('disjoint');
});
it('should require keepAssetIds when partially trashing', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000004';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('must cover all assets');
});
it('should reject partial resolution (not all assets covered)', async () => {
const [asset1, asset2, asset3] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000010';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset3.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('must cover all assets');
});
it('should reject asset not in duplicate group', async () => {
const [asset1, asset2, outsideAsset] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000011';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [outsideAsset.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('not a member of duplicate group');
});
it('should allow trash-all without keepers', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000012';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id, asset2.id] }],
});
expect(status).toBe(200);
expect(body).toEqual({
status: 'COMPLETED',
results: [
{
duplicateId,
status: 'SUCCESS',
},
],
});
// Verify both assets are trashed
const [asset1Info, asset2Info] = await Promise.all([
utils.getAssetInfo(user1.accessToken, asset1.id),
utils.getAssetInfo(user1.accessToken, asset2.id),
]);
expect(asset1Info.isTrashed).toBe(true);
expect(asset1Info.duplicateId).toBeNull();
expect(asset2Info.isTrashed).toBe(true);
expect(asset2Info.duplicateId).toBeNull();
});
it('should reject cross-user duplicate group access', async () => {
const asset1 = await utils.createAsset(user1.accessToken);
const asset2 = await utils.createAsset(user2.accessToken);
const duplicateId = '00000000-0000-4000-8000-000000000013';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user2.accessToken, asset2.id, duplicateId);
// User1 tries to resolve a group containing user2's asset
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('FAILED');
expect(body.results[0].reason).toContain('not a member of duplicate group');
});
it('should synchronize favorites when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Mark one asset as favorite
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], isFavorite: true });
const duplicateId = '00000000-0000-4000-8000-000000000020';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify favorite was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.isFavorite).toBe(true);
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize visibility when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Archive one asset
await utils.archiveAssets(user1.accessToken, [asset2.id]);
const duplicateId = '00000000-0000-4000-8000-000000000021';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify visibility was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.visibility).toBe('archive');
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize rating when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Set rating on one asset
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], rating: 5 });
const duplicateId = '00000000-0000-4000-8000-000000000022';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify rating was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.exifInfo?.rating).toBe(5);
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize description when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Set description on one asset
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], description: 'Test description for duplicate' });
const duplicateId = '00000000-0000-4000-8000-000000000023';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify description was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.exifInfo?.description).toBe('Test description for duplicate');
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize location when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Set location on one asset
await request(app)
.put('/assets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset2.id], latitude: 40.7128, longitude: -74.006 });
const duplicateId = '00000000-0000-4000-8000-000000000024';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify location was synchronized to keeper
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.exifInfo?.latitude).toBe(40.7128);
expect(keptAsset.exifInfo?.longitude).toBe(-74.006);
expect(keptAsset.duplicateId).toBeNull();
});
it('should synchronize albums when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Create albums and add assets to different albums
const album1 = await utils.createAlbum(user1.accessToken, {
albumName: 'Album 1',
assetIds: [asset1.id],
});
const album2 = await utils.createAlbum(user1.accessToken, {
albumName: 'Album 2',
assetIds: [asset2.id],
});
const duplicateId = '00000000-0000-4000-8000-000000000025';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify keeper is now in both albums
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.duplicateId).toBeNull();
// Check albums directly
const { status: album1Status, body: album1Body } = await request(app)
.get(`/albums/${album1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
const { status: album2Status, body: album2Body } = await request(app)
.get(`/albums/${album2.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(album1Status).toBe(200);
expect(album2Status).toBe(200);
expect(album1Body.assets.map((a: any) => a.id)).toContain(asset1.id);
expect(album2Body.assets.map((a: any) => a.id)).toContain(asset1.id);
});
it('should synchronize tags when enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
// Wait for metadata extraction to complete before adding tags
// Otherwise, metadata jobs will race and overwrite our tags
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
// Create tags and tag assets differently
const tags = await utils.upsertTags(user1.accessToken, ['tag1', 'tag2']);
await utils.tagAssets(user1.accessToken, tags[0].id, [asset1.id]);
await utils.tagAssets(user1.accessToken, tags[1].id, [asset2.id]);
const duplicateId = '00000000-0000-4000-8000-000000000026';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify keeper has both tags
const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(keptAsset.duplicateId).toBeNull();
expect(keptAsset.tags).toBeDefined();
const tagIds = keptAsset.tags?.map((t) => t.id) || [];
expect(tagIds).toContain(tags[0].id);
expect(tagIds).toContain(tags[1].id);
});
it('should handle batch resolve with mixed success and failure', async () => {
// Create first group that will succeed
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId1 = '00000000-0000-4000-8000-000000000027';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId1);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId1);
// Create second group with non-existent duplicate ID (will fail)
const fakeId = '00000000-0000-4000-8000-000000000099';
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [
{ duplicateId: duplicateId1, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] },
{ duplicateId: fakeId, keepAssetIds: [], trashAssetIds: [] },
],
});
expect(status).toBe(200);
expect(body.status).toBe('COMPLETED');
expect(body.results).toHaveLength(2);
// First group should succeed
expect(body.results[0].duplicateId).toBe(duplicateId1);
expect(body.results[0].status).toBe('SUCCESS');
// Second group should fail
expect(body.results[1].duplicateId).toBe(fakeId);
expect(body.results[1].status).toBe('FAILED');
expect(body.results[1].reason).toContain('not found or access denied');
// Verify first group was actually resolved despite second failure
const asset1Info = await utils.getAssetInfo(user1.accessToken, asset1.id);
expect(asset1Info.duplicateId).toBeNull();
const asset2Info = await utils.getAssetInfo(user1.accessToken, asset2.id);
expect(asset2Info.isTrashed).toBe(true);
});
it('should trash assets when trash is enabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000028';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
// Ensure trash is enabled (default)
const config = await utils.getSystemConfig(admin.accessToken);
expect(config.trash.enabled).toBe(true);
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Verify asset is trashed (not deleted)
const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id);
expect(trashedAsset.isTrashed).toBe(true);
});
it('should delete assets when trash is disabled', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
const duplicateId = '00000000-0000-4000-8000-000000000029';
await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId);
await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId);
// Disable trash
await request(app)
.put('/system-config')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
trash: { enabled: false, days: 30 },
});
const { status, body } = await request(app)
.post('/duplicates/resolve')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(status).toBe(200);
expect(body.results[0].status).toBe('SUCCESS');
// Asset should be marked as deleted (force delete)
const { status: getStatus } = await request(app)
.get(`/assets/${asset2.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
// Asset should still be accessible (soft deleted) but marked as deleted
expect(getStatus).toBe(200);
// Re-enable trash for other tests
await utils.resetAdminConfig(admin.accessToken);
});
});
});

View file

@ -1,9 +1,7 @@
export const uuidDto = {
invalid: 'invalid-uuid',
// valid uuid v4
notFound: '00000000-0000-4000-a000-000000000000',
dummy: '00000000-0000-4000-a000-000000000001',
dummy2: '00000000-0000-4000-a000-000000000002',
};
const adminLoginDto = {
@ -57,22 +55,6 @@ export const createUserDto = {
};
export const userDto = {
admin: {
name: signupDto.admin.name,
email: signupDto.admin.email,
password: signupDto.admin.password,
storageLabel: 'admin',
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
tags: [],
assets: [],
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
},
user1: {
name: createUserDto.user1.name,
email: createUserDto.user1.email,

View file

@ -1,89 +1,7 @@
import { expect } from 'vitest';
export const errorDto = {
unauthorized: {
message: 'Authentication required',
},
unauthorizedWithMessage: (message: string) => ({
message,
}),
forbidden: {
message: expect.any(String),
},
missingPermission: (permission: string) => ({
message: `Missing required permission: ${permission}`,
}),
wrongPassword: {
message: 'Wrong password',
},
invalidToken: {
message: 'Invalid user token',
},
invalidShareKey: {
message: 'Invalid share key',
},
passwordRequired: {
message: 'Password required',
},
badRequest: (message: any = null) => ({
message: message ?? expect.anything(),
}),
validationError: (errors?: ReadonlyArray<{ path: ReadonlyArray<string | number>; message: string }>) => ({
message: 'Validation failed',
errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array),
}),
noPermission: {
message: expect.stringContaining('Not found or no'),
},
incorrectLogin: {
message: 'Incorrect email or password',
},
};
export const signupResponseDto = {
admin: {
avatarColor: expect.any(String),
id: expect.any(String),
name: 'Immich Admin',
email: 'admin@immich.cloud',
storageLabel: 'admin',
profileImagePath: '',
// why? lol
shouldChangePassword: true,
isAdmin: true,
createdAt: expect.any(String),
updatedAt: expect.any(String),
deletedAt: null,
oauthId: '',
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
status: 'active',
license: null,
profileChangedAt: expect.any(String),
},
};
export const loginResponseDto = {
admin: {
accessToken: expect.any(String),
name: 'Immich Admin',
isAdmin: true,
isOnboarded: false,
profileImagePath: '',
shouldChangePassword: true,
userEmail: 'admin@immich.cloud',
userId: expect.any(String),
},
};
export const deviceDto = {
current: {
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
current: true,
isPendingSyncReset: false,
deviceOS: '',
deviceType: '',
appVersion: null,
},
};

View file

@ -1,5 +1,4 @@
import { LoginResponseDto } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
@ -8,18 +7,16 @@ import { beforeAll, describe, expect, it } from 'vitest';
describe('/admin/maintenance', () => {
let cookie: string | undefined;
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
await utils.resetBackups(admin.accessToken);
});
// => outside of maintenance mode
describe('GET ~/server/config', async () => {
describe('GET /server/config', async () => {
it('should indicate we are out of maintenance mode', async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
@ -49,24 +46,6 @@ describe('/admin/maintenance', () => {
// => enter maintenance mode
describe.sequential('POST /', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/admin/maintenance').send({
active: false,
action: 'end',
});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should only work for admins', async () => {
const { status, body } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
.send({ action: 'end' });
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should be a no-op if try to exit maintenance mode', async () => {
const { status } = await request(app)
.post('/admin/maintenance')
@ -132,7 +111,7 @@ describe('/admin/maintenance', () => {
it('should fail without cookie or token in body', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorizedWithMessage('Missing JWT Token'));
expect(body).toEqual({ message: 'Missing JWT Token' });
});
it('should succeed with cookie', async () => {

View file

@ -1,378 +0,0 @@
import {
ActivityCreateDto,
AlbumResponseDto,
AlbumUserRole,
AssetMediaResponseDto,
LoginResponseDto,
ReactionType,
createActivity as create,
createAlbum,
removeAssetFromAlbum,
} from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe('/activities', () => {
let admin: LoginResponseDto;
let nonOwner: LoginResponseDto;
let asset: AssetMediaResponseDto;
let album: AlbumResponseDto;
const createActivity = (dto: ActivityCreateDto, accessToken?: string) =>
create({ activityCreateDto: dto }, { headers: asBearerAuth(accessToken || admin.accessToken) });
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
nonOwner = await utils.userSetup(admin.accessToken, createUserDto.user1);
asset = await utils.createAsset(admin.accessToken);
album = await createAlbum(
{
createAlbumDto: {
albumName: 'Album 1',
assetIds: [asset.id],
albumUsers: [{ userId: nonOwner.userId, role: AlbumUserRole.Editor }],
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
});
beforeEach(async () => {
await utils.resetDatabase(['activity']);
});
describe('GET /activities', () => {
it('should start off empty', async () => {
const { status, body } = await request(app)
.get('/activities')
.query({ albumId: album.id })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([]);
expect(status).toEqual(200);
});
it('should filter by album id', async () => {
const album2 = await createAlbum(
{
createAlbumDto: {
albumName: 'Album 2',
assetIds: [asset.id],
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
const [reaction] = await Promise.all([
createActivity({ albumId: album.id, type: ReactionType.Like }),
createActivity({ albumId: album2.id, type: ReactionType.Like }),
]);
const { status, body } = await request(app)
.get('/activities')
.query({ albumId: album.id })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body.length).toBe(1);
expect(body[0]).toEqual(reaction);
});
it('should filter by type=comment', async () => {
const [reaction] = await Promise.all([
createActivity({
albumId: album.id,
type: ReactionType.Comment,
comment: 'comment',
}),
createActivity({ albumId: album.id, type: ReactionType.Like }),
]);
const { status, body } = await request(app)
.get('/activities')
.query({ albumId: album.id, type: 'comment' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body.length).toBe(1);
expect(body[0]).toEqual(reaction);
});
it('should filter by type=like', async () => {
const [reaction] = await Promise.all([
createActivity({ albumId: album.id, type: ReactionType.Like }),
createActivity({
albumId: album.id,
type: ReactionType.Comment,
comment: 'comment',
}),
]);
const { status, body } = await request(app)
.get('/activities')
.query({ albumId: album.id, type: 'like' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body.length).toBe(1);
expect(body[0]).toEqual(reaction);
});
it('should filter by userId', async () => {
const reaction = await createActivity({ albumId: album.id, type: ReactionType.Like });
const response1 = await request(app)
.get('/activities')
.query({ albumId: album.id, userId: uuidDto.notFound })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response1.status).toEqual(200);
expect(response1.body.length).toBe(0);
const response2 = await request(app)
.get('/activities')
.query({ albumId: album.id, userId: admin.userId })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response2.status).toEqual(200);
expect(response2.body.length).toBe(1);
expect(response2.body[0]).toEqual(reaction);
});
it('should filter by assetId', async () => {
const [reaction] = await Promise.all([
createActivity({
albumId: album.id,
assetId: asset.id,
type: ReactionType.Like,
}),
createActivity({ albumId: album.id, type: ReactionType.Like }),
]);
const { status, body } = await request(app)
.get('/activities')
.query({ albumId: album.id, assetId: asset.id })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body.length).toBe(1);
expect(body[0]).toEqual(reaction);
});
});
describe('POST /activities', () => {
it('should add a comment to an album', async () => {
const { status, body } = await request(app)
.post('/activities')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
albumId: album.id,
type: 'comment',
comment: 'This is my first comment',
});
expect(status).toEqual(201);
expect(body).toEqual({
id: expect.any(String),
assetId: null,
createdAt: expect.any(String),
type: 'comment',
comment: 'This is my first comment',
user: expect.objectContaining({ email: admin.userEmail }),
});
});
it('should add a like to an album', async () => {
const { status, body } = await request(app)
.post('/activities')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' });
expect(status).toEqual(201);
expect(body).toEqual({
id: expect.any(String),
assetId: null,
createdAt: expect.any(String),
type: 'like',
comment: null,
user: expect.objectContaining({ email: admin.userEmail }),
});
});
it('should return a 200 for a duplicate like on the album', async () => {
const reaction = await createActivity({ albumId: album.id, type: ReactionType.Like });
const { status, body } = await request(app)
.post('/activities')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' });
expect(status).toEqual(200);
expect(body).toEqual(reaction);
});
it('should not confuse an album like with an asset like', async () => {
const reaction = await createActivity({
albumId: album.id,
assetId: asset.id,
type: ReactionType.Like,
});
const { status, body } = await request(app)
.post('/activities')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' });
expect(status).toEqual(201);
expect(body.id).not.toEqual(reaction.id);
});
it('should add a comment to an asset', async () => {
const { status, body } = await request(app)
.post('/activities')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
albumId: album.id,
assetId: asset.id,
type: 'comment',
comment: 'This is my first comment',
});
expect(status).toEqual(201);
expect(body).toEqual({
id: expect.any(String),
assetId: asset.id,
createdAt: expect.any(String),
type: 'comment',
comment: 'This is my first comment',
user: expect.objectContaining({ email: admin.userEmail }),
});
});
it('should add a like to an asset', async () => {
const { status, body } = await request(app)
.post('/activities')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, assetId: asset.id, type: 'like' });
expect(status).toEqual(201);
expect(body).toEqual({
id: expect.any(String),
assetId: asset.id,
createdAt: expect.any(String),
type: 'like',
comment: null,
user: expect.objectContaining({ email: admin.userEmail }),
});
});
it('should return a 200 for a duplicate like on an asset', async () => {
const reaction = await createActivity({
albumId: album.id,
assetId: asset.id,
type: ReactionType.Like,
});
const { status, body } = await request(app)
.post('/activities')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, assetId: asset.id, type: 'like' });
expect(status).toEqual(200);
expect(body).toEqual(reaction);
});
});
describe('DELETE /activities/:id', () => {
it('should remove a comment from an album', async () => {
const reaction = await createActivity({
albumId: album.id,
type: ReactionType.Comment,
comment: 'This is a test comment',
});
const { status } = await request(app)
.delete(`/activities/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(204);
});
it('should remove a like from an album', async () => {
const reaction = await createActivity({
albumId: album.id,
type: ReactionType.Like,
});
const { status } = await request(app)
.delete(`/activities/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(204);
});
it('should let the owner remove a comment by another user', async () => {
const reaction = await createActivity({
albumId: album.id,
type: ReactionType.Comment,
comment: 'This is a test comment',
});
const { status } = await request(app)
.delete(`/activities/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(204);
});
it('should not let a user remove a comment by another user', async () => {
const reaction = await createActivity({
albumId: album.id,
type: ReactionType.Comment,
comment: 'This is a test comment',
});
const { status, body } = await request(app)
.delete(`/activities/${reaction.id}`)
.set('Authorization', `Bearer ${nonOwner.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not found or no activity.delete access'));
});
it('should let a non-owner remove their own comment', async () => {
const reaction = await createActivity(
{
albumId: album.id,
type: ReactionType.Comment,
comment: 'This is a test comment',
},
nonOwner.accessToken,
);
const { status } = await request(app)
.delete(`/activities/${reaction.id}`)
.set('Authorization', `Bearer ${nonOwner.accessToken}`);
expect(status).toBe(204);
});
it('should return empty list when asset is removed', async () => {
const album3 = await createAlbum(
{
createAlbumDto: {
albumName: 'Album 3',
assetIds: [asset.id],
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
await createActivity({ albumId: album3.id, assetId: asset.id, type: ReactionType.Like });
await removeAssetFromAlbum(
{
id: album3.id,
bulkIdsDto: {
ids: [asset.id],
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
const { status, body } = await request(app)
.get('/activities')
.query({ albumId: album.id })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body).toEqual([]);
});
});
});

View file

@ -613,16 +613,6 @@ describe('/albums', () => {
});
describe('DELETE /albums/:id/assets', () => {
it('should require authorization', async () => {
const { status, body } = await request(app)
.delete(`/albums/${user1Albums[1].id}/assets`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ ids: [user1Asset1.id] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should be able to remove foreign asset from owned album', async () => {
const { status, body } = await request(app)
.delete(`/albums/${user2Albums[0].id}/assets`)

View file

@ -24,13 +24,6 @@ describe('/api-keys', () => {
});
describe('POST /api-keys', () => {
it('should not work without permission', async () => {
const { secret } = await create(user.accessToken, [Permission.ApiKeyRead]);
const { status, body } = await request(app).post('/api-keys').set('x-api-key', secret).send({ name: 'API Key' });
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('apiKey.create'));
});
it('should work with apiKey.create', async () => {
const { secret } = await create(user.accessToken, [Permission.ApiKeyCreate, Permission.ApiKeyRead]);
const { status, body } = await request(app)
@ -41,14 +34,14 @@ describe('/api-keys', () => {
permissions: [Permission.ApiKeyRead],
});
expect(body).toEqual({
id: expect.any(String),
name: 'API Key',
permissions: [Permission.ApiKeyRead],
createdAt: expect.any(String),
updatedAt: expect.any(String),
secret: expect.any(String),
apiKey: {
id: expect.any(String),
name: 'API Key',
permissions: [Permission.ApiKeyRead],
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
// TODO: remove in v4
apiKey: expect.any(Object),
});
expect(status).toBe(201);
});
@ -79,14 +72,14 @@ describe('/api-keys', () => {
.send({ name: 'API Key', permissions: [Permission.All] })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual({
apiKey: {
id: expect.any(String),
name: 'API Key',
permissions: [Permission.All],
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
id: expect.any(String),
name: 'API Key',
permissions: [Permission.All],
createdAt: expect.any(String),
updatedAt: expect.any(String),
secret: expect.any(String),
// TODO: remove in v4
apiKey: expect.any(Object),
});
expect(status).toEqual(201);
});
@ -100,32 +93,30 @@ describe('/api-keys', () => {
});
it('should return a list of api keys', async () => {
const [{ apiKey: apiKey1 }, { apiKey: apiKey2 }, { apiKey: apiKey3 }] = await Promise.all([
const [apiKey1, apiKey2, apiKey3] = await Promise.all([
create(admin.accessToken, [Permission.All]),
create(admin.accessToken, [Permission.All]),
create(admin.accessToken, [Permission.All]),
]);
const { status, body } = await request(app).get('/api-keys').set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toHaveLength(3);
expect(body).toEqual(expect.arrayContaining([apiKey1, apiKey2, apiKey3]));
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: apiKey1.id }),
expect.objectContaining({ id: apiKey2.id }),
expect.objectContaining({ id: apiKey3.id }),
]),
);
expect(status).toEqual(200);
});
});
describe('GET /api-keys/:id', () => {
it('should require authorization', async () => {
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.get(`/api-keys/${apiKey.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('API Key not found'));
});
it('should get api key details', async () => {
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { id } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.get(`/api-keys/${apiKey.id}`)
.get(`/api-keys/${id}`)
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
@ -139,20 +130,10 @@ describe('/api-keys', () => {
});
describe('PUT /api-keys/:id', () => {
it('should require authorization', async () => {
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.put(`/api-keys/${apiKey.id}`)
.send({ name: 'new name', permissions: [Permission.All] })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('API Key not found'));
});
it('should update api key details', async () => {
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { id } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.put(`/api-keys/${apiKey.id}`)
.put(`/api-keys/${id}`)
.send({
name: 'new name',
permissions: [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate],
@ -170,19 +151,10 @@ describe('/api-keys', () => {
});
describe('DELETE /api-keys/:id', () => {
it('should require authorization', async () => {
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.delete(`/api-keys/${apiKey.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('API Key not found'));
});
it('should delete an api key', async () => {
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { id } = await create(user.accessToken, [Permission.All]);
const { status } = await request(app)
.delete(`/api-keys/${apiKey.id}`)
.delete(`/api-keys/${id}`)
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(204);
});

View file

@ -130,7 +130,7 @@ describe('/asset', () => {
});
await utils.createFace({
assetId: user1Assets[0].id,
personId: person1.id,
personGroupId: person1.id,
});
};
beforeAll(setupTests, 30_000);
@ -151,14 +151,6 @@ describe('/asset', () => {
});
describe('GET /assets/:id', () => {
it('should require access', async () => {
const { status, body } = await request(app)
.get(`/assets/${user2Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should get the asset info', async () => {
const { status, body } = await request(app)
.get(`/assets/${user1Assets[0].id}`)
@ -306,15 +298,6 @@ describe('/asset', () => {
});
describe('PUT /assets/:id', () => {
it('should require access', async () => {
const { status, body } = await request(app)
.put(`/assets/${user2Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should favorite an asset', async () => {
const before = await utils.getAssetInfo(user1.accessToken, user1Assets[0].id);
expect(before.isFavorite).toBe(false);

View file

@ -2,9 +2,7 @@ import { LoginResponseDto, QueueCommand, QueueName, updateConfig } from '@immich
import { cpSync, rmSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, testAssetDir, utils } from 'src/utils';
import request from 'supertest';
import { asBearerAuth, testAssetDir, utils } from 'src/utils';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
describe('/jobs', () => {
@ -47,13 +45,7 @@ describe('/jobs', () => {
config.machineLearning.enabled = false;
config.metadata.faces.import = false;
config.machineLearning.clip.enabled = false;
await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });
});
it('should require authentication', async () => {
const { status, body } = await request(app).put('/jobs/metadataExtraction');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
await updateConfig({ adminConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });
});
it('should queue metadata extraction for missing assets', async () => {

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,6 @@ import { readFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { Socket } from 'socket.io-client';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, testAssetDir, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
@ -55,12 +54,6 @@ describe('/map', () => {
});
describe('GET /map/markers', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/map/markers');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should get map markers for all non-archived assets', async () => {
const { status, body } = await request(app)
.get('/map/markers')
@ -139,52 +132,6 @@ describe('/map', () => {
});
describe('GET /map/reverse-geocode', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/map/reverse-geocode');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should throw an error if a lat is not provided', async () => {
const { status, body } = await request(app)
.get('/map/reverse-geocode?lon=123')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]),
);
});
it('should throw an error if a lat is not a number', async () => {
const { status, body } = await request(app)
.get('/map/reverse-geocode?lat=abc&lon=123.456')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]),
);
});
it('should throw an error if a lat is out of range', async () => {
const { status, body } = await request(app)
.get('/map/reverse-geocode?lat=91&lon=123.456')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['lat'], message: 'Too big: expected number to be <=90' }]),
);
});
it('should throw an error if a lon is not provided', async () => {
const { status, body } = await request(app)
.get('/map/reverse-geocode?lat=75')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['lon'], message: 'Invalid input: expected number, received NaN' }]),
);
});
const reverseGeocodeTestCases = [
{
name: 'Vaucluse',

View file

@ -7,7 +7,6 @@ import {
getMemory,
} from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
@ -42,14 +41,6 @@ describe('/memories', () => {
});
describe('GET /memories/:id', () => {
it('should require access', async () => {
const { status, body } = await request(app)
.get(`/memories/${userMemory.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should get the memory', async () => {
const { status, body } = await request(app)
.get(`/memories/${userMemory.id}`)
@ -60,15 +51,6 @@ describe('/memories', () => {
});
describe('PUT /memories/:id', () => {
it('should require access', async () => {
const { status, body } = await request(app)
.put(`/memories/${userMemory.id}`)
.send({ isSaved: true })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should update the memory', async () => {
const before = await getMemory({ id: userMemory.id }, { headers: asBearerAuth(user.accessToken) });
expect(before.isSaved).toBe(false);
@ -86,15 +68,6 @@ describe('/memories', () => {
});
describe('PUT /memories/:id/assets', () => {
it('should require access', async () => {
const { status, body } = await request(app)
.put(`/memories/${userMemory.id}/assets`)
.send({ ids: [userAsset1.id] })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should require asset access', async () => {
const { status, body } = await request(app)
.put(`/memories/${userMemory.id}/assets`)
@ -121,15 +94,6 @@ describe('/memories', () => {
});
describe('DELETE /memories/:id/assets', () => {
it('should require access', async () => {
const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}/assets`)
.send({ ids: [userAsset1.id] })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should only remove assets in the memory', async () => {
const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}/assets`)
@ -156,14 +120,6 @@ describe('/memories', () => {
});
describe('DELETE /memories/:id', () => {
it('should require access', async () => {
const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should delete the memory', async () => {
const { status } = await request(app)
.delete(`/memories/${userMemory.id}`)

View file

@ -1,7 +1,7 @@
import { OAuthClient, OAuthUser, generateLogoutToken } from '@immich/e2e-auth-server';
import {
AdminConfigOAuthDto,
LoginResponseDto,
SystemConfigOAuthDto,
getConfigDefaults,
getMyUser,
getSessions,
@ -70,17 +70,18 @@ const loginWithOAuth = async (sub: OAuthUser | string, redirectUri?: string) =>
return { url: redirectUrl, state, codeVerifier };
};
const setupOAuth = async (token: string, dto: Partial<SystemConfigOAuthDto>) => {
const setupOAuth = async (token: string, dto: Partial<AdminConfigOAuthDto>) => {
const options = { headers: asBearerAuth(token) };
const defaults = await getConfigDefaults(options);
const merged = {
...defaults.oauth,
buttonText: 'Login with Immich',
issuerUrl: `${authServer.internal}/.well-known/openid-configuration`,
accountManagementUrl: authServer.internal,
allowInsecureRequests: true,
...dto,
};
await updateConfig({ systemConfigDto: { ...defaults, oauth: merged } }, options);
await updateConfig({ adminConfigDto: { ...defaults, oauth: merged } }, options);
};
describe(`/oauth`, () => {
@ -102,16 +103,6 @@ describe(`/oauth`, () => {
});
});
it(`should throw an error if a redirect uri is not provided`, async () => {
const { status, body } = await request(app).post('/oauth/authorize').send({});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([
{ path: ['redirectUri'], message: 'Invalid input: expected string, received undefined' },
]),
);
});
it('should return a redirect uri', async () => {
const { status, body } = await request(app)
.post('/oauth/authorize')
@ -165,22 +156,6 @@ describe(`/oauth`, () => {
});
});
it(`should throw an error if a url is not provided`, async () => {
const { status, body } = await request(app).post('/oauth/callback').send({});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['url'], message: 'Invalid input: expected string, received undefined' }]),
);
});
it(`should throw an error if the url is empty`, async () => {
const { status, body } = await request(app).post('/oauth/callback').send({ url: '' });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['url'], message: 'Too small: expected string to have >=1 characters' }]),
);
});
it(`should throw an error if the state is not provided`, async () => {
const { url } = await loginWithOAuth('oauth-auto-register');
const { status, body } = await request(app).post('/oauth/callback').send({ url });
@ -380,16 +355,6 @@ describe(`/oauth`, () => {
});
describe(`POST /oauth/backchannel-logout`, () => {
it(`should throw an error if the logout_token is not provided`, async () => {
const { status, body } = await request(app).post('/oauth/backchannel-logout').send({});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([
{ path: ['logout_token'], message: 'Invalid input: expected string, received undefined' },
]),
);
});
it(`should throw an error if an invalid logout token is provided`, async () => {
const { status, body } = await request(app)
.post('/oauth/backchannel-logout')

View file

@ -1,102 +0,0 @@
import { LoginResponseDto, createPartner } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/partners', () => {
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
let user3: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
[user1, user2, user3] = await Promise.all([
utils.userSetup(admin.accessToken, createUserDto.user1),
utils.userSetup(admin.accessToken, createUserDto.user2),
utils.userSetup(admin.accessToken, createUserDto.user3),
]);
await Promise.all([
createPartner({ partnerCreateDto: { sharedWithId: user2.userId } }, { headers: asBearerAuth(user1.accessToken) }),
createPartner({ partnerCreateDto: { sharedWithId: user1.userId } }, { headers: asBearerAuth(user2.accessToken) }),
]);
});
describe('GET /partners', () => {
it('should get all partners shared by user', async () => {
const { status, body } = await request(app)
.get('/partners')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ direction: 'shared-by' });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user2.userId })]);
});
it('should get all partners that share with user', async () => {
const { status, body } = await request(app)
.get('/partners')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ direction: 'shared-with' });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user2.userId })]);
});
});
describe('POST /partners/:id', () => {
it('should share with new partner', async () => {
const { status, body } = await request(app)
.post(`/partners/${user3.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(201);
expect(body).toEqual(expect.objectContaining({ id: user3.userId }));
});
it('should not share with new partner if already sharing with this partner', async () => {
const { status, body } = await request(app)
.post(`/partners/${user2.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Partner already exists' }));
});
});
describe('PUT /partners/:id', () => {
it('should update partner', async () => {
const { status, body } = await request(app)
.put(`/partners/${user2.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ inTimeline: false });
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: user2.userId, inTimeline: false }));
});
});
describe('DELETE /partners/:id', () => {
it('should delete partner', async () => {
const { status } = await request(app)
.delete(`/partners/${user3.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(204);
});
it('should throw a bad request if partner not found', async () => {
const { status, body } = await request(app)
.delete(`/partners/${user3.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Partner not found' }));
});
});
});

View file

@ -82,32 +82,32 @@ describe('/people', () => {
const asset4 = await utils.createAsset(admin.accessToken);
await Promise.all([
utils.createFace({ assetId: asset1.id, personId: visiblePerson.id }),
utils.createFace({ assetId: asset1.id, personId: hiddenPerson.id }),
utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
utils.createFace({ assetId: asset2.id, personId: multipleAssetsPerson.id }),
utils.createFace({ assetId: asset3.id, personId: multipleAssetsPerson.id }), // 4 assets
utils.createFace({ assetId: asset1.id, personGroupId: visiblePerson.id }),
utils.createFace({ assetId: asset1.id, personGroupId: hiddenPerson.id }),
utils.createFace({ assetId: asset1.id, personGroupId: multipleAssetsPerson.id }),
utils.createFace({ assetId: asset1.id, personGroupId: multipleAssetsPerson.id }),
utils.createFace({ assetId: asset2.id, personGroupId: multipleAssetsPerson.id }),
utils.createFace({ assetId: asset3.id, personGroupId: multipleAssetsPerson.id }), // 4 assets
// Named persons
utils.createFace({ assetId: asset1.id, personId: nameCharliePerson.id }), // 1 asset
utils.createFace({ assetId: asset1.id, personId: nameBobPerson.id }),
utils.createFace({ assetId: asset2.id, personId: nameBobPerson.id }), // 2 assets
utils.createFace({ assetId: asset1.id, personId: nameAlicePerson.id }), // 1 asset
utils.createFace({ assetId: asset1.id, personGroupId: nameCharliePerson.id }), // 1 asset
utils.createFace({ assetId: asset1.id, personGroupId: nameBobPerson.id }),
utils.createFace({ assetId: asset2.id, personGroupId: nameBobPerson.id }), // 2 assets
utils.createFace({ assetId: asset1.id, personGroupId: nameAlicePerson.id }), // 1 asset
// Null-named person 4 assets
utils.createFace({ assetId: asset1.id, personId: nameNullPerson4Assets.id }),
utils.createFace({ assetId: asset2.id, personId: nameNullPerson4Assets.id }),
utils.createFace({ assetId: asset3.id, personId: nameNullPerson4Assets.id }),
utils.createFace({ assetId: asset4.id, personId: nameNullPerson4Assets.id }), // 4 assets
utils.createFace({ assetId: asset1.id, personGroupId: nameNullPerson4Assets.id }),
utils.createFace({ assetId: asset2.id, personGroupId: nameNullPerson4Assets.id }),
utils.createFace({ assetId: asset3.id, personGroupId: nameNullPerson4Assets.id }),
utils.createFace({ assetId: asset4.id, personGroupId: nameNullPerson4Assets.id }), // 4 assets
// Null-named person 3 assets
utils.createFace({ assetId: asset1.id, personId: nameNullPerson3Assets.id }),
utils.createFace({ assetId: asset2.id, personId: nameNullPerson3Assets.id }),
utils.createFace({ assetId: asset3.id, personId: nameNullPerson3Assets.id }), // 3 assets
utils.createFace({ assetId: asset1.id, personGroupId: nameNullPerson3Assets.id }),
utils.createFace({ assetId: asset2.id, personGroupId: nameNullPerson3Assets.id }),
utils.createFace({ assetId: asset3.id, personGroupId: nameNullPerson3Assets.id }), // 3 assets
// Null-named person 1 asset
utils.createFace({ assetId: asset3.id, personId: nameNullPerson1Asset.id }),
utils.createFace({ assetId: asset3.id, personGroupId: nameNullPerson1Asset.id }),
// Favourite People
utils.createFace({ assetId: asset1.id, personId: nameFreddyPersonFavourite.id }),
utils.createFace({ assetId: asset2.id, personId: nameFreddyPersonFavourite.id }),
utils.createFace({ assetId: asset1.id, personId: nameBillPersonFavourite.id }),
utils.createFace({ assetId: asset1.id, personGroupId: nameFreddyPersonFavourite.id }),
utils.createFace({ assetId: asset2.id, personGroupId: nameFreddyPersonFavourite.id }),
utils.createFace({ assetId: asset1.id, personGroupId: nameBillPersonFavourite.id }),
]);
});

View file

@ -1,6 +1,5 @@
import { LoginResponseDto } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
@ -122,6 +121,7 @@ describe('/server', () => {
expect(body).toEqual({
loginPageMessage: '',
oauthButtonText: 'Login with OAuth',
oauthAccountManagementUrl: '',
trashDays: 30,
userDeleteDelay: 7,
isInitialized: true,
@ -137,14 +137,6 @@ describe('/server', () => {
});
describe('GET /server/statistics', () => {
it('should only work for admins', async () => {
const { status, body } = await request(app)
.get('/server/statistics')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should return the server stats', async () => {
const { status, body } = await request(app)
.get('/server/statistics')
@ -195,14 +187,6 @@ describe('/server', () => {
});
describe('GET /server/license', () => {
it('should only work for admins', async () => {
const { status, body } = await request(app)
.get('/server/license')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should return the server license', async () => {
await request(app).put('/server/license').set('Authorization', `Bearer ${admin.accessToken}`).send(serverLicense);
const { status, body } = await request(app)
@ -217,14 +201,6 @@ describe('/server', () => {
});
describe('DELETE /server/license', () => {
it('should only work for admins', async () => {
const { status, body } = await request(app)
.delete('/server/license')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should delete the server license', async () => {
await request(app)
.delete('/server/license')
@ -236,14 +212,6 @@ describe('/server', () => {
});
describe('PUT /server/license', () => {
it('should only work for admins', async () => {
const { status, body } = await request(app)
.put('/server/license')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should set the server license', async () => {
const { status, body } = await request(app)
.put('/server/license')

View file

@ -1,6 +1,6 @@
import { LoginResponseDto, getSessions, login, signUpAdmin } from '@immich/sdk';
import { loginDto, signupDto, uuidDto } from 'src/fixtures';
import { deviceDto, errorDto } from 'src/responses';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { beforeEach, describe, expect, it } from 'vitest';
@ -18,7 +18,18 @@ describe('/sessions', () => {
it('should get a list of authorized devices', async () => {
const { status, body } = await request(app).get('/sessions').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([deviceDto.current]);
expect(body).toEqual([
{
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
current: true,
isPendingSyncReset: false,
deviceOS: '',
deviceType: '',
appVersion: null,
},
]);
});
});
@ -56,7 +67,7 @@ describe('/sessions', () => {
const response = await request(app)
.post('/auth/validateToken')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response.body).toEqual(errorDto.invalidToken);
expect(response.body).toEqual({ message: 'Invalid user token' });
expect(response.status).toBe(401);
});
});

View file

@ -212,21 +212,21 @@ describe('/shared-links', () => {
.query({ key: linkWithAlbum.key + 'foo' });
expect(status).toBe(401);
expect(body).toEqual(errorDto.invalidShareKey);
expect(body).toEqual({ message: 'Invalid share key' });
});
it('should return unauthorized if target has been soft deleted', async () => {
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithDeletedAlbum.key });
expect(status).toBe(401);
expect(body).toEqual(errorDto.invalidShareKey);
expect(body).toEqual({ message: 'Invalid share key' });
});
it('should return unauthorized for password protected link', async () => {
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithPassword.key });
expect(status).toBe(401);
expect(body).toEqual(errorDto.passwordRequired);
expect(body).toEqual({ message: 'Password required' });
});
it('should get data for correct password protected link', async () => {
@ -306,24 +306,14 @@ describe('/shared-links', () => {
});
describe('POST /shared-links', () => {
it('should require an asset/album id', async () => {
const { status, body } = await request(app)
.post('/shared-links')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: SharedLinkType.Album });
expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Invalid albumId' }));
});
it('should require a valid asset id', async () => {
const { status, body } = await request(app)
.post('/shared-links')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: SharedLinkType.Individual, assetId: uuidDto.notFound });
.send({ type: SharedLinkType.Individual, assetIds: [uuidDto.notFound] });
expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Invalid assetIds' }));
expect(body).toEqual(expect.objectContaining({ message: 'Not found or no asset.share access' }));
});
it('should create a shared link', async () => {
@ -403,16 +393,6 @@ describe('/shared-links', () => {
expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
});
it('should reject guests removing assets from an individual shared link', async () => {
const { status, body } = await request(app)
.delete(`/shared-links/${linkWithAssets.id}/assets`)
.query({ key: linkWithAssets.key })
.send({ assetIds: [asset1.id] });
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should remove assets from a shared link (individual)', async () => {
const { status, body } = await request(app)
.delete(`/shared-links/${linkWithAssets.id}/assets`)

View file

@ -1,6 +1,5 @@
import { AssetMediaResponseDto, LoginResponseDto, searchStacks } from '@immich/sdk';
import { LoginResponseDto, searchStacks } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
@ -8,34 +7,16 @@ import { beforeAll, describe, expect, it } from 'vitest';
describe('/stacks', () => {
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
let asset: AssetMediaResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
[user1, user2] = await Promise.all([
utils.userSetup(admin.accessToken, createUserDto.user1),
utils.userSetup(admin.accessToken, createUserDto.user2),
]);
asset = await utils.createAsset(user1.accessToken);
user1 = await utils.userSetup(admin.accessToken, createUserDto.user1);
});
describe('POST /stacks', () => {
it('should require access', async () => {
const user2Asset = await utils.createAsset(user2.accessToken);
const { status, body } = await request(app)
.post('/stacks')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset.id, user2Asset.id] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should create a stack', async () => {
const [asset1, asset2] = await Promise.all([
utils.createAsset(user1.accessToken),

View file

@ -1,30 +1,17 @@
import { LoginResponseDto, getServerConfig } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/server-info', () => {
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false });
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
});
describe('POST /system-metadata/admin-onboarding', () => {
it('should only work for admins', async () => {
const { status, body } = await request(app)
.post('/system-metadata/admin-onboarding')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
.send({ isOnboarded: true });
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should set admin onboarding', async () => {
const config = await getServerConfig({});
expect(config.isOnboarded).toBe(false);
@ -41,14 +28,6 @@ describe('/server-info', () => {
});
describe('GET /system-metadata/reverse-geocoding-state', () => {
it('should only work for admins', async () => {
const { status, body } = await request(app)
.get('/system-metadata/reverse-geocoding-state')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should get the reverse geocoding state', async () => {
const { status, body } = await request(app)
.get('/system-metadata/reverse-geocoding-state')

View file

@ -9,7 +9,7 @@ import {
tagAssets,
upsertTags,
} from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
@ -41,13 +41,6 @@ describe('/tags', () => {
});
describe('POST /tags', () => {
it('should require authorization (api key)', async () => {
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' });
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('tag.create'));
});
it('should work with tag.create', async () => {
const { secret } = await utils.createApiKey(user.accessToken, [Permission.TagCreate]);
const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' });
@ -111,13 +104,6 @@ describe('/tags', () => {
});
describe('GET /tags', () => {
it('should require authorization (api key)', async () => {
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
const { status, body } = await request(app).get('/tags').set('x-api-key', secret);
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('tag.read'));
});
it('should start off empty', async () => {
const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([]);
@ -157,13 +143,6 @@ describe('/tags', () => {
});
describe('PUT /tags', () => {
it('should require authorization (api key)', async () => {
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
const { status, body } = await request(app).put('/tags').set('x-api-key', secret).send({ name: 'TagA' });
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('tag.create'));
});
it('should upsert tags', async () => {
const { status, body } = await request(app)
.put(`/tags`)
@ -195,16 +174,6 @@ describe('/tags', () => {
});
describe('PUT /tags/assets', () => {
it('should require authorization (api key)', async () => {
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
const { status, body } = await request(app)
.put('/tags/assets')
.set('x-api-key', secret)
.send({ assetIds: [], tagIds: [] });
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('tag.asset'));
});
it('should skip assets that are not owned by the user', async () => {
const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([
create(user.accessToken, { name: 'TagA' }),
@ -255,25 +224,6 @@ describe('/tags', () => {
});
describe('GET /tags/:id', () => {
it('should require authorization', async () => {
const tag = await create(user.accessToken, { name: 'TagA' });
const { status, body } = await request(app)
.get(`/tags/${tag.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should require authorization (api key)', async () => {
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
const { status, body } = await request(app)
.get(`/tags/${uuidDto.notFound}`)
.set('x-api-key', secret)
.send({ assetIds: [], tagIds: [] });
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('tag.read'));
});
it('should get tag details', async () => {
const tag = await create(user.accessToken, { name: 'TagA' });
const { status, body } = await request(app)
@ -311,27 +261,6 @@ describe('/tags', () => {
});
describe('PUT /tags/:id', () => {
it('should require authorization', async () => {
const tag = await create(admin.accessToken, { name: 'tagA' });
const { status, body } = await request(app)
.put(`/tags/${tag.id}`)
.send({ color: '#000000' })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should require authorization (api key)', async () => {
const tag = await create(user.accessToken, { name: 'TagA' });
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
const { status, body } = await request(app)
.put(`/tags/${tag.id}`)
.set('x-api-key', secret)
.send({ color: '#000000' });
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('tag.update'));
});
it('should update a tag', async () => {
const tag = await create(user.accessToken, { name: 'tagA' });
const { status, body } = await request(app)
@ -354,23 +283,6 @@ describe('/tags', () => {
});
describe('DELETE /tags/:id', () => {
it('should require authorization', async () => {
const tag = await create(user.accessToken, { name: 'TagA' });
const { status, body } = await request(app)
.delete(`/tags/${tag.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should require authorization (api key)', async () => {
const tag = await create(user.accessToken, { name: 'TagA' });
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
const { status, body } = await request(app).delete(`/tags/${tag.id}`).set('x-api-key', secret);
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('tag.delete'));
});
it('should delete a tag', async () => {
const tag = await create(user.accessToken, { name: 'TagA' });
const { status } = await request(app)
@ -404,27 +316,6 @@ describe('/tags', () => {
});
describe('PUT /tags/:id/assets', () => {
it('should require authorization', async () => {
const tag = await create(user.accessToken, { name: 'TagA' });
const { status, body } = await request(app)
.put(`/tags/${tag.id}/assets`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ids: [userAsset.id] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should require authorization (api key)', async () => {
const tag = await create(user.accessToken, { name: 'TagA' });
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
const { status, body } = await request(app)
.put(`/tags/${tag.id}/assets`)
.set('x-api-key', secret)
.send({ ids: [userAsset.id] });
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('tag.asset'));
});
it('should be able to tag own asset', async () => {
const tagA = await create(user.accessToken, { name: 'TagA' });
const { status, body } = await request(app)
@ -463,32 +354,6 @@ describe('/tags', () => {
});
describe('DELETE /tags/:id/assets', () => {
it('should require authorization', async () => {
const tagA = await create(user.accessToken, { name: 'TagA' });
await tagAssets(
{ id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } },
{ headers: asBearerAuth(user.accessToken) },
);
const { status, body } = await request(app)
.delete(`/tags/${tagA.id}/assets`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ids: [userAsset.id] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should require authorization (api key)', async () => {
const tag = await create(user.accessToken, { name: 'TagA' });
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
const { status, body } = await request(app)
.delete(`/tags/${tag.id}/assets`)
.set('x-api-key', secret)
.send({ ids: [userAsset.id] });
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('tag.asset'));
});
it('should be able to remove own asset from own tag', async () => {
const tagA = await create(user.accessToken, { name: 'TagA' });
await tagAssets(

View file

@ -1,7 +1,6 @@
import { LoginResponseDto, getAssetInfo, getAssetStatistics } from '@immich/sdk';
import { existsSync } from 'node:fs';
import { Socket } from 'socket.io-client';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
@ -21,13 +20,6 @@ describe('/trash', () => {
});
describe('POST /trash/empty', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/trash/empty');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should empty the trash', async () => {
const { id: assetId } = await utils.createAsset(admin.accessToken);
await utils.deleteAssets(admin.accessToken, [assetId]);
@ -142,13 +134,6 @@ describe('/trash', () => {
});
describe('POST /trash/restore', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/trash/restore');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should restore all trashed assets', async () => {
const { id: assetId } = await utils.createAsset(admin.accessToken);
await utils.deleteAssets(admin.accessToken, [assetId]);
@ -198,13 +183,6 @@ describe('/trash', () => {
});
describe('POST /trash/restore/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/trash/restore/assets');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should restore a trashed asset by id', async () => {
const { id: assetId } = await utils.createAsset(admin.accessToken);
await utils.deleteAssets(admin.accessToken, [assetId]);

View file

@ -1,6 +1,5 @@
import {
LoginResponseDto,
Permission,
QueueName,
createStack,
deleteUserAdmin,
@ -10,8 +9,7 @@ import {
login,
} from '@immich/sdk';
import { Socket } from 'socket.io-client';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { createUserDto } from 'src/fixtures';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
@ -46,14 +44,6 @@ describe('/admin/users', () => {
});
describe('GET /admin/users', () => {
it('should require authorization', async () => {
const { status, body } = await request(app)
.get(`/admin/users`)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should hide deleted users by default', async () => {
const { status, body } = await request(app)
.get(`/admin/users`)
@ -88,15 +78,6 @@ describe('/admin/users', () => {
});
describe('POST /admin/users', () => {
it('should require authorization', async () => {
const { status, body } = await request(app)
.post(`/admin/users`)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
.send(createUserDto.user1);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should accept `isAdmin`', async () => {
const { status, body } = await request(app)
.post(`/admin/users`)
@ -117,14 +98,6 @@ describe('/admin/users', () => {
});
describe('PUT /admin/users/:id', () => {
it('should require authorization', async () => {
const { status, body } = await request(app)
.put(`/admin/users/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should allow a non-admin to become an admin', async () => {
const user = await utils.userSetup(admin.accessToken, createUserDto.create('admin2'));
const { status, body } = await request(app)
@ -225,14 +198,6 @@ describe('/admin/users', () => {
});
describe('DELETE /admin/users/:id', () => {
it('should require authorization', async () => {
const { status, body } = await request(app)
.delete(`/admin/users/${userToDelete.userId}`)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should delete user', async () => {
const { status, body } = await request(app)
.delete(`/admin/users/${userToDelete.userId}`)
@ -296,34 +261,7 @@ describe('/admin/users', () => {
});
});
describe('GET /admin/users/:id/calendar-heatmap', () => {
it('should require admin permissions', async () => {
const { status, body } = await request(app)
.get(`/admin/users/${nonAdmin.userId}/calendar-heatmap`)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should require the AdminUserRead permission', async () => {
const { secret } = await utils.createApiKey(admin.accessToken, [Permission.UserRead]);
const { status, body } = await request(app)
.get(`/admin/users/${nonAdmin.userId}/calendar-heatmap`)
.set('x-api-key', secret);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
});
describe('POST /admin/users/:id/restore', () => {
it('should require authorization', async () => {
const { status, body } = await request(app)
.post(`/admin/users/${userToDelete.userId}/restore`)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should restore a user', async () => {
const user = await utils.userSetup(admin.accessToken, createUserDto.create('restore'));

View file

@ -1,6 +1,5 @@
import { LoginResponseDto, SharedLinkType, getMyUser, login } from '@immich/sdk';
import { LoginResponseDto, getMyUser, login } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
@ -15,19 +14,6 @@ describe('/users', () => {
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user2);
});
describe('GET /users/me', () => {
it('should not work for shared links', async () => {
const album = await utils.createAlbum(admin.accessToken, { albumName: 'Album' });
const sharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Album,
albumId: album.id,
});
const { status, body } = await request(app).get(`/users/me?key=${sharedLink.key}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
});
describe('PUT /users/me', () => {
/** @deprecated */
it('should allow a user to change their password (deprecated)', async () => {

View file

@ -30,8 +30,8 @@ describe(`immich login`, () => {
it('should login and save auth.yml with 600', async () => {
const admin = await utils.adminSetup();
const key = await utils.createApiKey(admin.accessToken, [Permission.All]);
const { stdout, stderr, exitCode } = await immichCli(['login', app, key.secret]);
const apiKey = await utils.createApiKey(admin.accessToken, [Permission.All]);
const { stdout, stderr, exitCode } = await immichCli(['login', app, apiKey.secret]);
expect(stdout.split('\n')).toEqual([
'Logging in to http://127.0.0.1:2285/api',
'Logged in as admin@immich.cloud',
@ -47,8 +47,8 @@ describe(`immich login`, () => {
it('should login without /api in the url', async () => {
const admin = await utils.adminSetup();
const key = await utils.createApiKey(admin.accessToken, [Permission.All]);
const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), key.secret]);
const apiKey = await utils.createApiKey(admin.accessToken, [Permission.All]);
const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), apiKey.secret]);
expect(stdout.split('\n')).toEqual([
'Logging in to http://127.0.0.1:2285',
'Discovered API at http://127.0.0.1:2285/api',

View file

@ -24,13 +24,6 @@ export const ASPECT_RATIO_WEIGHTS = {
'3:1': 0.01, // 1% 3:1 panorama
} as const;
export type AspectRatio = {
width: number;
height: number;
ratio: number;
name: string;
};
// Mock configuration for asset generation - will be transformed to API response formats
export type MockTimelineAsset = {
id: string;

View file

@ -1,4 +1,4 @@
import { BrowserContext, expect, Page } from '@playwright/test';
import { expect, Page } from '@playwright/test';
import { DateTime } from 'luxon';
import { TimelineAssetConfig } from 'src/ui/generators/timeline';
@ -11,18 +11,6 @@ export const padYearMonth = (yearMonth: string) => {
return `${year}-${month.padStart(2, '0')}`;
};
export async function throttlePage(context: BrowserContext, page: Page) {
const session = await context.newCDPSession(page);
await session.send('Network.emulateNetworkConditions', {
offline: false,
downloadThroughput: (1.5 * 1024 * 1024) / 8,
uploadThroughput: (750 * 1024) / 8,
latency: 40,
connectionType: 'cellular3g',
});
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
}
export const poll = async <T>(
page: Page,
query: () => Promise<T>,

View file

@ -184,6 +184,8 @@ export const utils = {
'library',
'shared_link',
'person',
'person_group',
'cluster_group',
'album',
'asset',
'asset_face',
@ -434,12 +436,12 @@ export const utils = {
return person;
},
createFace: async ({ assetId, personId }: { assetId: string; personId: string }) => {
createFace: async ({ assetId, personGroupId }: { assetId: string; personGroupId: string }) => {
if (!client) {
return;
}
await client.query('INSERT INTO asset_face ("assetId", "personId") VALUES ($1, $2)', [assetId, personId]);
await client.query('INSERT INTO asset_face ("assetId", "personGroupId") VALUES ($1, $2)', [assetId, personGroupId]);
},
setPersonThumbnail: async (personId: string) => {
@ -447,7 +449,9 @@ export const utils = {
return;
}
await client.query(`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`, [personId]);
await client.query(`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "personGroupId" = $1`, [
personId,
]);
},
createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) =>
@ -647,7 +651,7 @@ export const utils = {
resetAdminConfig: async (accessToken: string) => {
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
await updateConfig({ adminConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
},
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => {
@ -675,9 +679,9 @@ export const utils = {
},
cliLogin: async (accessToken: string) => {
const key = await utils.createApiKey(accessToken, [Permission.All]);
await immichCli(['login', app, key.secret]);
return key.secret;
const { secret } = await utils.createApiKey(accessToken, [Permission.All]);
await immichCli(['login', app, secret]);
return secret;
},
scan: async (accessToken: string, id: string) => {

View file

@ -1,5 +1,6 @@
{
"about": "About",
"accept": "Accept",
"account": "Account",
"account_settings": "Account Settings",
"acknowledge": "Acknowledge",
@ -36,6 +37,7 @@
"add_to_bottom_bar": "Add to",
"add_upload_to_stack": "Add upload to stack",
"add_url": "Add URL",
"add_user": "Add user",
"added_to_archive": "Added to archive",
"added_to_favorites": "Added to favorites",
"added_to_favorites_count": "Added {count, number} to favorites",
@ -292,6 +294,8 @@
"notification_enable_email_notifications": "Enable email notifications",
"notification_settings": "Notification Settings",
"notification_settings_description": "Manage notification settings, including email",
"oauth_account_management_url": "Account Management URL",
"oauth_account_management_url_description": "Location in the external identity provider where a user can manage their settings or profile.",
"oauth_allow_insecure_requests": "Allow insecure requests",
"oauth_allow_insecure_requests_description": "WARNING: This disables TLS certificate validation for OAuth requests and may expose you to MITM attacks.",
"oauth_auto_launch": "Auto launch",
@ -492,6 +496,7 @@
"admin_password": "Admin Password",
"administration": "Administration",
"advanced": "Advanced",
"advanced_filters": "Advanced filters",
"advanced_settings_clear_image_cache": "Clear Image Cache",
"advanced_settings_clear_image_cache_error": "Failed to clear image cache",
"advanced_settings_clear_image_cache_success": "Successfully cleared {size}",
@ -733,6 +738,9 @@
"client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate import/removal is available only before login",
"client_cert_title": "SSL client certificate [EXPERIMENTAL]",
"close": "Close",
"cluster_group": "Cluster group",
"cluster_group_description": "People are recognized across the photos of everyone in the group",
"cluster_group_invite_description": "You have been invited to join this cluster group.",
"collapse": "Collapse",
"collapse_all": "Collapse all",
"color": "Color",
@ -830,6 +838,7 @@
"date_time_original": "Date/Time Original",
"day": "Day",
"days": "Days",
"decline": "Decline",
"deduplicate_all": "Deduplicate All",
"default_quality_subtitle": "Quality used when tapping share. Long press the share button to choose each time.",
"default_share_quality": "Default share quality",
@ -891,7 +900,9 @@
"download_settings_description": "Manage settings related to asset download",
"download_waiting_to_retry": "Waiting to retry",
"downloading": "Downloading",
"downloading_archive_filename_size": "Downloading archive {filename} (approximately {size})",
"downloading_asset_filename": "Downloading asset {filename}",
"downloading_filename": "Downloading {filename}",
"downloading_from_icloud": "Downloading from iCloud",
"downloading_media": "Downloading media",
"drag_to_reorder": "Drag to reorder",
@ -1040,8 +1051,12 @@
"unable_to_get_comments_number": "Unable to get number of comments",
"unable_to_get_shared_link": "Failed to get shared link",
"unable_to_hide_person": "Unable to hide person",
"unable_to_leave_cluster_group": "Unable to leave cluster group",
"unable_to_link_motion_video": "Unable to link motion video",
"unable_to_link_oauth_account": "Unable to link OAuth account",
"unable_to_load_cluster_group": "Unable to load cluster group",
"unable_to_load_map": "Unable to load map",
"unable_to_load_map_description": "The map requires WebGL to work properly.",
"unable_to_log_out_all_devices": "Unable to log out all devices",
"unable_to_log_out_device": "Unable to log out device",
"unable_to_login_with_oauth": "Unable to login with OAuth",
@ -1131,6 +1146,7 @@
"filename": "Filename",
"filetype": "Filetype",
"filter": "Filter",
"filter_by": "Filter by",
"filter_people": "Filter people",
"filter_places": "Filter places",
"filter_tags": "Filter tags",
@ -1259,6 +1275,8 @@
"latitude": "Latitude",
"leave": "Leave",
"leave_album": "Leave album",
"leave_group": "Leave group",
"leave_group_description": "People will no longer be recognized across the photos of everyone in the group. Are you sure you want to continue?",
"lens_model": "Lens model",
"less": "Less",
"let_others_respond": "Let others respond",
@ -1365,6 +1383,8 @@
"manage_media_access_settings": "Open settings",
"manage_media_access_subtitle": "Allow the Immich app to manage and move media files.",
"manage_media_access_title": "Media Management Access",
"manage_oauth_account": "Manage OAuth Account",
"manage_sharing_with_other_users": "Manage sharing with other users",
"manage_sharing_with_partners": "Manage sharing with partners",
"manage_the_app_settings": "Manage the app settings",
"manage_your_account": "Manage your account",
@ -1394,6 +1414,7 @@
"marked_all_as_read": "Marked all as read",
"matches": "Matches",
"matching_assets": "Matching Assets",
"media": "Media",
"media_chrome": {
"auto": "Auto",
"captions": "Captions",
@ -1427,6 +1448,7 @@
"volume": "volume"
},
"media_type": "Media type",
"media_type_description": "Show only photos or videos from your library.",
"memories": "Memories",
"memories_all_caught_up": "All caught up",
"memories_check_back_tomorrow": "Check back tomorrow for more memories",
@ -1469,6 +1491,7 @@
"my_immich_title": "My Immich link",
"name": "Name",
"name_or_nickname": "Name or nickname",
"name_plus_more_people": "{name} + {count, plural, one {# person} other {# people}}",
"name_required": "Name is required",
"navigate": "Navigate",
"navigate_to_time": "Navigate to Time",
@ -1608,8 +1631,10 @@
"paused": "Paused",
"pending": "Pending",
"people": "People",
"people_count": "{count, plural, one {# person} other {# people}}",
"people_edits_count": "Edited {count, plural, one {# person} other {# people}}",
"people_feature_description": "Browsing photos and videos grouped by people",
"people_search_description": "Select one or more people to find photos they appear in.",
"permanent_deletion_warning": "Permanent deletion warning",
"permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets",
"permanently_delete": "Permanently delete",
@ -1653,6 +1678,7 @@
"port": "Port",
"preferences_settings_subtitle": "Manage the app's preferences",
"preferences_settings_title": "Preferences",
"prepared_archives": "Prepared Archives",
"preparing": "Preparing",
"preset": "Preset",
"preview": "Preview",
@ -1766,6 +1792,7 @@
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
"rename": "Rename",
"repository": "Repository",
"request_received_description": "You have been invited to another group",
"require_password": "Require password",
"rescan": "Rescan",
"reset": "Reset",
@ -1788,13 +1815,17 @@
"restore_all": "Restore all",
"restore_user": "Restore user",
"restored_asset": "Restored asset",
"result": "Result",
"resume": "Resume",
"resume_paused_jobs": "Resume {count, plural, one {# paused job} other {# paused jobs}}",
"retry": "Retry",
"review_duplicates": "Review duplicates",
"review_large_files": "Review large files",
"role": "Role",
"role_editor": "Editor",
"role_viewer": "Viewer",
"rotate_api_key_prompt": "Are you sure you want to rotate this API key? The current key will stop working immediately.",
"rotate_key": "Rotate key",
"running": "Running",
"save": "Save",
"saved": "Saved",
@ -1830,17 +1861,24 @@
"search_filter_apply": "Apply filter",
"search_filter_camera_title": "Select camera type",
"search_filter_date": "Date",
"search_filter_date_custom": "Custom",
"search_filter_date_description": "Filter by when your photos were taken using a preset range or custom date window.",
"search_filter_date_interval": "{start} to {end}",
"search_filter_date_last_30_days": "Last 30 days",
"search_filter_date_last_year": "Last year",
"search_filter_date_this_year": "This year",
"search_filter_date_title": "Select a date range",
"search_filter_display_option_not_in_album": "Not in album",
"search_filter_display_options": "Display Options",
"search_filter_filename": "Search by file name",
"search_filter_location": "Location",
"search_filter_location_description": "Search for photos taken in a specific city, country, or location.",
"search_filter_location_title": "Select location",
"search_filter_media_type": "Media Type",
"search_filter_media_type_title": "Select media type",
"search_filter_people_title": "Select people",
"search_filter_star_rating": "Star Rating",
"search_filter_tags_description": "Filter by tags you've added to organize your library.",
"search_filter_tags_title": "Select tags",
"search_for": "Search for",
"search_for_existing_person": "Search for existing person",
@ -1859,6 +1897,7 @@
"search_tags": "Search tags...",
"search_timezone": "Search timezone...",
"search_type": "Search type",
"search_type_description": "Choose how Immich interprets your search: by visual content, file details, or embedded text.",
"search_your_photos": "Search your photos",
"searching_locales": "Searching locales...",
"second": "Second",
@ -2090,6 +2129,7 @@
"tag_face": "Tag face",
"tag_feature_description": "Browsing photos and videos grouped by logical tag topics",
"tag_people": "Tag People",
"tag_plus_more_tags": "{tag} + {count, plural, one {# tag} other {# tags}}",
"tag_updated": "Updated tag: {tag}",
"tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}",
"tags": "Tags",
@ -2139,6 +2179,8 @@
"trigger": "Trigger",
"trigger_asset_metadata_extraction": "Asset Metadata Extraction",
"trigger_asset_metadata_extraction_description": "Triggered when the EXIF metadata of an asset is extracted",
"trigger_asset_tagged": "Asset Tagged",
"trigger_asset_tagged_description": "Triggered when a tag is added to an asset",
"trigger_asset_uploaded": "Asset Upload",
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
"trigger_person_recognized": "Person Recognized",
@ -2245,6 +2287,7 @@
"view_all_users": "View all users",
"view_asset_owners": "View asset owners",
"view_details": "View Details",
"view_group": "View group",
"view_in_timeline": "View in timeline",
"view_link": "View link",
"view_name": "View",
@ -2276,6 +2319,13 @@
"workflow_info": "Workflow info",
"workflow_json": "Workflow JSON",
"workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.",
"workflow_logging_completed": "Completed",
"workflow_logging_disable": "Disable logging",
"workflow_logging_disabled_description": "Logging is currently disabled for this workflow.",
"workflow_logging_enable": "Enable logging",
"workflow_logging_error_step": "Error on step #{step}",
"workflow_logging_halted": "Halted",
"workflow_logging_halted_step": "Halted on step #{step}",
"workflow_name": "Workflow name",
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
"workflow_summary": "Workflow summary",
@ -2288,6 +2338,7 @@
"year": "Year",
"years_ago": "{years, plural, one {# year} other {# years}} ago",
"yes": "Yes",
"you": "You",
"you_dont_have_any_shared_links": "You don't have any shared links",
"your_wifi_name": "Your Wi-Fi name",
"zero_to_clear_rating": "press 0 to clear asset rating",

View file

@ -291,43 +291,43 @@ url = "https://github.com/opentofu/opentofu/releases/download/v1.12.5/tofu_1.12.
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/484602547"
[[tools.pnpm]]
version = "11.20.0"
version = "11.21.0"
backend = "aqua:pnpm/pnpm"
[tools.pnpm."platforms.linux-arm64"]
checksum = "sha256:f00fc2041bb41742b7943bf2bb24183ad20320e8384824a8031eb94edf2f57a5"
url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142378"
checksum = "sha256:64eb219b008f7a4c176d81fbce919c20b0b7e093e815cbb669d46e681451f43b"
url = "https://github.com/pnpm/pnpm/releases/download/v11.21.0/pnpm-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/507585528"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-arm64-musl"]
checksum = "sha256:2a21235d3f0fbfbcb9e530b9a98f2c64dcee95d1d5d1c2d04428255c7b85f32c"
url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-arm64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142372"
checksum = "sha256:43587a1f3d26ee009c640378ef377cac3531990579acf0f35646531b7832831d"
url = "https://github.com/pnpm/pnpm/releases/download/v11.21.0/pnpm-linux-arm64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/507585511"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64"]
checksum = "sha256:b4ad6ad2b21db2f8cd50af416c3aa148ba704c31c84893f465a770a01c2c4572"
url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-x64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142376"
checksum = "sha256:aadc489ce4473c2af0fec06a5c19e113b5793d404eac49853c8153bf4b0d8263"
url = "https://github.com/pnpm/pnpm/releases/download/v11.21.0/pnpm-linux-x64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/507585503"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64-musl"]
checksum = "sha256:db046881c027f1a2d69e9a530a21a62c2bced4ba8ff437e7300426ae56951fe4"
url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-x64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142377"
checksum = "sha256:8cad0a4d20318c0445d992630ace51edc0853fd259573f50ee5d0216dce9b420"
url = "https://github.com/pnpm/pnpm/releases/download/v11.21.0/pnpm-linux-x64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/507585524"
provenance = "github-attestations"
[tools.pnpm."platforms.macos-arm64"]
checksum = "sha256:4bc97fea72e5c92eec1fefc8d410c35d01e0d0d52f3160c59f38c32db58b5cd2"
url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-darwin-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142373"
checksum = "sha256:860a96978a79ca5132bdc247375f94150a3c98edbecbee36e9f8e0c9e2f7f056"
url = "https://github.com/pnpm/pnpm/releases/download/v11.21.0/pnpm-darwin-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/507585526"
provenance = "github-attestations"
[tools.pnpm."platforms.windows-x64"]
checksum = "sha256:ea2528bdc3d96a1ff3c35587dc48ca692b39d77f08f26df4adeaaa9eb427024e"
url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-win32-x64.zip"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142371"
checksum = "sha256:6643043caa3b01c65c431039926a2af954326d5cefbdb4da7baceceb17f5051f"
url = "https://github.com/pnpm/pnpm/releases/download/v11.21.0/pnpm-win32-x64.zip"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/507585516"
provenance = "github-attestations"
[[tools.terragrunt]]

View file

@ -16,7 +16,7 @@ config_roots = [
[tools]
node = "24.15.0"
pnpm = "11.20.0"
pnpm = "11.21.0"
terragrunt = "1.1.1"
opentofu = "1.12.5"
"npm:@openapitools/openapi-generator-cli" = "2.40.1"
@ -73,7 +73,6 @@ outputs = ["../mobile/generated/openapi/"]
run = "bash ./bin/generate-dart-sdk.sh"
[tasks.open-api]
env = { SHARP_IGNORE_GLOBAL_LIBVIPS = true }
run = [
{ task = "//:plugins" },
{ task = "//server:install" },

View file

@ -26,52 +26,59 @@ linter:
# producing the lint.
rules:
# Formatting
avoid_print: true
require_trailing_commas: true
unrelated_type_equality_checks: true
prefer_const_constructors: true
always_use_package_imports: true
always_put_control_body_on_new_line: true
# Correctness
always_declare_return_types: true
avoid_type_to_string: true
avoid_unused_constructor_parameters: true
avoid_void_async: true
cancel_subscriptions: true
cast_nullable_to_non_nullable: true
close_sinks: true
collection_methods_unrelated_type: true
deprecated_consistency: true
discarded_futures: true
no_adjacent_strings_in_list: true
no_self_assignments: true
noop_primitive_operations: true
only_throw_errors: true
parameter_assignments: true
throw_in_finally: true
tighten_type_of_initializing_formals: true
unawaited_futures: true
unnecessary_ignore: true
unnecessary_null_checks: true
unnecessary_parenthesis: true
prefer_final_locals: true
unnecessary_statements: true
unrelated_type_equality_checks: true
# Performance
avoid_slow_async_io: true
prefer_const_constructors: true
prefer_const_declarations: true
prefer_const_literals_to_create_immutables: true
use_super_parameters: true
directives_ordering: true
no_leading_underscores_for_local_identifiers: true
always_declare_return_types: true
avoid_void_async: true
noop_primitive_operations: true
use_named_constants: true
combinators_ordering: true
avoid_multiple_declarations_per_line: true
unnecessary_breaks: true
# Correctness
no_adjacent_strings_in_list: true
cancel_subscriptions: true
close_sinks: true
unawaited_futures: true
discarded_futures: true
no_self_assignments: true
throw_in_finally: true
collection_methods_unrelated_type: true
cast_nullable_to_non_nullable: true
# Known issues
avoid_slow_async_io: true
avoid_type_to_string: true
# Flutter specific
use_build_context_synchronously: true
avoid_unnecessary_containers: true
sized_box_for_whitespace: true
use_build_context_synchronously: true
use_colored_box: true
use_decorated_box: true
avoid_unnecessary_containers: true
use_full_hex_values_for_flutter_colors: true
# Style
always_put_control_body_on_new_line: true
always_use_package_imports: true
avoid_multiple_declarations_per_line: true
avoid_print: true
combinators_ordering: true
directives_ordering: true
no_leading_underscores_for_local_identifiers: true
prefer_final_locals: true
require_trailing_commas: true
unnecessary_breaks: true
unnecessary_parenthesis: true
use_named_constants: true
use_super_parameters: true
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
analyzer:

View file

@ -18,8 +18,7 @@ targets:
generate_for: &drift_generate_for
- lib/infrastructure/entities/*.dart
- lib/infrastructure/entities/*.drift
- lib/infrastructure/repositories/db.repository.dart
- lib/infrastructure/repositories/logger_db.repository.dart
- lib/infrastructure/repositories/*.repository.dart
drift_dev:modular:
enabled: true
options: *drift_options

View file

@ -32,8 +32,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
"state" : {
"revision" : "84a79bc375a301169390ac110c868f06c857b83f",
"version" : "6.27.0"
"revision" : "5ee345ca5d65238a6fce29bba87816204be7df20",
"version" : "6.28.0"
}
},
{

View file

@ -22,8 +22,9 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers")
}
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
private static let processingTaskID = "app.alextran.immich.background.processingUpload"
private static let taskIDs = Bundle.main.object(forInfoDictionaryKey: "BGTaskSchedulerPermittedIdentifiers") as! [String]
private static let refreshTaskID = taskIDs.first { $0.hasSuffix(".refreshUpload") }!
private static let processingTaskID = taskIDs.first { $0.hasSuffix(".processingUpload") }!
private static let taskSemaphore = DispatchSemaphore(value: 1)
public static func registerBackgroundWorkers() {

View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Makes Flutter not incorrectly append `-sdk` arguments to simulator builds
# This breaks macros that must build for the host's platform
#
# Flutter was informed of this (https://github.com/flutter/flutter/issues/146122) but has not fixed it in 2 years
set -eu
sdk="${MISE_TOOL_INSTALL_PATH:-$(mise where aqua:flutter/flutter)}/flutter"
mac_dart="$sdk/packages/flutter_tools/lib/src/ios/mac.dart"
pattern="buildCommands.addAll(<String>['-sdk', XcodeSdk.IPhoneSimulator.platformName]);"
# Filter out the sdk arg pattern
if awk -v pat="$pattern" 'index($0, pat) { found=1; next } { print } END { exit !found }' "$mac_dart" > "$mac_dart.tmp"; then
# If it was filtered out, apply it
mv "$mac_dart.tmp" "$mac_dart"
# Force flutter itself to rebuild
rm -f "$sdk/bin/cache/flutter_tools.snapshot" "$sdk/bin/cache/flutter_tools.stamp"
echo "flutter postinstall: removed simulator -sdk flag from xcodebuild invocation"
else
rm -f "$mac_dart.tmp"
fi

View file

@ -1,4 +1,3 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';

View file

@ -3,32 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'person.model.freezed.dart';
// TODO: Remove PersonDto once Isar is removed
@freezed
abstract class PersonDto with _$PersonDto {
const factory PersonDto({
required String id,
DateTime? birthDate,
required bool isHidden,
required String name,
required String thumbnailPath,
DateTime? updatedAt,
}) = _PersonDto;
}
// Model for a person stored in the server
@freezed
abstract class DriftPerson with _$DriftPerson {
const factory DriftPerson({
required String id,
required DateTime createdAt,
required DateTime updatedAt,
required String ownerId,
required String name,
String? faceAssetId,
required bool isFavorite,
required bool isHidden,
required String? color,
DateTime? birthDate,
}) = _DriftPerson;
abstract class Person with _$Person {
const factory Person({required String id, required String name, DateTime? updatedAt, DateTime? birthDate}) = _Person;
}

View file

@ -0,0 +1,23 @@
import 'package:immich_mobile/utils/semver.dart';
enum ServerCapability {
// Feature Support
cloudIdMetadata(SemVer(major: 2, minor: 4, patch: 0)),
bulkCloudIdMetadata(SemVer(major: 2, minor: 5, patch: 0)),
assetEdits(SemVer(major: 2, minor: 6, patch: 0)),
assetFacesV2(SemVer(major: 2, minor: 6, patch: 0)),
syncV2(SemVer(major: 3, minor: 0, patch: 0)),
assetOcr(SemVer(major: 3, minor: 0, patch: 0)),
// Migrations
assetPayloadChange20260128(SemVer(major: 2, minor: 5, patch: 0)),
assetPayloadChange20260597(SemVer(major: 2, minor: 7, patch: 6));
const ServerCapability(this.minVersion);
final SemVer minVersion;
}
extension ServerCapabilitySupport on SemVer {
bool supports(ServerCapability capability) => this >= capability.minVersion;
}

View file

@ -1,4 +1,3 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:ui';
import 'package:freezed_annotation/freezed_annotation.dart';

View file

@ -17,10 +17,10 @@ import 'package:maplibre_gl/maplibre_gl.dart';
class AssetService {
final RemoteAssetRepository _remoteRepository;
final RemoteExifRepository _exifRepository;
final DriftLocalAssetRepository _localRepository;
final LocalAssetRepository _localRepository;
final AssetApiRepository _apiRepository;
final AssetMediaRepository _mediaRepository;
final DriftTrashedLocalAssetRepository _trashedLocalRepository;
final TrashedLocalAssetRepository _trashedLocalRepository;
const AssetService({
required this._remoteRepository,
@ -136,13 +136,13 @@ class AssetService {
location: location,
dateTimeOriginal: dateTime,
);
await _remoteRepository.update(
await _remoteRepository.updateAssets(
remoteIds,
isFavorite: isFavorite,
visibility: visibility,
createdAt: parsedDateTime,
);
await _exifRepository.update(
await _exifRepository.updateExif(
remoteIds,
location: location,
dateTimeOriginal: parsedDateTime,
@ -165,7 +165,7 @@ class AssetService {
}
await _apiRepository.delete(remoteIds, true);
await _remoteRepository.delete(remoteIds);
await _remoteRepository.deleteAssets(remoteIds);
}
Future<void> applyEdits(String remoteId, List<AssetEdit> edits) async {
@ -189,7 +189,7 @@ class AssetService {
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
await _trashedLocalRepository.applyTrashedAssets(deletedIds);
} else {
await _localRepository.delete(deletedIds);
await _localRepository.deleteAssets(deletedIds);
}
return deletedIds.length;
}

View file

@ -19,8 +19,6 @@ import 'package:immich_mobile/platform/background_worker_api.g.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
@ -76,31 +74,32 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
: _backgroundHostApi = BackgroundWorkerBgHostApi() {
final ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(_drift))]);
_ref = ref;
final db = ref.read(driftProvider);
_localSyncService = LocalSyncService(
localAlbumRepository: ref.read(localAlbumRepository),
localAssetRepository: ref.read(localAssetRepository),
localAlbumRepository: db.localAlbumRepository,
localAssetRepository: db.localAssetRepository,
nativeSyncApi: ref.read(nativeSyncApiProvider),
trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository),
trashedLocalAssetRepository: db.trashedLocalAssetRepository,
assetMediaRepository: ref.read(assetMediaRepositoryProvider),
permissionRepository: ref.read(permissionRepositoryProvider),
cancellation: _cancellationToken,
);
_remoteSyncService = SyncStreamService(
syncApiRepository: ref.read(syncApiRepositoryProvider),
syncStreamRepository: ref.read(syncStreamRepositoryProvider),
localAssetRepository: ref.read(localAssetRepository),
trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository),
syncStreamRepository: db.syncStreamRepository,
localAssetRepository: db.localAssetRepository,
trashedLocalAssetRepository: db.trashedLocalAssetRepository,
assetMediaRepository: ref.read(assetMediaRepositoryProvider),
permissionRepository: ref.read(permissionRepositoryProvider),
syncMigrationRepository: ref.read(syncMigrationRepositoryProvider),
syncMigrationRepository: db.syncMigrationRepository,
api: ref.read(apiServiceProvider),
cancellation: _cancellationToken,
);
_hashService = HashService(
localAlbumRepository: ref.read(localAlbumRepository),
localAssetRepository: ref.read(localAssetRepository),
localAlbumRepository: db.localAlbumRepository,
localAssetRepository: db.localAssetRepository,
nativeSyncApi: ref.read(nativeSyncApiProvider),
trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository),
trashedLocalAssetRepository: db.trashedLocalAssetRepository,
cancellation: _cancellationToken,
);
BackgroundWorkerFlutterApi.setUp(this);

View file

@ -15,9 +15,9 @@ const String _kHashCancelledCode = "HASH_CANCELLED";
class HashService {
final int _batchSize;
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalAlbumRepository _localAlbumRepository;
final LocalAssetRepository _localAssetRepository;
final TrashedLocalAssetRepository _trashedLocalAssetRepository;
final NativeSyncApi _nativeSyncApi;
final Completer<void>? _cancellation;
final _log = Logger('HashService');

View file

@ -3,7 +3,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
class LocalAlbumService {
final DriftLocalAlbumRepository _repository;
final LocalAlbumRepository _repository;
const LocalAlbumService(this._repository);

View file

@ -21,11 +21,11 @@ import 'package:logging/logging.dart';
const String _kSyncCancelledCode = "SYNC_CANCELLED";
class LocalSyncService {
final DriftLocalAlbumRepository _localAlbumRepository;
final LocalAlbumRepository _localAlbumRepository;
// ignore: unused_field
final DriftLocalAssetRepository _localAssetRepository;
final LocalAssetRepository _localAssetRepository;
final NativeSyncApi _nativeSyncApi;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final TrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final DevicePermissionRepository _permissionRepository;
final Completer<void>? _cancellation;
@ -186,7 +186,7 @@ class LocalSyncService {
_log.fine("Removing device album ${a.name}");
try {
// Asset deletion is handled in the repository
await _localAlbumRepository.delete(a.id);
await _localAlbumRepository.deleteAlbum(a.id);
} catch (e, s) {
_log.warning("Error while removing device album", e, s);
}

View file

@ -8,7 +8,7 @@ typedef MapMarkerSource = Future<List<Marker>> Function(LatLngBounds? bounds);
typedef MapQuery = ({MapMarkerSource markerSource});
class MapFactory {
final DriftMapRepository _mapRepository;
final MapRepository _mapRepository;
const MapFactory({required this._mapRepository});

View file

@ -5,7 +5,7 @@ import 'package:logging/logging.dart';
class DriftMemoryService {
final log = Logger("DriftMemoryService");
final DriftMemoryRepository _repository;
final MemoryRepository _repository;
DriftMemoryService(this._repository);

View file

@ -27,7 +27,7 @@ class PartnerService {
Future<void> update({required String sharedById, required String sharedWithId, required bool inTimeline}) async {
await _partnerApiRepository.update(sharedById, inTimeline: inTimeline);
await _partnerRepository.update(sharedById: sharedById, sharedWithId: sharedWithId, inTimeline: inTimeline);
await _partnerRepository.updatePartner(sharedById: sharedById, sharedWithId: sharedWithId, inTimeline: inTimeline);
}
Future<void> create({required String sharedById, required String sharedWithId, bool inTimeline = false}) async {
@ -37,6 +37,6 @@ class PartnerService {
Future<void> delete({required String sharedById, required String sharedWithId}) async {
await _partnerApiRepository.delete(sharedWithId);
await _partnerRepository.delete(sharedById: sharedById, sharedWithId: sharedWithId);
await _partnerRepository.deletePartner(sharedById: sharedById, sharedWithId: sharedWithId);
}
}

View file

@ -5,21 +5,21 @@ import 'package:immich_mobile/infrastructure/repositories/people.repository.dart
import 'package:immich_mobile/repositories/person_api.repository.dart';
class DriftPeopleService {
final DriftPeopleRepository _repository;
final PeopleRepository _repository;
final PersonApiRepository _personApiRepository;
const DriftPeopleService(this._repository, this._personApiRepository);
Future<DriftPerson?> get(String personId) {
Future<Person?> get(String personId) {
return _repository.get(personId);
}
Future<List<DriftPerson>> getAssetPeople(String assetId) {
Future<List<Person>> getAssetPeople(String assetId) {
return _repository.getAssetPeople(assetId);
}
Future<List<DriftPerson>> getAllPeople({int minFaces = 3}) {
return _repository.getAllPeople(minFaces: minFaces);
Stream<List<Person>> watch({int minFaces = 3}) {
return _repository.watch(minFaces: minFaces);
}
Future<int> updateName(String personId, String name) async {
@ -27,7 +27,7 @@ class DriftPeopleService {
return _repository.updateName(personId, name);
}
Future<int> updateBrithday(String personId, DateTime birthday) async {
Future<int> updateBirthday(String personId, DateTime birthday) async {
await _personApiRepository.update(personId, birthday: birthday);
return _repository.updateBirthday(personId, birthday);
}

View file

@ -25,7 +25,7 @@ class AlbumAssetCandidates {
class RemoteAlbumService {
static final _logger = Logger('RemoteAlbumService');
final DriftRemoteAlbumRepository _repository;
final RemoteAlbumRepository _repository;
final DriftAlbumApiRepository _albumApiRepository;
final ForegroundUploadService _uploadService;
@ -152,7 +152,7 @@ class RemoteAlbumService {
);
// Update the local database
await _repository.update(updatedAlbum);
await _repository.updateAlbum(updatedAlbum);
return updatedAlbum;
}

View file

@ -6,13 +6,13 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'
/// Provides access to a persistent key-value store with an in-memory cache.
/// Listens for repository changes to keep the cache updated.
class StoreService {
final DriftStoreRepository _storeRepository;
final StoreRepository _storeRepository;
/// In-memory cache. Keys are [StoreKey.id]
final Map<int, Object?> _cache = {};
StreamSubscription<List<StoreDto>>? _storeUpdateSubscription;
StoreService._({required DriftStoreRepository isarStoreRepository}) : _storeRepository = isarStoreRepository;
StoreService._({required StoreRepository isarStoreRepository}) : _storeRepository = isarStoreRepository;
// TODO: Temporary typedef to make minimal changes. Remove this and make the presentation layer access store through a provider
static StoreService? _instance;
@ -24,12 +24,12 @@ class StoreService {
}
// TODO: Replace the implementation with the one from create after removing the typedef
static Future<StoreService> init({required DriftStoreRepository storeRepository, bool listenUpdates = true}) async {
static Future<StoreService> init({required StoreRepository storeRepository, bool listenUpdates = true}) async {
_instance ??= await create(storeRepository: storeRepository, listenUpdates: listenUpdates);
return _instance!;
}
static Future<StoreService> create({required DriftStoreRepository storeRepository, bool listenUpdates = true}) async {
static Future<StoreService> create({required StoreRepository storeRepository, bool listenUpdates = true}) async {
final instance = StoreService._(isarStoreRepository: storeRepository);
await instance.populateCache();
if (listenUpdates) {
@ -90,7 +90,7 @@ class StoreService {
/// Removes the value for [key]
Future<void> delete<T>(StoreKey<T> key) async {
await _storeRepository.delete(key);
await _storeRepository.deleteValue(key);
_cache.remove(key.id);
}

View file

@ -6,24 +6,25 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
final syncLinkedAlbumServiceProvider = Provider(
(ref) => SyncLinkedAlbumService(
ref.watch(localAlbumRepository),
ref.watch(remoteAlbumRepository),
final syncLinkedAlbumServiceProvider = Provider((ref) {
final db = ref.watch(driftProvider);
return SyncLinkedAlbumService(
db.localAlbumRepository,
db.remoteAlbumRepository,
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(storeServiceProvider),
),
);
);
});
class SyncLinkedAlbumService {
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final LocalAlbumRepository _localAlbumRepository;
final RemoteAlbumRepository _remoteAlbumRepository;
final DriftAlbumApiRepository _albumApiRepository;
final StoreService _storeService;

View file

@ -4,6 +4,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/server_capability.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@ -33,8 +34,8 @@ class SyncStreamService {
final SyncApiRepository _syncApiRepository;
final SyncStreamRepository _syncStreamRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalAssetRepository _localAssetRepository;
final TrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final DevicePermissionRepository _permissionRepository;
final SyncMigrationRepository _syncMigrationRepository;
@ -122,7 +123,7 @@ class SyncStreamService {
}
if (!migrations.contains(SyncMigrationTask.v20260128_ResetAssetV1.name) &&
semVer >= const SemVer(major: 2, minor: 5, patch: 0)) {
semVer.supports(.assetPayloadChange20260128)) {
_logger.info("Running pre-sync task: v20260128_ResetAssetV1");
await _syncApiRepository.deleteSyncAck([
SyncEntityType.assetV1,
@ -139,7 +140,7 @@ class SyncStreamService {
}
if (!migrations.contains(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name) &&
semVer > const SemVer(major: 2, minor: 7, patch: 5)) {
semVer.supports(.assetPayloadChange20260597)) {
_logger.info("Running pre-sync task: v20260597_ResetAssetV1AssetV2");
await _syncApiRepository.deleteSyncAck([SyncEntityType.assetV1, SyncEntityType.assetV2]);
migrations.add(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name);

View file

@ -38,7 +38,7 @@ enum TimelineOrigin {
}
class TimelineFactory {
final DriftTimelineRepository _timelineRepository;
final TimelineRepository _timelineRepository;
final SettingsRepository _settingsRepository;
const TimelineFactory({required this._timelineRepository, required this._settingsRepository});

View file

@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/server_capability.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
@ -31,12 +32,12 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
await _populateCloudIds(db);
final serverInfo = await ref.read(serverInfoProvider.notifier).getServerInfo();
final canUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 4);
final canUpdateMetadata = serverInfo.serverVersion.supports(.cloudIdMetadata);
if (!canUpdateMetadata) {
logger.fine('Server version does not support asset metadata updates. Skipping cloudId migration.');
return;
}
final canBulkUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 5);
final canBulkUpdateMetadata = serverInfo.serverVersion.supports(.bulkCloudIdMetadata);
// Wait for remote sync to complete, so we have up-to-date asset metadata entries
try {
@ -171,7 +172,7 @@ Future<void> _populateCloudIds(Drift drift) async {
);
}
}
await DriftLocalAlbumRepository(drift).updateCloudMapping(cloudMapping);
await LocalAlbumRepository(drift).updateCloudMapping(cloudMapping);
}
typedef _CloudIdMapping = ({String remoteAssetId, LocalAsset localAsset});

View file

@ -1,21 +1,18 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
final backupRepositoryProvider = Provider<DriftBackupRepository>(
(ref) => DriftBackupRepository(ref.watch(driftProvider)),
);
@DriftAccessor()
class BackupRepository extends DatabaseAccessor<Drift> with $BackupRepositoryMixin {
BackupRepository(super.attachedDatabase);
class DriftBackupRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftBackupRepository(this._db) : super(_db);
Drift get _db => attachedDatabase;
JoinedSelectStatement<$LocalAlbumAssetEntityTable, LocalAlbumAssetEntityData> _getExcludedSubquery() {
return _db.localAlbumAssetEntity.selectOnly()

View file

@ -32,8 +32,27 @@ import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/ocr.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/people.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
@ -69,6 +88,28 @@ import 'package:sqlite_async/sqlite_async.dart';
AssetOcrEntity,
],
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
daos: [
AuthUserRepository,
BackupRepository,
LocalAlbumRepository,
LocalAssetRepository,
MapRepository,
MemoryRepository,
OcrRepository,
PartnerRepository,
PeopleRepository,
RemoteAlbumRepository,
RemoteAssetRepository,
RemoteExifRepository,
StackRepository,
StoreRepository,
SyncMigrationRepository,
SyncStreamRepository,
TimelineRepository,
TrashedLocalAssetRepository,
UserMetadataRepository,
UserRepository,
],
)
class Drift extends $Drift {
final SqliteConnectionPool? _updatePool;
@ -344,13 +385,6 @@ class Drift extends $Drift {
);
}
class DriftDatabaseRepository {
final Drift _db;
const DriftDatabaseRepository(this._db);
Future<T> transaction<T>(Future<T> Function() callback) => _db.transaction(callback);
}
// ignore: invalid_use_of_internal_member
final class _DriftPoolStreamQueries extends StreamQueryStore {
_DriftPoolStreamQueries(this._pool);

View file

@ -10,13 +10,15 @@ import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.d
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.drift.dart';
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset }
class DriftLocalAlbumRepository extends DriftDatabaseRepository {
final Drift _db;
@DriftAccessor()
class LocalAlbumRepository extends DatabaseAccessor<Drift> with $LocalAlbumRepositoryMixin {
LocalAlbumRepository(super.attachedDatabase);
const DriftLocalAlbumRepository(this._db) : super(_db);
Drift get _db => attachedDatabase;
Future<List<LocalAlbum>> getAll({Set<SortLocalAlbumsBy> sortBy = const {}}) {
final assetCount = _db.localAlbumAssetEntity.assetId.count();
@ -57,7 +59,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get();
}
Future<void> delete(String albumId) => transaction(() async {
Future<void> deleteAlbum(String albumId) => transaction(() async {
// Remove all assets that are only in this particular album
// We cannot remove all assets in the album because they might be in other albums in iOS
// That is not the case on Android since asset <-> album has one:one mapping

View file

@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.drift.dart';
class RemovalCandidatesResult {
final List<LocalAsset> assets;
@ -18,10 +19,11 @@ class RemovalCandidatesResult {
const RemovalCandidatesResult({required this.assets, required this.totalBytes});
}
class DriftLocalAssetRepository extends DriftDatabaseRepository {
final Drift _db;
@DriftAccessor()
class LocalAssetRepository extends DatabaseAccessor<Drift> with $LocalAssetRepositoryMixin {
LocalAssetRepository(super.attachedDatabase);
const DriftLocalAssetRepository(this._db) : super(_db);
Drift get _db => attachedDatabase;
SingleOrNullSelectable<LocalAsset?> _assetSelectable(String id) {
final query =
@ -72,7 +74,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
});
}
Future<void> delete(List<String> ids) {
Future<void> deleteAssets(List<String> ids) {
if (ids.isEmpty) {
return Future.value();
}

View file

@ -5,13 +5,15 @@ import 'package:immich_mobile/domain/services/map.service.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class DriftMapRepository extends DriftDatabaseRepository {
final Drift _db;
@DriftAccessor()
class MapRepository extends DatabaseAccessor<Drift> with $MapRepositoryMixin {
MapRepository(super.attachedDatabase);
const DriftMapRepository(super._db) : _db = _db;
Drift get _db => attachedDatabase;
MapQuery remote(List<String> ownerIds, TimelineMapOptions options) => _mapQueryBuilder(
assetFilter: (row) {

View file

@ -4,10 +4,13 @@ import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/memory.repository.drift.dart';
class DriftMemoryRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftMemoryRepository(this._db) : super(_db);
@DriftAccessor()
class MemoryRepository extends DatabaseAccessor<Drift> with $MemoryRepositoryMixin {
MemoryRepository(super.attachedDatabase);
Drift get _db => attachedDatabase;
Future<List<DriftMemory>> getAll(String ownerId, {bool onlyToday = true}) async {
final query =

View file

@ -2,10 +2,13 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/ocr.model.dart';
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/ocr.repository.drift.dart';
class OcrRepository extends DriftDatabaseRepository {
final Drift _db;
const OcrRepository(this._db) : super(_db);
@DriftAccessor()
class OcrRepository extends DatabaseAccessor<Drift> with $OcrRepositoryMixin {
OcrRepository(super.attachedDatabase);
Drift get _db => attachedDatabase;
Future<List<Ocr>> get(String assetId) async {
final query = _db.select(_db.assetOcrEntity)

View file

@ -4,10 +4,13 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
import 'package:immich_mobile/infrastructure/mapper.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.drift.dart';
class PartnerRepository {
final Drift _db;
const PartnerRepository(this._db);
@DriftAccessor()
class PartnerRepository extends DatabaseAccessor<Drift> with $PartnerRepositoryMixin {
PartnerRepository(super.attachedDatabase);
Drift get _db => attachedDatabase;
Future<Partner> get({required String sharedById, required String sharedWithId}) =>
(_db.select(_db.partnerEntity).join([
@ -46,11 +49,11 @@ class PartnerRepository {
),
);
Future<void> update({required String sharedById, required String sharedWithId, required bool inTimeline}) =>
Future<void> updatePartner({required String sharedById, required String sharedWithId, required bool inTimeline}) =>
(_db.partnerEntity.update()..where((t) => t.sharedById.equals(sharedById) & t.sharedWithId.equals(sharedWithId)))
.write(PartnerEntityCompanion(inTimeline: Value(inTimeline)));
Future<void> delete({required String sharedById, required String sharedWithId}) =>
Future<void> deletePartner({required String sharedById, required String sharedWithId}) =>
(_db.partnerEntity.delete()..where((t) => t.sharedById.equals(sharedById) & t.sharedWithId.equals(sharedWithId)))
.go();

View file

@ -3,19 +3,22 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/people.repository.drift.dart';
class DriftPeopleRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftPeopleRepository(this._db) : super(_db);
@DriftAccessor()
class PeopleRepository extends DatabaseAccessor<Drift> with $PeopleRepositoryMixin {
PeopleRepository(super.attachedDatabase);
Future<DriftPerson?> get(String personId) async {
Drift get _db => attachedDatabase;
Future<Person?> get(String personId) async {
final query = _db.select(_db.personEntity)..where((row) => row.id.equals(personId));
final result = await query.getSingleOrNull();
return result?.toDto();
}
Future<List<DriftPerson>> getAssetPeople(String assetId) async {
Future<List<Person>> getAssetPeople(String assetId) async {
// An asset can have multiple face records for the same person (e.g., metadata
// imports alongside ML detections). Use a subquery instead of a join so each
// person is returned once, regardless of how many of their faces are on the asset
@ -33,7 +36,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get();
}
Future<List<DriftPerson>> getAllPeople({int minFaces = 3}) async {
Stream<List<Person>> watch({int minFaces = 3}) {
final people = _db.personEntity;
final faces = _db.assetFaceEntity;
final assets = _db.remoteAssetEntity;
@ -59,7 +62,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
return query.map((row) {
final person = row.readTable(people);
return person.toDto();
}).get();
}).watch();
}
Future<int> updateName(String personId, String name) {
@ -76,18 +79,5 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
}
extension on PersonEntityData {
DriftPerson toDto() {
return DriftPerson(
id: id,
createdAt: createdAt,
updatedAt: updatedAt,
ownerId: ownerId,
name: name,
faceAssetId: faceAssetId,
isFavorite: isFavorite,
isHidden: isHidden,
color: color,
birthDate: birthDate,
);
}
Person toDto() => Person(id: id, updatedAt: updatedAt, name: name, birthDate: birthDate);
}

View file

@ -12,12 +12,15 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.d
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.drift.dart';
enum SortRemoteAlbumsBy { id, updatedAt }
class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftRemoteAlbumRepository(this._db) : super(_db);
@DriftAccessor()
class RemoteAlbumRepository extends DatabaseAccessor<Drift> with $RemoteAlbumRepositoryMixin {
RemoteAlbumRepository(super.attachedDatabase);
Drift get _db => attachedDatabase;
Future<List<RemoteAlbum>> getAll({Set<SortRemoteAlbumsBy> sortBy = const {SortRemoteAlbumsBy.updatedAt}}) {
// Count non-trashed assets via the joined asset table. Filtering trashed assets in the
@ -191,7 +194,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
});
}
Future<void> update(RemoteAlbum album) async {
Future<void> updateAlbum(RemoteAlbum album) async {
await _db.remoteAlbumEntity.update().replace(
RemoteAlbumEntityCompanion(
id: Value(album.id),

View file

@ -10,13 +10,15 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.drift.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class RemoteAssetRepository extends DriftDatabaseRepository {
final Drift _db;
@DriftAccessor()
class RemoteAssetRepository extends DatabaseAccessor<Drift> with $RemoteAssetRepositoryMixin {
RemoteAssetRepository(super.attachedDatabase);
const RemoteAssetRepository(this._db) : super(_db);
Drift get _db => attachedDatabase;
/// For testing purposes
Future<List<RemoteAsset>> getSome(String userId) {
@ -163,7 +165,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
);
}
Future<void> delete(List<String> ids) {
Future<void> deleteAssets(List<String> ids) {
return _db.batch((batch) {
for (final id in ids) {
batch.deleteWhere(_db.remoteAssetEntity, (row) => row.id.equals(id));
@ -244,7 +246,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()!).get();
}
Future<void> update(
Future<void> updateAssets(
List<String> remoteIds, {
Option<bool> isFavorite = const .none(),
Option<AssetVisibility> visibility = const .none(),

View file

@ -1,14 +1,17 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.drift.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class RemoteExifRepository extends DriftDatabaseRepository {
final Drift _db;
@DriftAccessor()
class RemoteExifRepository extends DatabaseAccessor<Drift> with $RemoteExifRepositoryMixin {
RemoteExifRepository(super.attachedDatabase);
const RemoteExifRepository(this._db) : super(_db);
Drift get _db => attachedDatabase;
Future<void> update(
Future<void> updateExif(
List<String> ids, {
Option<DateTime> dateTimeOriginal = const .none(),
Option<String> timeZone = const .none(),

View file

@ -2,10 +2,13 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.repository.drift.dart';
class DriftStackRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftStackRepository(this._db) : super(_db);
@DriftAccessor()
class StackRepository extends DatabaseAccessor<Drift> with $StackRepositoryMixin {
StackRepository(super.attachedDatabase);
Drift get _db => attachedDatabase;
Future<List<Stack>> getAll(String userId) {
final query = _db.stackEntity.select()..where((e) => e.ownerId.equals(userId));

View file

@ -3,13 +3,16 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
class DriftStoreRepository extends DriftDatabaseRepository {
final Drift _db;
@DriftAccessor()
class StoreRepository extends DatabaseAccessor<Drift> with $StoreRepositoryMixin {
final validStoreKeys = StoreKey.values.map((e) => e.id).toSet();
DriftStoreRepository(super.db) : _db = db;
StoreRepository(super.attachedDatabase);
Drift get _db => attachedDatabase;
Future<bool> deleteAll() async {
await _db.storeEntity.deleteAll();
@ -27,7 +30,7 @@ class DriftStoreRepository extends DriftDatabaseRepository {
return query.asyncMap((entity) => _toUpdateEvent(entity)).watch();
}
Future<void> delete<T>(StoreKey<T> key) async {
Future<void> deleteValue<T>(StoreKey<T> key) async {
await _db.storeEntity.deleteWhere((entity) => entity.id.equals(key.id));
return;
}
@ -64,7 +67,7 @@ class DriftStoreRepository extends DriftDatabaseRepository {
const (bool) => entity.intValue == 1,
const (DateTime) => entity.intValue == null ? null : DateTime.fromMillisecondsSinceEpoch(entity.intValue!),
const (UserDto) =>
entity.stringValue == null ? null : await DriftAuthUserRepository(_db).get(entity.stringValue!),
entity.stringValue == null ? null : await AuthUserRepository(_db).get(entity.stringValue!),
_ => null,
}
as T?;
@ -75,7 +78,7 @@ class DriftStoreRepository extends DriftDatabaseRepository {
const (String) => (null, value as String),
const (bool) => ((value as bool) ? 1 : 0, null),
const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null),
const (UserDto) => (null, (await DriftAuthUserRepository(_db).upsert(value as UserDto)).id),
const (UserDto) => (null, (await AuthUserRepository(_db).upsert(value as UserDto)).id),
_ => throw UnsupportedError("Unsupported primitive type: ${key.type} for key: ${key.name}"),
};
return StoreEntityCompanion(id: Value(key.id), intValue: Value(intValue), stringValue: Value(strValue));

View file

@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/server_capability.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
@ -44,25 +45,16 @@ class SyncApiRepository {
types: [
SyncRequestType.authUsersV1,
SyncRequestType.usersV1,
serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
? SyncRequestType.assetsV2
: SyncRequestType.assetsV1,
serverVersion.supports(.syncV2) ? SyncRequestType.assetsV2 : SyncRequestType.assetsV1,
SyncRequestType.assetExifsV1,
if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetEditsV1,
if (serverVersion.supports(.assetEdits)) SyncRequestType.assetEditsV1,
SyncRequestType.assetMetadataV1,
SyncRequestType.partnersV1,
serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
? SyncRequestType.partnerAssetsV2
: SyncRequestType.partnerAssetsV1,
serverVersion.supports(.syncV2) ? SyncRequestType.partnerAssetsV2 : SyncRequestType.partnerAssetsV1,
SyncRequestType.partnerAssetExifsV1,
if (serverVersion < const SemVer(major: 3, minor: 0, patch: 0))
SyncRequestType.albumsV1
else
SyncRequestType.albumsV2,
serverVersion.supports(.syncV2) ? SyncRequestType.albumsV2 : SyncRequestType.albumsV1,
SyncRequestType.albumUsersV1,
serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)
? SyncRequestType.albumAssetsV2
: SyncRequestType.albumAssetsV1,
serverVersion.supports(.syncV2) ? SyncRequestType.albumAssetsV2 : SyncRequestType.albumAssetsV1,
SyncRequestType.albumAssetExifsV1,
SyncRequestType.albumToAssetsV1,
SyncRequestType.memoriesV1,
@ -71,10 +63,8 @@ class SyncApiRepository {
SyncRequestType.partnerStacksV1,
SyncRequestType.userMetadataV1,
SyncRequestType.peopleV1,
serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)
? SyncRequestType.assetFacesV2
: SyncRequestType.assetFacesV1,
if (serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) SyncRequestType.assetOcrV1,
serverVersion.supports(.assetFacesV2) ? SyncRequestType.assetFacesV2 : SyncRequestType.assetFacesV1,
if (serverVersion.supports(.assetOcr)) SyncRequestType.assetOcrV1,
],
).toJson(),
);

View file

@ -1,9 +1,12 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.drift.dart';
class SyncMigrationRepository extends DriftDatabaseRepository {
final Drift _db;
@DriftAccessor()
class SyncMigrationRepository extends DatabaseAccessor<Drift> with $SyncMigrationRepositoryMixin {
SyncMigrationRepository(super.attachedDatabase);
const SyncMigrationRepository(super.db) : _db = db;
Drift get _db => attachedDatabase;
Future<void> v20260128CopyExifWidthHeightToAsset() async {
await _db.customStatement('''

View file

@ -29,16 +29,19 @@ import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.drift.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AlbumUserRole, AssetEditAction, AssetVisibility, UserMetadataKey;
import 'package:openapi/api.dart' hide AlbumUserRole, AssetEditAction, AssetVisibility, UserMetadataKey;
class SyncStreamRepository extends DriftDatabaseRepository {
final Logger _logger = Logger('DriftSyncStreamRepository');
final Drift _db;
@DriftAccessor()
class SyncStreamRepository extends DatabaseAccessor<Drift> with $SyncStreamRepositoryMixin {
final Logger _logger = Logger('SyncStreamRepository');
SyncStreamRepository(super.db) : _db = db;
SyncStreamRepository(super.attachedDatabase);
Drift get _db => attachedDatabase;
Future<void> reset() async {
_logger.fine("SyncResetV1 received. Resetting remote entities");
@ -224,6 +227,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
batch.insert(
_db.remoteAssetEntity,
companion.copyWith(id: Value(asset.id)),
mode: InsertMode.insertOrReplace,
onConflict: DoUpdate((_) => companion),
);
}
@ -263,6 +267,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
batch.insert(
_db.remoteAssetEntity,
companion.copyWith(id: Value(asset.id)),
mode: InsertMode.insertOrReplace,
onConflict: DoUpdate((_) => companion),
);
}

View file

@ -12,6 +12,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.drift.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:stream_transform/stream_transform.dart';
@ -33,10 +34,11 @@ class TimelineMapOptions {
});
}
class DriftTimelineRepository extends DriftDatabaseRepository {
final Drift _db;
@DriftAccessor()
class TimelineRepository extends DatabaseAccessor<Drift> with $TimelineRepositoryMixin {
TimelineRepository(super.attachedDatabase);
const DriftTimelineRepository(super._db) : _db = _db;
Drift get _db => attachedDatabase;
Stream<List<String>> watchTimelineUserIds(String userId) {
final query = _db.partnerEntity.selectOnly()
@ -183,7 +185,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
TimelineQuery remoteAlbum(String albumId, GroupAssetsBy groupBy) => (
bucketSource: () => _watchRemoteAlbumBucket(albumId, groupBy: groupBy),
assetSource: (offset, count) => _getRemoteAlbumBucketAssets(albumId, offset: offset, count: count),
assetSource: (offset, count) =>
_getRemoteAlbumBucketAssets(albumId, groupBy: groupBy, offset: offset, count: count),
origin: TimelineOrigin.remoteAlbum,
);
@ -237,7 +240,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
.handleError((error) => const <Bucket>[]);
}
Future<List<BaseAsset>> _getRemoteAlbumBucketAssets(String albumId, {required int offset, required int count}) async {
Future<List<BaseAsset>> _getRemoteAlbumBucketAssets(
String albumId, {
required int offset,
required int count,
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) async {
final albumData = await (_db.remoteAlbumEntity.select()..where((row) => row.id.equals(albumId))).getSingleOrNull();
// If album doesn't exist (was deleted), return empty list
@ -264,11 +272,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
),
])..where(_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAlbumAssetEntity.albumId.equals(albumId));
if (isAscending) {
query.orderBy([OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]);
} else {
query.orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]);
}
query.orderBy(
_assetDateOrder(groupBy, ascending: isAscending).map((order) => order(_db.remoteAssetEntity)).toList(),
);
query.limit(count, offset: offset);
@ -375,13 +381,14 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
TimelineQuery place(String place, GroupAssetsBy groupBy) => (
bucketSource: () => _watchPlaceBucket(place, groupBy: groupBy),
assetSource: (offset, count) => _getPlaceBucketAssets(place, offset: offset, count: count),
assetSource: (offset, count) => _getPlaceBucketAssets(place, groupBy: groupBy, offset: offset, count: count),
origin: TimelineOrigin.place,
);
TimelineQuery person(String userId, String personId, GroupAssetsBy groupBy) => (
bucketSource: () => _watchPersonBucket(userId, personId, groupBy: groupBy),
assetSource: (offset, count) => _getPersonBucketAssets(userId, personId, offset: offset, count: count),
assetSource: (offset, count) =>
_getPersonBucketAssets(userId, personId, groupBy: groupBy, offset: offset, count: count),
origin: TimelineOrigin.person,
);
@ -418,7 +425,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}).watch();
}
Future<List<BaseAsset>> _getPlaceBucketAssets(String place, {required int offset, required int count}) {
Future<List<BaseAsset>> _getPlaceBucketAssets(
String place, {
required int offset,
required int count,
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
final query =
_db.remoteAssetEntity.select().join([
innerJoin(
@ -432,7 +444,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.remoteExifEntity.city.equals(place),
)
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
..orderBy(_assetDateOrder(groupBy).map((order) => order(_db.remoteAssetEntity)).toList())
..limit(count, offset: offset);
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
}
@ -488,6 +500,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
String personId, {
required int offset,
required int count,
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
final idQuery = _db.assetFaceEntity.selectOnly()
..addColumns([_db.assetFaceEntity.assetId])
@ -505,7 +518,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
row.ownerId.equals(userId) &
row.visibility.equalsValue(AssetVisibility.timeline),
)
..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
..orderBy(_assetDateOrder(groupBy))
..limit(count, offset: offset);
return query.map((row) => row.toDto()).get();
@ -715,6 +728,14 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
List<Bucket> _generateBuckets(int count) => count == 0 ? const [] : [Bucket(assetCount: count)];
List<OrderingTerm Function($RemoteAssetEntityTable)> _assetDateOrder(GroupAssetsBy groupBy, {bool ascending = false}) {
OrderingTerm order(Expression<Object> exp) => ascending ? OrderingTerm.asc(exp) : OrderingTerm.desc(exp);
return [
if (groupBy != GroupAssetsBy.none) (row) => order(row.effectiveCreatedAt(groupBy)),
(row) => order(row.createdAt),
];
}
extension on Expression<DateTime> {
Expression<String> dateFmt(GroupAssetsBy groupBy, {bool toLocal = false}) {
// DateTimes are stored in UTC, so we need to convert them to local time inside the query before formatting

View file

@ -8,13 +8,15 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.d
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.drift.dart';
typedef TrashedAsset = ({String albumId, LocalAsset asset});
class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
final Drift _db;
@DriftAccessor()
class TrashedLocalAssetRepository extends DatabaseAccessor<Drift> with $TrashedLocalAssetRepositoryMixin {
TrashedLocalAssetRepository(super.attachedDatabase);
const DriftTrashedLocalAssetRepository(this._db) : super(_db);
Drift get _db => attachedDatabase;
/// Matches remote_asset_entity rows owned by the current user. The asset is unique over (owner, checksum),
/// so partners can have a duplicate checksum

View file

@ -4,18 +4,23 @@ import 'package:immich_mobile/domain/models/user_metadata.model.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/mapper.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart';
class UserRepository {
final Drift _db;
const UserRepository(this._db);
@DriftAccessor()
class UserRepository extends DatabaseAccessor<Drift> with $UserRepositoryMixin {
UserRepository(super.attachedDatabase);
Drift get _db => attachedDatabase;
Stream<Iterable<User>> getAll() => _db.select(_db.userEntity).map(mapToUser).watch();
}
class DriftAuthUserRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftAuthUserRepository(super.db) : _db = db;
@DriftAccessor()
class AuthUserRepository extends DatabaseAccessor<Drift> with $AuthUserRepositoryMixin {
AuthUserRepository(super.attachedDatabase);
Drift get _db => attachedDatabase;
Future<UserDto?> get(String id) async {
final user = await _db.managers.authUserEntity.filter((user) => user.id.equals(id)).getSingleOrNull();

View file

@ -3,9 +3,11 @@ import 'package:immich_mobile/domain/models/user_metadata.model.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class DriftUserMetadataRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftUserMetadataRepository(this._db) : super(_db);
@DriftAccessor()
class UserMetadataRepository extends DatabaseAccessor<Drift> {
UserMetadataRepository(super.attachedDatabase);
Drift get _db => attachedDatabase;
Future<List<UserMetadata>> getUserMetadata(String userId) {
final query = _db.userMetadataEntity.select()..where((e) => e.userId.equals(userId));

View file

@ -272,12 +272,16 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: context.locale),
theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale),
builder: (context, child) => ImmichTranslationProvider(
translations: ImmichTranslations(submit: context.t.submit, password: context.t.password),
translations: ImmichTranslations(
submit: context.t.submit,
password: context.t.password,
undo: context.t.undo,
),
child: ImmichThemeProvider(colorScheme: context.colorScheme, child: child!),
),
routerConfig: router.config(
deepLinkBuilder: _deepLinkBuilder,
navigatorObservers: () => [AppNavigationObserver(ref: ref)],
navigatorObservers: () => [AppNavigationObserver(ref: ref), TransitioningRouteObserver()],
),
),
);

View file

@ -1,4 +1,3 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'package:background_downloader/background_downloader.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

View file

@ -1,4 +1,3 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
enum LivePhotosPart { video, image }

View file

@ -1,4 +1,3 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
@ -194,7 +193,7 @@ class SearchFilter {
String? language;
String? assetId;
List<String>? tagIds;
Set<PersonDto> people;
Set<Person> people;
SearchLocationFilter location;
SearchCameraFilter camera;
SearchDateFilter date;
@ -250,7 +249,7 @@ class SearchFilter {
String? language,
String? ocr,
String? assetId,
Set<PersonDto>? people,
Set<Person>? people,
List<String>? tagIds,
SearchLocationFilter? location,
SearchCameraFilter? camera,

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