Merge branch 'main' of github.com:immich-app/immich into feat/mobile-thumbhash-scroll

This commit is contained in:
Alex Tran 2023-10-23 20:39:28 -05:00
commit f14880ba53
69 changed files with 983 additions and 202 deletions

View file

@ -3307,6 +3307,12 @@ export interface SystemConfigDto {
* @memberof SystemConfigDto
*/
'storageTemplate': SystemConfigStorageTemplateDto;
/**
*
* @type {SystemConfigThemeDto}
* @memberof SystemConfigDto
*/
'theme': SystemConfigThemeDto;
/**
*
* @type {SystemConfigThumbnailDto}
@ -3741,6 +3747,19 @@ export interface SystemConfigTemplateStorageOptionDto {
*/
'yearOptions': Array<string>;
}
/**
*
* @export
* @interface SystemConfigThemeDto
*/
export interface SystemConfigThemeDto {
/**
*
* @type {string}
* @memberof SystemConfigThemeDto
*/
'customCss': string;
}
/**
*
* @export

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View file

@ -1,5 +1,5 @@
---
sidebar_position: 80
sidebar_position: 90
---
import RegisterAdminUser from '../partials/_register-admin.md';

View file

@ -0,0 +1,192 @@
---
sidebar_position: 80
---
# TrueNAS SCALE [Community]
:::note
This is a community contribution and not officially supported by the Immich team, but included here for convenience.
**Please report issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).**
:::
Immich can easily be installed on TrueNAS SCALE via the **Community** train application.
Consider reviewing the TrueNAS [Apps tutorial](https://www.truenas.com/docs/scale/scaletutorials/apps/) if you have not previously configured applications on your system.
TrueNAS SCALE makes installing and updating Immich easy, but you must use the Immich web portal and mobile app to configure accounts and access libraries.
## First Steps
The Immich app in TrueNAS SCALE installs, completes the initial configuration, then starts the Immich web portal.
When updates become available, SCALE alerts and provides easy updates.
Before installing the Immich app in SCALE, review the [Environment Variables](/docs/install/environment-variables.md) documentation to see if you want to configure any during installation.
You can configure environment variables at any time after deploying the application.
You can allow SCALE to create the datasets Immich requires automatically during app installation.
Or before beginning app installation, [create the datasets](https://www.truenas.com/docs/scale/scaletutorials/storage/datasets/datasetsscale/) to use in the **Storage Configuration** section during installation.
Immich requires seven datasets: **library**, **pgBackup**, **pgData**, **profile**, **thumbs**, **uploads**, and **video**.
You can organize these as one parent with seven child datasets, for example `mnt/tank/immich/library`, `mnt/tank/immich/pgBackup`, and so on.
## Installing the Immich Application
To install the **Immich** application, go to **Apps**, click **Discover Apps**, either begin typing Immich into the search field or scroll down to locate the **Immich** application widget.
<img
src={require('./img/truenas01.png').default}
width="50%"
alt="Immich App Widget"
className="border rounded-xl"
/>
Click on the widget to open the **Immich** application details screen.
<img
src={require('./img/truenas02.png').default}
width="100%"
alt="Immich App Details Screen"
className="border rounded-xl"
/>
Click **Install** to open the Immich application configuration screen.
Application configuration settings are presented in several sections, each explained below.
To find specific fields click in the **Search Input Fields** search field, scroll down to a particular section or click on the section heading on the navigation area in the upper-right corner.
<img
src={require('./img/truenas03.png').default}
width="100%"
alt="Install Immich Screen"
className="border rounded-xl"
/>
Accept the default values in **Application Name** and **Version**.
Accept the default value in **Timezone** or change to match your local timezone.
**Timezone** is only used by the Immich `exiftool` microservice if it cannot be determined from the image metadata.
Accept the default port in **Web Port**.
Immich requires seven storage datasets.
You can allow SCALE to create them for you, or use the dataset(s) created in [First Steps](#first-steps).
Select the storage options you want to use for **Immich Uploads Storage**, **Immich Library Storage**, **Immich Thumbs Storage**, **Immich Profile Storage**, **Immich Video Storage**, **Immich Postgres Data Storage**, **Immich Postgres Backup Storage**.
Select **ixVolume (dataset created automatically by the system)** in **Type** to let SCALE create the dataset or select **Host Path** to use the existing datasets created on the system.
Accept the defaults in Resources or change the CPU and memory limits to suit your use case.
Click **Install**.
The system opens the **Installed Applications** screen with the Immich app in the **Deploying** state.
When the installation completes it changes to **Running**.
<img
src={require('./img/truenas04.png').default}
width="100%"
alt="Immich Installed"
className="border rounded-xl"
/>
Click **Web Portal** on the **Application Info** widget to open the Immich web interface to set up your account and begin uploading photos.
:::tip
For more information on how to use the application once installed, please refer to the [Post Install](/docs/install/post-install.mdx) guide.
:::
## Editing Environment Variables
Go to the **Installed Applications** screen and select Immich from the list of installed applications.
Click **Edit** on the **Application Info** widget to open the **Edit Immich** screen.
The settings on the edit screen are the same as on the install screen.
You cannot edit **Storage Configuration** paths after the initial app install.
Click **Update** to save changes.
TrueNAS automatically updates, recreates, and redeploys the Immich container with the updated environment variables.
## Updating the App
When updates become available, SCALE alerts and provides easy updates.
To update the app to the latest version, click **Update** on the **Application Info** widget from the **Installed Applications** screen.
Update opens an update window for the application that includes two selectable options, Images (to be updated) and Changelog. Click on the down arrow to see the options available for each.
Click **Upgrade** to begin the process and open a counter dialog that shows the upgrade progress. When complete, the update badge and buttons disappear and the application Update state on the Installed screen changes from Update Available to Up to date.
## Understanding Immich Settings in TrueNAS SCALE
Accept the default value or enter a name in **Application Name** field.
In most cases use the default name, but if adding a second deployment of the application you must change this name.
Accept the default version number in **Version**.
When a new version becomes available, the application has an update badge.
The **Installed Applications** screen shows the option to update applications.
### Immich Configuration Settings
You can accept the defaults in the **Immich Configuration** settings, or enter the settings you want to use.
<img
src={require('./img/truenas05.png').default}
width="100%"
alt="Configuration Settings"
className="border rounded-xl"
/>
Accept the default setting in **Timezone** or change to match your local timezone.
**Timezone** is only used by the Immich `exiftool` microservice if it cannot be determined from the image metadata.
You can enter a **Public Login Message** to display on the login page, or leave it blank.
### Networking Settings
Accept the default port numbers in **Web Port**.
The SCALE Immich app listens on port **30041**.
Refer to the TrueNAS [default port list](https://www.truenas.com/docs/references/defaultports/) for a list of assigned port numbers.
To change the port numbers, enter a number within the range 9000-65535.
<img
src={require('./img/truenas06.png').default}
width="100%"
alt="Networking Settings"
className="border rounded-xl"
/>
### Storage Settings
You can install Immich using the default setting **ixVolume (dataset created automatically by the system)** or use the host path option with datasets [created before installing the app](#first-steps).
<img
src={require('./img/truenas07.png').default}
width="100%"
alt="Configure Storage ixVolumes"
className="border rounded-xl"
/>
Select **Host Path (Path that already exists on the system)** to browse to and select the datasets.
<img
src={require('./img/truenas08.png').default}
width="100%"
alt="Configure Storage Host Paths"
className="border rounded-xl"
/>
### Resource Configuration Settings
Accept the default values in **Resources Configuration** or enter new CPU and memory values
By default, this application is limited to use no more than 4 CPU cores and 8 Gigabytes available memory. The application might use considerably less system resources.
<img
src={require('./img/truenas09.png').default}
width="100%"
alt="Resource Limits"
className="border rounded-xl"
/>
To customize the CPU and memory allocated to the container Immich uses, enter new CPU values as a plain integer value followed by the suffix m (milli).
Default is 4000m.
Accept the default value 8Gi allocated memory or enter a new limit in bytes.
Enter a plain integer followed by the measurement suffix, for example 129M or 123Mi.
Systems with compatible GPU(s) display devices in **GPU Configuration**.
See [Managing GPUs](https://www.truenas.com/docs/scale/scaletutorials/systemsettings/advanced/managegpuscale/) for more information about allocating isolated GPU devices in TrueNAS SCALE.

View file

@ -10,6 +10,12 @@ export interface Item {
release: string;
tag?: string;
date: Date;
dateType: DateType;
}
export enum DateType {
RELEASE = 'Release Date',
DATE = 'Date',
}
interface Props {
@ -50,7 +56,7 @@ export default function Timeline({ items }: Props): JSX.Element {
<div className="z-10 flex items-center bg-immich-primary dark:bg-immich-dark-primary border-2 border-solid rounded-full dark:text-black text-white relative top-[50%] left-[-3px] translate-y-[-50%] translate-x-[-50%] w-8 h-8 shadow-lg ">
<Icon path={mdiCheckboxMarkedCircleOutline} size={1.25} />
</div>
<section className=" dark:bg-immich-dark-gray bg-immich-gray dark:border-0 border-gray-200 border border-solid rounded-2xl flex flex-col w-full gap-2 p-4 ml-4 my-2 hover:bg-immich-primary/10 dark:hover:bg-immich-dark-primary/10 transition-all">
<section className=" dark:bg-immich-dark-gray bg-immich-gray dark:border-0 border-gray-200 border border-solid rounded-2xl flex flex-col w-full gap-2 p-4 md:ml-4 my-2 hover:bg-immich-primary/10 dark:hover:bg-immich-dark-primary/10 transition-all">
<div className="m-0 text-lg flex w-full items-center justify-between gap-2">
<p className="m-0 items-start flex gap-2">
<Icon path={item.icon} size={1} />
@ -67,14 +73,12 @@ export default function Timeline({ items }: Props): JSX.Element {
[{item.release}]{' '}
</a>
) : (
<span>
[{item.release} {isBrowser ? item.date.toLocaleDateString(navigator.language) : ''}]
</span>
<span>[{item.release}]</span>
)}
</span>
</div>
<div className="md:hidden text-xs">
Release Date - {isBrowser ? item.date.toLocaleDateString(navigator.language) : ''}
{`${item.dateType} - ${isBrowser ? item.date.toLocaleDateString(navigator.language) : ''}`}
</div>
<p className="m-0 text-sm text-gray-600 dark:text-gray-300">{item.description}</p>
</section>

View file

@ -4,6 +4,7 @@ import {
mdiAppleIos,
mdiArchiveOutline,
mdiBookSearchOutline,
mdiCakeVariant,
mdiCheckAll,
mdiCheckboxMarked,
mdiCollage,
@ -43,7 +44,7 @@ import {
} from '@mdi/js';
import Layout from '@theme/Layout';
import React from 'react';
import Timeline, { Item } from '../components/timeline';
import Timeline, { DateType, Item } from '../components/timeline';
const items: Item[] = [
{
@ -53,6 +54,7 @@ const items: Item[] = [
release: 'v1.82.0',
tag: 'v1.82.0',
date: new Date(2023, 9, 17),
dateType: DateType.RELEASE,
},
{
icon: mdiBookSearchOutline,
@ -61,6 +63,7 @@ const items: Item[] = [
release: 'v1.79.0',
tag: 'v1.79.0',
date: new Date(2023, 8, 21),
dateType: DateType.RELEASE,
},
{
icon: mdiMap,
@ -69,6 +72,7 @@ const items: Item[] = [
release: 'v1.76.0',
tag: 'v1.76.0',
date: new Date(2023, 7, 29),
dateType: DateType.RELEASE,
},
{
icon: mdiFile,
@ -77,6 +81,7 @@ const items: Item[] = [
release: 'v1.75.0',
tag: 'v1.75.0',
date: new Date(2023, 7, 26),
dateType: DateType.RELEASE,
},
{
icon: mdiMonitor,
@ -85,6 +90,7 @@ const items: Item[] = [
release: 'v1.75.0',
tag: 'v1.75.0',
date: new Date(2023, 7, 26),
dateType: DateType.RELEASE,
},
{
icon: mdiServer,
@ -93,6 +99,7 @@ const items: Item[] = [
release: 'v1.72.0',
tag: 'v1.72.0',
date: new Date(2023, 7, 6),
dateType: DateType.RELEASE,
},
{
icon: mdiImageAlbum,
@ -101,6 +108,7 @@ const items: Item[] = [
release: 'v1.72.0',
tag: 'v1.72.0',
date: new Date(2023, 7, 6),
dateType: DateType.RELEASE,
},
{
icon: mdiImageAlbum,
@ -109,6 +117,7 @@ const items: Item[] = [
release: 'v1.72.0',
tag: 'v1.72.0',
date: new Date(2023, 7, 6),
dateType: DateType.RELEASE,
},
{
icon: mdiRotate360,
@ -117,6 +126,7 @@ const items: Item[] = [
release: 'v1.71.0',
tag: 'v1.71.0',
date: new Date(2023, 6, 29),
dateType: DateType.RELEASE,
},
{
icon: mdiMotionPlayOutline,
@ -125,6 +135,7 @@ const items: Item[] = [
release: 'v1.69.0',
tag: 'v1.69.0',
date: new Date(2023, 6, 23),
dateType: DateType.RELEASE,
},
{
icon: mdiFaceManOutline,
@ -133,6 +144,7 @@ const items: Item[] = [
release: 'v1.68.0',
tag: 'v1.68.0',
date: new Date(2023, 6, 20),
dateType: DateType.RELEASE,
},
{
icon: mdiMerge,
@ -141,6 +153,7 @@ const items: Item[] = [
release: 'v1.67.0',
tag: 'v1.67.0',
date: new Date(2023, 6, 14),
dateType: DateType.RELEASE,
},
{
icon: mdiImage,
@ -149,6 +162,7 @@ const items: Item[] = [
release: 'v1.66.0',
tag: 'v1.66.0',
date: new Date(2023, 6, 4),
dateType: DateType.RELEASE,
},
{
icon: mdiKeyboardSettingsOutline,
@ -157,6 +171,7 @@ const items: Item[] = [
release: 'v1.66.0',
tag: 'v1.66.0',
date: new Date(2023, 6, 4),
dateType: DateType.RELEASE,
},
{
icon: mdiImageMultipleOutline,
@ -165,6 +180,7 @@ const items: Item[] = [
release: 'v1.65.0',
tag: 'v1.65.0',
date: new Date(2023, 5, 30),
dateType: DateType.RELEASE,
},
{
icon: mdiFaceMan,
@ -173,6 +189,7 @@ const items: Item[] = [
release: 'v1.63.0',
tag: 'v1.63.0',
date: new Date(2023, 5, 24),
dateType: DateType.RELEASE,
},
{
icon: mdiImageMultipleOutline,
@ -181,6 +198,7 @@ const items: Item[] = [
release: 'v1.61.0',
tag: 'v1.61.0',
date: new Date(2023, 5, 16),
dateType: DateType.RELEASE,
},
{
icon: mdiCollage,
@ -189,6 +207,7 @@ const items: Item[] = [
release: 'v1.61.0',
tag: 'v1.61.0',
date: new Date(2023, 5, 16),
dateType: DateType.RELEASE,
},
{
icon: mdiRaw,
@ -197,6 +216,7 @@ const items: Item[] = [
release: 'v1.61.0',
tag: 'v1.61.0',
date: new Date(2023, 5, 16),
dateType: DateType.RELEASE,
},
{
icon: mdiShareAll,
@ -205,6 +225,7 @@ const items: Item[] = [
release: 'v1.58.0',
tag: 'v1.58.0',
date: new Date(2023, 4, 28),
dateType: DateType.RELEASE,
},
{
icon: mdiFile,
@ -213,6 +234,7 @@ const items: Item[] = [
release: 'v1.58.0',
tag: 'v1.58.0',
date: new Date(2023, 4, 28),
dateType: DateType.RELEASE,
},
{
icon: mdiFolder,
@ -221,6 +243,7 @@ const items: Item[] = [
release: 'v1.57.0',
tag: 'v1.57.0',
date: new Date(2023, 4, 23),
dateType: DateType.RELEASE,
},
{
icon: mdiShareCircle,
@ -229,6 +252,7 @@ const items: Item[] = [
release: 'v1.56.0',
tag: 'v1.56.0',
date: new Date(2023, 4, 18),
dateType: DateType.RELEASE,
},
{
icon: mdiFaceMan,
@ -237,6 +261,7 @@ const items: Item[] = [
release: 'v1.56.0',
tag: 'v1.56.0',
date: new Date(2023, 4, 18),
dateType: DateType.RELEASE,
},
{
icon: mdiMap,
@ -245,6 +270,7 @@ const items: Item[] = [
release: 'v1.55.0',
tag: 'v1.55.0',
date: new Date(2023, 4, 9),
dateType: DateType.RELEASE,
},
{
icon: mdiDevices,
@ -253,6 +279,7 @@ const items: Item[] = [
release: 'v1.55.0',
tag: 'v1.55.0',
date: new Date(2023, 4, 9),
dateType: DateType.RELEASE,
},
{
icon: mdiStar,
@ -261,6 +288,7 @@ const items: Item[] = [
release: 'v1.54.0',
tag: 'v1.54.0',
date: new Date(2023, 3, 18),
dateType: DateType.RELEASE,
},
{
icon: mdiText,
@ -269,6 +297,7 @@ const items: Item[] = [
release: 'v1.54.0',
tag: 'v1.54.0',
date: new Date(2023, 3, 18),
dateType: DateType.RELEASE,
},
{
icon: mdiArchiveOutline,
@ -277,6 +306,7 @@ const items: Item[] = [
release: 'v1.54.0',
tag: 'v1.54.0',
date: new Date(2023, 3, 18),
dateType: DateType.RELEASE,
},
{
icon: mdiDevices,
@ -285,6 +315,7 @@ const items: Item[] = [
release: 'v1.54.0',
tag: 'v1.54.0',
date: new Date(2023, 3, 18),
dateType: DateType.RELEASE,
},
{
icon: mdiFileSearch,
@ -293,6 +324,7 @@ const items: Item[] = [
release: 'v1.52.0',
tag: 'v1.52.0',
date: new Date(2023, 2, 29),
dateType: DateType.RELEASE,
},
{
icon: mdiImageSearch,
@ -301,6 +333,7 @@ const items: Item[] = [
release: 'v1.51.0',
tag: 'v1.51.0',
date: new Date(2023, 2, 20),
dateType: DateType.RELEASE,
},
{
icon: mdiMagnify,
@ -309,6 +342,7 @@ const items: Item[] = [
release: 'v1.51.0',
tag: 'v1.51.0',
date: new Date(2023, 2, 20),
dateType: DateType.RELEASE,
},
{
icon: mdiAppleIos,
@ -317,6 +351,7 @@ const items: Item[] = [
release: 'v1.48.0',
tag: 'v1.48.0',
date: new Date(2023, 1, 21),
dateType: DateType.RELEASE,
},
{
icon: mdiMotionPlayOutline,
@ -325,6 +360,7 @@ const items: Item[] = [
release: 'v1.48.0',
tag: 'v1.48.0',
date: new Date(2023, 2, 21),
dateType: DateType.RELEASE,
},
{
icon: mdiMaterialDesign,
@ -333,6 +369,7 @@ const items: Item[] = [
release: 'v1.47.0',
tag: 'v1.47.0',
date: new Date(2023, 1, 13),
dateType: DateType.RELEASE,
},
{
icon: mdiHeart,
@ -341,14 +378,16 @@ const items: Item[] = [
release: 'v1.46.0',
tag: 'v1.46.0',
date: new Date(2023, 1, 9),
dateType: DateType.RELEASE,
},
{
icon: mdiPartyPopper,
icon: mdiCakeVariant,
title: 'Immich Turns 1',
description: 'Immich is officially one year old.',
release: 'v1.43.0',
tag: 'v1.43.0',
date: new Date(2023, 0, 27),
date: new Date(2023, 1, 3),
dateType: DateType.DATE,
},
{
icon: mdiHeart,
@ -357,6 +396,7 @@ const items: Item[] = [
release: 'v1.43.0',
tag: 'v1.43.0',
date: new Date(2023, 0, 27),
dateType: DateType.RELEASE,
},
{
icon: mdiShareCircle,
@ -365,6 +405,7 @@ const items: Item[] = [
release: 'v1.41.0',
tag: 'v1.41.1_64-dev',
date: new Date(2023, 0, 10),
dateType: DateType.RELEASE,
},
{
icon: mdiFolder,
@ -373,6 +414,7 @@ const items: Item[] = [
release: 'v1.39.0',
tag: 'v1.39.0_61-dev',
date: new Date(2022, 11, 19),
dateType: DateType.RELEASE,
},
{
icon: mdiMotionPlayOutline,
@ -381,6 +423,7 @@ const items: Item[] = [
release: 'v1.36.0',
tag: 'v1.36.0_55-dev',
date: new Date(2022, 10, 20),
dateType: DateType.RELEASE,
},
{
icon: mdiSecurity,
@ -389,6 +432,7 @@ const items: Item[] = [
release: 'v1.36.0',
tag: 'v1.36.0_55-dev',
date: new Date(2022, 10, 20),
dateType: DateType.RELEASE,
},
{
icon: mdiWeb,
@ -397,6 +441,7 @@ const items: Item[] = [
release: 'v1.33.1',
tag: 'v1.33.0_52-dev',
date: new Date(2022, 9, 26),
dateType: DateType.RELEASE,
},
{
icon: mdiThemeLightDark,
@ -405,6 +450,7 @@ const items: Item[] = [
release: 'v1.32.0',
tag: ' v1.32.0_50-dev',
date: new Date(2022, 9, 14),
dateType: DateType.RELEASE,
},
{
icon: mdiPanVertical,
@ -413,6 +459,7 @@ const items: Item[] = [
release: 'v1.27.0',
tag: 'v1.27.0_37-dev',
date: new Date(2022, 8, 6),
dateType: DateType.RELEASE,
},
{
icon: mdiCheckAll,
@ -421,6 +468,7 @@ const items: Item[] = [
release: 'v1.27.0',
tag: 'v1.27.0_37-dev',
date: new Date(2022, 8, 6),
dateType: DateType.RELEASE,
},
{
icon: mdiAndroid,
@ -429,6 +477,7 @@ const items: Item[] = [
release: 'v1.24.0',
tag: 'v1.24.0_34-dev',
date: new Date(2022, 7, 19),
dateType: DateType.RELEASE,
},
{
icon: mdiAccountGroup,
@ -437,6 +486,7 @@ const items: Item[] = [
release: 'v1.10.0',
tag: 'v1.10.0_15-dev',
date: new Date(2022, 4, 29),
dateType: DateType.RELEASE,
},
{
icon: mdiShareCircle,
@ -445,6 +495,7 @@ const items: Item[] = [
release: 'v1.7.0',
tag: 'v1.7.0_11-dev ',
date: new Date(2022, 3, 24),
dateType: DateType.RELEASE,
},
{
icon: mdiTag,
@ -453,6 +504,7 @@ const items: Item[] = [
release: 'v1.7.0',
tag: 'v1.7.0_11-dev ',
date: new Date(2022, 3, 24),
dateType: DateType.RELEASE,
},
{
icon: mdiImage,
@ -461,6 +513,7 @@ const items: Item[] = [
release: 'v1.3.0',
tag: 'V1.3.0-dev ',
date: new Date(2022, 2, 22),
dateType: DateType.RELEASE,
},
{
icon: mdiCheckboxMarked,
@ -469,6 +522,7 @@ const items: Item[] = [
release: 'v1.2.0',
tag: 'V0.2-dev ',
date: new Date(2022, 1, 8),
dateType: DateType.RELEASE,
},
{
icon: mdiVideo,
@ -477,13 +531,15 @@ const items: Item[] = [
release: 'v1.2.0',
tag: 'v0.2-dev ',
date: new Date(2022, 1, 8),
dateType: DateType.RELEASE,
},
{
icon: mdiPartyPopper,
title: 'First Commit',
description: 'First commit on GitHub, Immich is born.',
release: 'v1.0.0',
date: new Date(2022, 2, 3),
date: new Date(2022, 1, 3),
dateType: DateType.DATE,
},
];
@ -491,17 +547,15 @@ export default function MilestonePage(): JSX.Element {
return (
<Layout title="Milestones" description="History of Immich">
<section className="my-8">
<h1 className="md:text-6xl text-center mb-10 text-immich-primary dark:text-immich-dark-primary">
<h1 className="md:text-6xl text-center mb-10 text-immich-primary dark:text-immich-dark-primary px-2">
Major Milestones
</h1>
<p className="text-center text-xl">
<p className="text-center text-xl px-2">
A list of project achievements and milestones, <br />
by release date.
</p>
<div className="flex row justify-around mt-8">
<div className="flex max-w-full ">
<Timeline items={items} />
</div>
<div className="flex justify-around mt-8 w-full max-w-full">
<Timeline items={items} />
</div>
</section>
</Layout>

View file

@ -131,17 +131,14 @@ class ImmichAppState extends ConsumerState<ImmichApp>
debugPrint("[APP STATE] resumed");
ref.read(appStateProvider.notifier).handleAppResume();
break;
case AppLifecycleState.inactive:
debugPrint("[APP STATE] inactive");
ref.read(appStateProvider.notifier).handleAppInactivity();
break;
case AppLifecycleState.paused:
debugPrint("[APP STATE] paused");
ref.read(appStateProvider.notifier).handleAppPause();
break;
case AppLifecycleState.detached:
debugPrint("[APP STATE] detached");
ref.read(appStateProvider.notifier).handleAppDetached();

View file

@ -38,6 +38,7 @@ import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart' show ThumbnailFormat;
// ignore: must_be_immutable
@ -86,6 +87,8 @@ class GalleryViewerPage extends HookConsumerWidget {
? ref.watch(assetStackStateProvider(currentAsset))
: <Asset>[];
final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[];
// Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
final isFromResponse = currentAsset.id == Isar.autoIncrement;
Asset asset() => stackIndex.value == -1
? currentAsset
@ -752,7 +755,9 @@ class GalleryViewerPage extends HookConsumerWidget {
},
imageProvider: provider,
heroAttributes: PhotoViewHeroAttributes(
tag: a.id + heroOffset,
tag: isFromResponse
? '${a.remoteId}-$heroOffset'
: a.id + heroOffset,
),
filterQuality: FilterQuality.high,
tightMode: true,
@ -769,7 +774,9 @@ class GalleryViewerPage extends HookConsumerWidget {
onDragUpdate: (_, details, __) =>
handleSwipeUpDown(details),
heroAttributes: PhotoViewHeroAttributes(
tag: a.id + heroOffset,
tag: isFromResponse
? '${a.remoteId}-$heroOffset'
: a.id + heroOffset,
),
filterQuality: FilterQuality.high,
maxScale: 1.0,
@ -777,7 +784,10 @@ class GalleryViewerPage extends HookConsumerWidget {
basePosition: Alignment.center,
child: VideoViewerPage(
onPlaying: () => isPlayingVideo.value = true,
onPaused: () => isPlayingVideo.value = false,
onPaused: () =>
WidgetsBinding.instance.addPostFrameCallback(
(_) => isPlayingVideo.value = false,
),
asset: a,
isMotionVideo: isPlayingMotionVideo.value,
placeholder: Image(

View file

@ -5,6 +5,7 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/utils/storage_indicator.dart';
import 'package:isar/isar.dart';
class ThumbnailImage extends StatelessWidget {
final Asset asset;
@ -41,6 +42,8 @@ class ThumbnailImage extends StatelessWidget {
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
final assetContainerColor =
isDarkTheme ? Colors.blueGrey : Theme.of(context).primaryColorLight;
// Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
final isFromResponse = asset.id == Isar.autoIncrement;
Widget buildSelectionIcon(Asset asset) {
if (isSelected) {
@ -129,7 +132,9 @@ class ThumbnailImage extends StatelessWidget {
width: 300,
height: 300,
child: Hero(
tag: asset.id + heroOffset,
tag: isFromResponse
? '${asset.remoteId}-$heroOffset'
: asset.id + heroOffset,
child: ImmichImage(
asset,
useGrayBoxPlaceholder: useGrayBoxPlaceholder,

View file

@ -135,6 +135,7 @@ doc/SystemConfigPasswordLoginDto.md
doc/SystemConfigReverseGeocodingDto.md
doc/SystemConfigStorageTemplateDto.md
doc/SystemConfigTemplateStorageOptionDto.md
doc/SystemConfigThemeDto.md
doc/SystemConfigThumbnailDto.md
doc/SystemConfigTrashDto.md
doc/TagApi.md
@ -302,6 +303,7 @@ lib/model/system_config_password_login_dto.dart
lib/model/system_config_reverse_geocoding_dto.dart
lib/model/system_config_storage_template_dto.dart
lib/model/system_config_template_storage_option_dto.dart
lib/model/system_config_theme_dto.dart
lib/model/system_config_thumbnail_dto.dart
lib/model/system_config_trash_dto.dart
lib/model/tag_response_dto.dart
@ -456,6 +458,7 @@ test/system_config_password_login_dto_test.dart
test/system_config_reverse_geocoding_dto_test.dart
test/system_config_storage_template_dto_test.dart
test/system_config_template_storage_option_dto_test.dart
test/system_config_theme_dto_test.dart
test/system_config_thumbnail_dto_test.dart
test/system_config_trash_dto_test.dart
test/tag_api_test.dart

View file

@ -318,6 +318,7 @@ Class | Method | HTTP request | Description
- [SystemConfigReverseGeocodingDto](doc//SystemConfigReverseGeocodingDto.md)
- [SystemConfigStorageTemplateDto](doc//SystemConfigStorageTemplateDto.md)
- [SystemConfigTemplateStorageOptionDto](doc//SystemConfigTemplateStorageOptionDto.md)
- [SystemConfigThemeDto](doc//SystemConfigThemeDto.md)
- [SystemConfigThumbnailDto](doc//SystemConfigThumbnailDto.md)
- [SystemConfigTrashDto](doc//SystemConfigTrashDto.md)
- [TagResponseDto](doc//TagResponseDto.md)

View file

@ -16,6 +16,7 @@ Name | Type | Description | Notes
**passwordLogin** | [**SystemConfigPasswordLoginDto**](SystemConfigPasswordLoginDto.md) | |
**reverseGeocoding** | [**SystemConfigReverseGeocodingDto**](SystemConfigReverseGeocodingDto.md) | |
**storageTemplate** | [**SystemConfigStorageTemplateDto**](SystemConfigStorageTemplateDto.md) | |
**theme** | [**SystemConfigThemeDto**](SystemConfigThemeDto.md) | |
**thumbnail** | [**SystemConfigThumbnailDto**](SystemConfigThumbnailDto.md) | |
**trash** | [**SystemConfigTrashDto**](SystemConfigTrashDto.md) | |

View file

@ -0,0 +1,15 @@
# openapi.model.SystemConfigThemeDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**customCss** | **String** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View file

@ -163,6 +163,7 @@ part 'model/system_config_password_login_dto.dart';
part 'model/system_config_reverse_geocoding_dto.dart';
part 'model/system_config_storage_template_dto.dart';
part 'model/system_config_template_storage_option_dto.dart';
part 'model/system_config_theme_dto.dart';
part 'model/system_config_thumbnail_dto.dart';
part 'model/system_config_trash_dto.dart';
part 'model/tag_response_dto.dart';

View file

@ -417,6 +417,8 @@ class ApiClient {
return SystemConfigStorageTemplateDto.fromJson(value);
case 'SystemConfigTemplateStorageOptionDto':
return SystemConfigTemplateStorageOptionDto.fromJson(value);
case 'SystemConfigThemeDto':
return SystemConfigThemeDto.fromJson(value);
case 'SystemConfigThumbnailDto':
return SystemConfigThumbnailDto.fromJson(value);
case 'SystemConfigTrashDto':

View file

@ -21,6 +21,7 @@ class SystemConfigDto {
required this.passwordLogin,
required this.reverseGeocoding,
required this.storageTemplate,
required this.theme,
required this.thumbnail,
required this.trash,
});
@ -41,6 +42,8 @@ class SystemConfigDto {
SystemConfigStorageTemplateDto storageTemplate;
SystemConfigThemeDto theme;
SystemConfigThumbnailDto thumbnail;
SystemConfigTrashDto trash;
@ -55,6 +58,7 @@ class SystemConfigDto {
other.passwordLogin == passwordLogin &&
other.reverseGeocoding == reverseGeocoding &&
other.storageTemplate == storageTemplate &&
other.theme == theme &&
other.thumbnail == thumbnail &&
other.trash == trash;
@ -69,11 +73,12 @@ class SystemConfigDto {
(passwordLogin.hashCode) +
(reverseGeocoding.hashCode) +
(storageTemplate.hashCode) +
(theme.hashCode) +
(thumbnail.hashCode) +
(trash.hashCode);
@override
String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, machineLearning=$machineLearning, map=$map, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, storageTemplate=$storageTemplate, thumbnail=$thumbnail, trash=$trash]';
String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, machineLearning=$machineLearning, map=$map, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, storageTemplate=$storageTemplate, theme=$theme, thumbnail=$thumbnail, trash=$trash]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -85,6 +90,7 @@ class SystemConfigDto {
json[r'passwordLogin'] = this.passwordLogin;
json[r'reverseGeocoding'] = this.reverseGeocoding;
json[r'storageTemplate'] = this.storageTemplate;
json[r'theme'] = this.theme;
json[r'thumbnail'] = this.thumbnail;
json[r'trash'] = this.trash;
return json;
@ -106,6 +112,7 @@ class SystemConfigDto {
passwordLogin: SystemConfigPasswordLoginDto.fromJson(json[r'passwordLogin'])!,
reverseGeocoding: SystemConfigReverseGeocodingDto.fromJson(json[r'reverseGeocoding'])!,
storageTemplate: SystemConfigStorageTemplateDto.fromJson(json[r'storageTemplate'])!,
theme: SystemConfigThemeDto.fromJson(json[r'theme'])!,
thumbnail: SystemConfigThumbnailDto.fromJson(json[r'thumbnail'])!,
trash: SystemConfigTrashDto.fromJson(json[r'trash'])!,
);
@ -163,6 +170,7 @@ class SystemConfigDto {
'passwordLogin',
'reverseGeocoding',
'storageTemplate',
'theme',
'thumbnail',
'trash',
};

View file

@ -0,0 +1,98 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SystemConfigThemeDto {
/// Returns a new [SystemConfigThemeDto] instance.
SystemConfigThemeDto({
required this.customCss,
});
String customCss;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigThemeDto &&
other.customCss == customCss;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(customCss.hashCode);
@override
String toString() => 'SystemConfigThemeDto[customCss=$customCss]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'customCss'] = this.customCss;
return json;
}
/// Returns a new [SystemConfigThemeDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SystemConfigThemeDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return SystemConfigThemeDto(
customCss: mapValueOfType<String>(json, r'customCss')!,
);
}
return null;
}
static List<SystemConfigThemeDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SystemConfigThemeDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SystemConfigThemeDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SystemConfigThemeDto> mapFromJson(dynamic json) {
final map = <String, SystemConfigThemeDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SystemConfigThemeDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SystemConfigThemeDto-objects as value to a dart map
static Map<String, List<SystemConfigThemeDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SystemConfigThemeDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SystemConfigThemeDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'customCss',
};
}

View file

@ -56,6 +56,11 @@ void main() {
// TODO
});
// SystemConfigThemeDto theme
test('to test the property `theme`', () async {
// TODO
});
// SystemConfigThumbnailDto thumbnail
test('to test the property `thumbnail`', () async {
// TODO

View file

@ -0,0 +1,27 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for SystemConfigThemeDto
void main() {
// final instance = SystemConfigThemeDto();
group('test SystemConfigThemeDto', () {
// String customCss
test('to test the property `customCss`', () async {
// TODO
});
});
}

View file

@ -8060,6 +8060,9 @@
"storageTemplate": {
"$ref": "#/components/schemas/SystemConfigStorageTemplateDto"
},
"theme": {
"$ref": "#/components/schemas/SystemConfigThemeDto"
},
"thumbnail": {
"$ref": "#/components/schemas/SystemConfigThumbnailDto"
},
@ -8077,7 +8080,8 @@
"storageTemplate",
"job",
"thumbnail",
"trash"
"trash",
"theme"
],
"type": "object"
},
@ -8404,6 +8408,17 @@
],
"type": "object"
},
"SystemConfigThemeDto": {
"properties": {
"customCss": {
"type": "string"
}
},
"required": [
"customCss"
],
"type": "object"
},
"SystemConfigThumbnailDto": {
"properties": {
"colorspace": {

View file

@ -37,8 +37,22 @@ export enum Permission {
PERSON_MERGE = 'person.merge',
}
let instance: AccessCore | null;
export class AccessCore {
constructor(private repository: IAccessRepository) {}
private constructor(private repository: IAccessRepository) {}
static create(repository: IAccessRepository) {
if (!instance) {
instance = new AccessCore(repository);
}
return instance;
}
static reset() {
instance = null;
}
requireUploadAccess(authUser: AuthUserDto | null): AuthUserDto {
if (!authUser || (authUser.isPublicUser && !authUser.isAllowUpload)) {

View file

@ -460,7 +460,7 @@ describe(AlbumService.name, () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(false);
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
await expect(
sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
@ -485,6 +485,7 @@ describe(AlbumService.name, () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' }));
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
{ success: true, id: 'asset-1' },
@ -503,6 +504,7 @@ describe(AlbumService.name, () => {
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser));
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
await expect(
sut.addAssets(authStub.user1, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
@ -529,7 +531,7 @@ describe(AlbumService.name, () => {
accessMock.album.hasSharedLinkAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(false);
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
await expect(
sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
@ -560,7 +562,7 @@ describe(AlbumService.name, () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
accessMock.asset.hasPartnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(false);
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
{ success: true, id: 'asset-1' },
@ -578,7 +580,7 @@ describe(AlbumService.name, () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(true);
albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id']));
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE },
@ -592,6 +594,7 @@ describe(AlbumService.name, () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
{ success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION },
@ -630,7 +633,7 @@ describe(AlbumService.name, () => {
it('should allow the owner to remove assets', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(true);
albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id']));
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: true, id: 'asset-id' },
@ -643,6 +646,7 @@ describe(AlbumService.name, () => {
it('should skip assets not in the album', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.empty));
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: false, id: 'asset-id', error: BulkIdErrorReason.NOT_FOUND },
@ -654,7 +658,7 @@ describe(AlbumService.name, () => {
it('should skip assets without user permission to remove', async () => {
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
albumMock.hasAsset.mockResolvedValue(true);
albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id']));
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{
@ -670,7 +674,7 @@ describe(AlbumService.name, () => {
it('should reset the thumbnail if it is removed', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets));
albumMock.hasAsset.mockResolvedValue(true);
albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id']));
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
{ success: true, id: 'asset-id' },

View file

@ -31,7 +31,7 @@ export class AlbumService {
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
) {
this.access = new AccessCore(accessRepository);
this.access = AccessCore.create(accessRepository);
}
async getCount(authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
@ -152,9 +152,11 @@ export class AlbumService {
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
const existingAssetIds = await this.albumRepository.getAssetIds(id, dto.ids);
const results: BulkIdResponseDto[] = [];
for (const assetId of dto.ids) {
const hasAsset = await this.albumRepository.hasAsset({ albumId: id, assetId });
const hasAsset = existingAssetIds.has(assetId);
if (hasAsset) {
results.push({ id: assetId, success: false, error: BulkIdErrorReason.DUPLICATE });
continue;
@ -187,9 +189,11 @@ export class AlbumService {
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
const existingAssetIds = await this.albumRepository.getAssetIds(id, dto.ids);
const results: BulkIdResponseDto[] = [];
for (const assetId of dto.ids) {
const hasAsset = await this.albumRepository.hasAsset({ albumId: id, assetId });
const hasAsset = existingAssetIds.has(assetId);
if (!hasAsset) {
results.push({ id: assetId, success: false, error: BulkIdErrorReason.NOT_FOUND });
continue;

View file

@ -10,8 +10,6 @@ import {
newCommunicationRepositoryMock,
newCryptoRepositoryMock,
newJobRepositoryMock,
newMoveRepositoryMock,
newPersonRepositoryMock,
newStorageRepositoryMock,
newSystemConfigRepositoryMock,
} from '@test';
@ -25,8 +23,6 @@ import {
ICommunicationRepository,
ICryptoRepository,
IJobRepository,
IMoveRepository,
IPersonRepository,
IStorageRepository,
ISystemConfigRepository,
JobItem,
@ -165,8 +161,6 @@ describe(AssetService.name, () => {
let assetMock: jest.Mocked<IAssetRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let moveMock: jest.Mocked<IMoveRepository>;
let personMock: jest.Mocked<IPersonRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
let communicationMock: jest.Mocked<ICommunicationRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>;
@ -181,21 +175,9 @@ describe(AssetService.name, () => {
communicationMock = newCommunicationRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
configMock = newSystemConfigRepositoryMock();
sut = new AssetService(
accessMock,
assetMock,
cryptoMock,
jobMock,
configMock,
moveMock,
personMock,
storageMock,
communicationMock,
);
sut = new AssetService(accessMock, assetMock, cryptoMock, jobMock, configMock, storageMock, communicationMock);
when(assetMock.getById)
.calledWith(assetStub.livePhotoStillAsset.id)

View file

@ -16,8 +16,6 @@ import {
ICommunicationRepository,
ICryptoRepository,
IJobRepository,
IMoveRepository,
IPersonRepository,
IStorageRepository,
ISystemConfigRepository,
ImmichReadStream,
@ -76,7 +74,6 @@ export class AssetService {
private logger = new Logger(AssetService.name);
private access: AccessCore;
private configCore: SystemConfigCore;
private storageCore: StorageCore;
constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository,
@ -84,14 +81,11 @@ export class AssetService {
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
) {
this.access = new AccessCore(accessRepository);
this.access = AccessCore.create(accessRepository);
this.configCore = SystemConfigCore.create(configRepository);
this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
}
canUploadFile({ authUser, fieldName, file }: UploadRequest): true {
@ -147,9 +141,9 @@ export class AssetService {
getUploadFolder({ authUser, fieldName }: UploadRequest): string {
authUser = this.access.requireUploadAccess(authUser);
let folder = this.storageCore.getFolderLocation(StorageFolder.UPLOAD, authUser.id);
let folder = StorageCore.getFolderLocation(StorageFolder.UPLOAD, authUser.id);
if (fieldName === UploadFieldName.PROFILE_DATA) {
folder = this.storageCore.getFolderLocation(StorageFolder.PROFILE, authUser.id);
folder = StorageCore.getFolderLocation(StorageFolder.PROFILE, authUser.id);
}
this.storageRepository.mkdirSync(folder);

View file

@ -40,7 +40,7 @@ export class AuditService {
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
) {
this.access = new AccessCore(accessRepository);
this.access = AccessCore.create(accessRepository);
}
async handleCleanup(): Promise<boolean> {

View file

@ -75,7 +75,7 @@ export class AuthService {
@Inject(IKeyRepository) private keyRepository: IKeyRepository,
) {
this.configCore = SystemConfigCore.create(configRepository);
this.userCore = new UserCore(userRepository, libraryRepository, cryptoRepository);
this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository);
custom.setHttpOptionsDefaults({ timeout: 30000 });
}

View file

@ -43,7 +43,7 @@ export class LibraryService {
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
) {
this.access = new AccessCore(accessRepository);
this.access = AccessCore.create(accessRepository);
}
async getStatistics(authUser: AuthUserDto, id: string): Promise<LibraryStatsResponseDto> {

View file

@ -44,7 +44,7 @@ export class MediaService {
@Inject(IMoveRepository) moveRepository: IMoveRepository,
) {
this.configCore = SystemConfigCore.create(configRepository);
this.storageCore = new StorageCore(this.storageRepository, assetRepository, moveRepository, personRepository);
this.storageCore = StorageCore.create(assetRepository, moveRepository, personRepository, storageRepository);
}
async handleQueueGenerateThumbnails({ force }: IBaseJob) {
@ -140,7 +140,7 @@ export class MediaService {
const { thumbnail, ffmpeg } = await this.configCore.getConfig();
const size = format === 'jpeg' ? thumbnail.jpegSize : thumbnail.webpSize;
const path =
format === 'jpeg' ? this.storageCore.getLargeThumbnailPath(asset) : this.storageCore.getSmallThumbnailPath(asset);
format === 'jpeg' ? StorageCore.getLargeThumbnailPath(asset) : StorageCore.getSmallThumbnailPath(asset);
this.storageCore.ensureFolders(path);
switch (asset.type) {
@ -220,7 +220,7 @@ export class MediaService {
}
const input = asset.originalPath;
const output = this.storageCore.getEncodedVideoPath(asset);
const output = StorageCore.getEncodedVideoPath(asset);
this.storageCore.ensureFolders(output);
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input);

View file

@ -80,7 +80,7 @@ export class MetadataService {
@Inject(IPersonRepository) personRepository: IPersonRepository,
) {
this.configCore = SystemConfigCore.create(configRepository);
this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
this.storageCore = StorageCore.create(assetRepository, moveRepository, personRepository, storageRepository);
this.configCore.config$.subscribe(() => this.init());
}
@ -294,7 +294,7 @@ export class MetadataService {
});
const checksum = this.cryptoRepository.hashSha1(video);
const motionPath = this.storageCore.getAndroidMotionPath(asset);
const motionPath = StorageCore.getAndroidMotionPath(asset);
this.storageCore.ensureFolders(motionPath);
let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum);

View file

@ -56,9 +56,9 @@ export class PersonService {
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
) {
this.access = new AccessCore(accessRepository);
this.access = AccessCore.create(accessRepository);
this.configCore = SystemConfigCore.create(configRepository);
this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, repository);
this.storageCore = StorageCore.create(assetRepository, moveRepository, repository, storageRepository);
}
async getAll(authUser: AuthUserDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
@ -309,7 +309,7 @@ export class PersonService {
}
this.logger.verbose(`Cropping face for person: ${personId}`);
const thumbnailPath = this.storageCore.getPersonThumbnailPath(person);
const thumbnailPath = StorageCore.getPersonThumbnailPath(person);
this.storageCore.ensureFolders(thumbnailPath);
const halfWidth = (x2 - x1) / 2;

View file

@ -26,6 +26,7 @@ export interface IAlbumRepository {
getByIds(ids: string[]): Promise<AlbumEntity[]>;
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>;
addAssets(assets: AlbumAssets): Promise<void>;
getAssetIds(albumId: string, assetIds?: string[]): Promise<Set<string>>;
hasAsset(asset: AlbumAsset): Promise<boolean>;
removeAsset(assetId: string): Promise<void>;
removeAssets(assets: AlbumAssets): Promise<void>;

View file

@ -16,7 +16,7 @@ export class SharedLinkService {
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(ISharedLinkRepository) private repository: ISharedLinkRepository,
) {
this.access = new AccessCore(accessRepository);
this.access = AccessCore.create(accessRepository);
}
getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {

View file

@ -1,6 +1,7 @@
import { AssetPathType } from '@app/infra/entities';
import {
assetStub,
newAlbumRepositoryMock,
newAssetRepositoryMock,
newMoveRepositoryMock,
newPersonRepositoryMock,
@ -11,6 +12,7 @@ import {
} from '@test';
import { when } from 'jest-when';
import {
IAlbumRepository,
IAssetRepository,
IMoveRepository,
IPersonRepository,
@ -23,6 +25,7 @@ import { StorageTemplateService } from './storage-template.service';
describe(StorageTemplateService.name, () => {
let sut: StorageTemplateService;
let albumMock: jest.Mocked<IAlbumRepository>;
let assetMock: jest.Mocked<IAssetRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>;
let moveMock: jest.Mocked<IMoveRepository>;
@ -36,13 +39,23 @@ describe(StorageTemplateService.name, () => {
beforeEach(async () => {
assetMock = newAssetRepositoryMock();
albumMock = newAlbumRepositoryMock();
configMock = newSystemConfigRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
sut = new StorageTemplateService(assetMock, configMock, defaults, moveMock, personMock, storageMock, userMock);
sut = new StorageTemplateService(
albumMock,
assetMock,
configMock,
defaults,
moveMock,
personMock,
storageMock,
userMock,
);
});
describe('handleMigrationSingle', () => {

View file

@ -7,6 +7,7 @@ import sanitize from 'sanitize-filename';
import { getLivePhotoMotionFilename, usePagination } from '../domain.util';
import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE } from '../job';
import {
IAlbumRepository,
IAssetRepository,
IMoveRepository,
IPersonRepository,
@ -32,14 +33,26 @@ export interface MoveAssetMetadata {
filename: string;
}
interface RenderMetadata {
asset: AssetEntity;
filename: string;
extension: string;
albumName: string | null;
}
@Injectable()
export class StorageTemplateService {
private logger = new Logger(StorageTemplateService.name);
private configCore: SystemConfigCore;
private storageCore: StorageCore;
private storageTemplate: HandlebarsTemplateDelegate<any>;
private template: {
compiled: HandlebarsTemplateDelegate<any>;
raw: string;
needsAlbum: boolean;
};
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig,
@ -48,11 +61,15 @@ export class StorageTemplateService {
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
) {
this.storageTemplate = this.compile(config.storageTemplate.template);
this.template = this.compile(config.storageTemplate.template);
this.configCore = SystemConfigCore.create(configRepository);
this.configCore.addValidator((config) => this.validate(config));
this.configCore.config$.subscribe((config) => this.onConfig(config));
this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
this.configCore.config$.subscribe((config) => {
const template = config.storageTemplate.template;
this.logger.debug(`Received config, compiling storage template: ${template}`);
this.template = this.compile(template);
});
this.storageCore = StorageCore.create(assetRepository, moveRepository, personRepository, storageRepository);
}
async handleMigrationSingle({ id }: IEntityJob) {
@ -99,7 +116,7 @@ export class StorageTemplateService {
}
async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) {
if (asset.isReadOnly || asset.isExternal || this.storageCore.isAndroidMotionPath(asset.originalPath)) {
if (asset.isReadOnly || asset.isExternal || StorageCore.isAndroidMotionPath(asset.originalPath)) {
// External assets are not affected by storage template
// TODO: shouldn't this only apply to external assets?
return;
@ -131,8 +148,20 @@ export class StorageTemplateService {
const source = asset.originalPath;
const ext = path.extname(source).split('.').pop() as string;
const sanitized = sanitize(path.basename(filename, `.${ext}`));
const rootPath = this.storageCore.getLibraryFolder({ id: asset.ownerId, storageLabel });
const storagePath = this.render(this.storageTemplate, asset, sanitized, ext);
const rootPath = StorageCore.getLibraryFolder({ id: asset.ownerId, storageLabel });
let albumName = null;
if (this.template.needsAlbum) {
const albums = await this.albumRepository.getByAssetId(asset.ownerId, asset.id);
albumName = albums?.[0]?.albumName || null;
}
const storagePath = this.render(this.template.compiled, {
asset,
filename: sanitized,
extension: ext,
albumName,
});
const fullPath = path.normalize(path.join(rootPath, storagePath));
let destination = `${fullPath}.${ext}`;
@ -187,39 +216,43 @@ export class StorageTemplateService {
}
private validate(config: SystemConfig) {
const testAsset = {
fileCreatedAt: new Date(),
originalPath: '/upload/test/IMG_123.jpg',
type: AssetType.IMAGE,
id: 'd587e44b-f8c0-4832-9ba3-43268bbf5d4e',
} as AssetEntity;
try {
this.render(this.compile(config.storageTemplate.template), testAsset, 'IMG_123', 'jpg');
const { compiled } = this.compile(config.storageTemplate.template);
this.render(compiled, {
asset: {
fileCreatedAt: new Date(),
originalPath: '/upload/test/IMG_123.jpg',
type: AssetType.IMAGE,
id: 'd587e44b-f8c0-4832-9ba3-43268bbf5d4e',
} as AssetEntity,
filename: 'IMG_123',
extension: 'jpg',
albumName: 'album',
});
} catch (e) {
this.logger.warn(`Storage template validation failed: ${JSON.stringify(e)}`);
throw new Error(`Invalid storage template: ${e}`);
}
}
private onConfig(config: SystemConfig) {
this.logger.debug(`Received new config, recompiling storage template: ${config.storageTemplate.template}`);
this.storageTemplate = this.compile(config.storageTemplate.template);
}
private compile(template: string) {
return handlebar.compile(template, {
knownHelpers: undefined,
strict: true,
});
return {
raw: template,
compiled: handlebar.compile(template, { knownHelpers: undefined, strict: true }),
needsAlbum: template.indexOf('{{album}}') !== -1,
};
}
private render(template: HandlebarsTemplateDelegate<any>, asset: AssetEntity, filename: string, ext: string) {
private render(template: HandlebarsTemplateDelegate<any>, options: RenderMetadata) {
const { filename, extension, asset, albumName } = options;
const substitutions: Record<string, string> = {
filename,
ext,
ext: extension,
filetype: asset.type == AssetType.IMAGE ? 'IMG' : 'VID',
filetypefull: asset.type == AssetType.IMAGE ? 'IMAGE' : 'VIDEO',
assetId: asset.id,
//just throw into the root if it doesn't belong to an album
album: (albumName && sanitize(albumName.replace(/\.+/g, ''))) || '.',
};
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;

View file

@ -21,21 +21,40 @@ export interface MoveRequest {
type GeneratedAssetPath = AssetPathType.JPEG_THUMBNAIL | AssetPathType.WEBP_THUMBNAIL | AssetPathType.ENCODED_VIDEO;
let instance: StorageCore | null;
export class StorageCore {
private logger = new Logger(StorageCore.name);
constructor(
private repository: IStorageRepository,
private constructor(
private assetRepository: IAssetRepository,
private moveRepository: IMoveRepository,
private personRepository: IPersonRepository,
private repository: IStorageRepository,
) {}
getFolderLocation(folder: StorageFolder, userId: string) {
static create(
assetRepository: IAssetRepository,
moveRepository: IMoveRepository,
personRepository: IPersonRepository,
repository: IStorageRepository,
) {
if (!instance) {
instance = new StorageCore(assetRepository, moveRepository, personRepository, repository);
}
return instance;
}
static reset() {
instance = null;
}
static getFolderLocation(folder: StorageFolder, userId: string) {
return join(StorageCore.getBaseFolder(folder), userId);
}
getLibraryFolder(user: { storageLabel: string | null; id: string }) {
static getLibraryFolder(user: { storageLabel: string | null; id: string }) {
return join(StorageCore.getBaseFolder(StorageFolder.LIBRARY), user.storageLabel || user.id);
}
@ -43,27 +62,27 @@ export class StorageCore {
return join(APP_MEDIA_LOCATION, folder);
}
getPersonThumbnailPath(person: PersonEntity) {
return this.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`);
static getPersonThumbnailPath(person: PersonEntity) {
return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`);
}
getLargeThumbnailPath(asset: AssetEntity) {
return this.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.jpeg`);
static getLargeThumbnailPath(asset: AssetEntity) {
return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.jpeg`);
}
getSmallThumbnailPath(asset: AssetEntity) {
return this.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.webp`);
static getSmallThumbnailPath(asset: AssetEntity) {
return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.webp`);
}
getEncodedVideoPath(asset: AssetEntity) {
return this.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.mp4`);
static getEncodedVideoPath(asset: AssetEntity) {
return StorageCore.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.mp4`);
}
getAndroidMotionPath(asset: AssetEntity) {
return this.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}-MP.mp4`);
static getAndroidMotionPath(asset: AssetEntity) {
return StorageCore.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}-MP.mp4`);
}
isAndroidMotionPath(originalPath: string) {
static isAndroidMotionPath(originalPath: string) {
return originalPath.startsWith(StorageCore.getBaseFolder(StorageFolder.ENCODED_VIDEO));
}
@ -75,15 +94,25 @@ export class StorageCore {
const { id: entityId, resizePath, webpPath, encodedVideoPath } = asset;
switch (pathType) {
case AssetPathType.JPEG_THUMBNAIL:
return this.moveFile({ entityId, pathType, oldPath: resizePath, newPath: this.getLargeThumbnailPath(asset) });
return this.moveFile({
entityId,
pathType,
oldPath: resizePath,
newPath: StorageCore.getLargeThumbnailPath(asset),
});
case AssetPathType.WEBP_THUMBNAIL:
return this.moveFile({ entityId, pathType, oldPath: webpPath, newPath: this.getSmallThumbnailPath(asset) });
return this.moveFile({
entityId,
pathType,
oldPath: webpPath,
newPath: StorageCore.getSmallThumbnailPath(asset),
});
case AssetPathType.ENCODED_VIDEO:
return this.moveFile({
entityId,
pathType,
oldPath: encodedVideoPath,
newPath: this.getEncodedVideoPath(asset),
newPath: StorageCore.getEncodedVideoPath(asset),
});
}
}
@ -96,7 +125,7 @@ export class StorageCore {
entityId,
pathType,
oldPath: thumbnailPath,
newPath: this.getPersonThumbnailPath(person),
newPath: StorageCore.getPersonThumbnailPath(person),
});
}
}
@ -159,7 +188,12 @@ export class StorageCore {
}
}
private getNestedPath(folder: StorageFolder, ownerId: string, filename: string): string {
return join(this.getFolderLocation(folder, ownerId), filename.substring(0, 2), filename.substring(2, 4), filename);
private static getNestedPath(folder: StorageFolder, ownerId: string, filename: string): string {
return join(
StorageCore.getFolderLocation(folder, ownerId),
filename.substring(0, 2),
filename.substring(2, 4),
filename,
);
}
}

View file

@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class SystemConfigThemeDto {
@IsString()
customCss!: string;
}

View file

@ -9,6 +9,7 @@ import { SystemConfigOAuthDto } from './system-config-oauth.dto';
import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto';
import { SystemConfigReverseGeocodingDto } from './system-config-reverse-geocoding.dto';
import { SystemConfigStorageTemplateDto } from './system-config-storage-template.dto';
import { SystemConfigThemeDto } from './system-config-theme.dto';
import { SystemConfigThumbnailDto } from './system-config-thumbnail.dto';
import { SystemConfigTrashDto } from './system-config-trash.dto';
@ -62,6 +63,11 @@ export class SystemConfigDto implements SystemConfig {
@ValidateNested()
@IsObject()
trash!: SystemConfigTrashDto;
@Type(() => SystemConfigThemeDto)
@ValidateNested()
@IsObject()
theme!: SystemConfigThemeDto;
}
export function mapConfig(config: SystemConfig): SystemConfigDto {

View file

@ -23,6 +23,7 @@ export const supportedPresetTokens = [
'{{y}}/{{y}}-{{MM}}-{{dd}}/{{assetId}}',
'{{y}}/{{y}}-{{MM}}/{{assetId}}',
'{{y}}/{{y}}-{{WW}}/{{assetId}}',
'{{album}}/{{filename}}',
];
export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG';

View file

@ -114,6 +114,9 @@ export const defaults = Object.freeze<SystemConfig>({
enabled: true,
days: 30,
},
theme: {
customCss: '',
},
});
export enum FeatureFlag {

View file

@ -115,6 +115,9 @@ const updatedConfig = Object.freeze<SystemConfig>({
enabled: true,
days: 10,
},
theme: {
customCss: '',
},
});
describe(SystemConfigService.name, () => {
@ -242,6 +245,7 @@ describe(SystemConfigService.name, () => {
'{{y}}/{{y}}-{{MM}}-{{dd}}/{{assetId}}',
'{{y}}/{{y}}-{{MM}}/{{assetId}}',
'{{y}}/{{y}}-{{WW}}/{{assetId}}',
'{{album}}/{{filename}}',
],
secondOptions: ['s', 'ss'],
weekOptions: ['W', 'WW'],

View file

@ -15,13 +15,31 @@ import { ICryptoRepository, ILibraryRepository, IUserRepository, UserListFilter
const SALT_ROUNDS = 10;
let instance: UserCore | null;
export class UserCore {
constructor(
private userRepository: IUserRepository,
private libraryRepository: ILibraryRepository,
private constructor(
private cryptoRepository: ICryptoRepository,
private libraryRepository: ILibraryRepository,
private userRepository: IUserRepository,
) {}
static create(
cryptoRepository: ICryptoRepository,
libraryRepository: ILibraryRepository,
userRepository: IUserRepository,
) {
if (!instance) {
instance = new UserCore(cryptoRepository, libraryRepository, userRepository);
}
return instance;
}
static reset() {
instance = null;
}
async updateUser(authUser: AuthUserDto, id: string, dto: Partial<UserEntity>): Promise<UserEntity> {
if (!authUser.isAdmin && authUser.id !== id) {
throw new ForbiddenException('You are not allowed to update this user');

View file

@ -11,8 +11,6 @@ import {
newCryptoRepositoryMock,
newJobRepositoryMock,
newLibraryRepositoryMock,
newMoveRepositoryMock,
newPersonRepositoryMock,
newStorageRepositoryMock,
newUserRepositoryMock,
userStub,
@ -26,8 +24,6 @@ import {
ICryptoRepository,
IJobRepository,
ILibraryRepository,
IMoveRepository,
IPersonRepository,
IStorageRepository,
IUserRepository,
} from '../repositories';
@ -139,8 +135,6 @@ describe(UserService.name, () => {
let assetMock: jest.Mocked<IAssetRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let libraryMock: jest.Mocked<ILibraryRepository>;
let moveMock: jest.Mocked<IMoveRepository>;
let personMock: jest.Mocked<IPersonRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
beforeEach(async () => {
@ -149,22 +143,10 @@ describe(UserService.name, () => {
cryptoRepositoryMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
libraryMock = newLibraryRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
sut = new UserService(
albumMock,
assetMock,
cryptoRepositoryMock,
jobMock,
libraryMock,
moveMock,
personMock,
storageMock,
userMock,
);
sut = new UserService(albumMock, assetMock, cryptoRepositoryMock, jobMock, libraryMock, storageMock, userMock);
when(userMock.get).calledWith(adminUser.id).mockResolvedValue(adminUser);
when(userMock.get).calledWith(adminUser.id, undefined).mockResolvedValue(adminUser);

View file

@ -10,8 +10,6 @@ import {
ICryptoRepository,
IJobRepository,
ILibraryRepository,
IMoveRepository,
IPersonRepository,
IStorageRepository,
IUserRepository,
} from '../repositories';
@ -30,7 +28,6 @@ import { UserCore } from './user.core';
@Injectable()
export class UserService {
private logger = new Logger(UserService.name);
private storageCore: StorageCore;
private userCore: UserCore;
constructor(
@ -39,13 +36,10 @@ export class UserService {
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ILibraryRepository) libraryRepository: ILibraryRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
) {
this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
this.userCore = new UserCore(userRepository, libraryRepository, cryptoRepository);
this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository);
}
async getAll(authUser: AuthUserDto, isAll: boolean): Promise<UserResponseDto[]> {
@ -171,11 +165,11 @@ export class UserService {
this.logger.log(`Deleting user: ${user.id}`);
const folders = [
this.storageCore.getLibraryFolder(user),
this.storageCore.getFolderLocation(StorageFolder.UPLOAD, user.id),
this.storageCore.getFolderLocation(StorageFolder.PROFILE, user.id),
this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, user.id),
this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, user.id),
StorageCore.getLibraryFolder(user),
StorageCore.getFolderLocation(StorageFolder.UPLOAD, user.id),
StorageCore.getFolderLocation(StorageFolder.PROFILE, user.id),
StorageCore.getFolderLocation(StorageFolder.THUMBNAILS, user.id),
StorageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, user.id),
];
for (const folder of folders) {

View file

@ -68,7 +68,7 @@ export class AssetService {
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {
this.assetCore = new AssetCore(_assetRepository, jobRepository);
this.access = new AccessCore(accessRepository);
this.access = AccessCore.create(accessRepository);
}
public async uploadFile(

View file

@ -90,6 +90,8 @@ export enum SystemConfigKey {
TRASH_ENABLED = 'trash.enabled',
TRASH_DAYS = 'trash.days',
THEME_CUSTOM_CSS = 'theme.customCss',
}
export enum TranscodePolicy {
@ -221,4 +223,7 @@ export interface SystemConfig {
enabled: boolean;
days: number;
};
theme: {
customCss: string;
};
}

View file

@ -183,6 +183,28 @@ export class AlbumRepository implements IAlbumRepository {
.execute();
}
/**
* Get asset IDs for the given album ID.
*
* @param albumId Album ID to get asset IDs for.
* @param assetIds Optional list of asset IDs to filter on.
* @returns Set of Asset IDs for the given album ID.
*/
async getAssetIds(albumId: string, assetIds?: string[]): Promise<Set<string>> {
const query = this.dataSource
.createQueryBuilder()
.select('albums_assets.assetsId', 'assetId')
.from('albums_assets_assets', 'albums_assets')
.where('"albums_assets"."albumsId" = :albumId', { albumId });
if (assetIds?.length) {
query.andWhere('"albums_assets"."assetsId" IN (:...assetIds)', { assetIds });
}
const result = await query.getRawMany();
return new Set(result.map((row) => row['assetId']));
}
hasAsset(asset: AlbumAsset): Promise<boolean> {
return this.repository.exist({
where: {

View file

@ -1,4 +1,4 @@
import { IAccessRepository } from '@app/domain';
import { AccessCore, IAccessRepository } from '@app/domain';
export interface IAccessRepositoryMock {
asset: jest.Mocked<IAccessRepository['asset']>;
@ -8,7 +8,11 @@ export interface IAccessRepositoryMock {
person: jest.Mocked<IAccessRepository['person']>;
}
export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => {
if (reset) {
AccessCore.reset();
}
return {
asset: {
hasOwnerAccess: jest.fn(),

View file

@ -17,6 +17,7 @@ export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
addAssets: jest.fn(),
removeAsset: jest.fn(),
removeAssets: jest.fn(),
getAssetIds: jest.fn(),
hasAsset: jest.fn(),
create: jest.fn(),
update: jest.fn(),

View file

@ -1,6 +1,10 @@
import { IStorageRepository } from '@app/domain';
import { IStorageRepository, StorageCore } from '@app/domain';
export const newStorageRepositoryMock = (reset = true): jest.Mocked<IStorageRepository> => {
if (reset) {
StorageCore.reset();
}
export const newStorageRepositoryMock = (): jest.Mocked<IStorageRepository> => {
return {
createZipStream: jest.fn(),
createReadStream: jest.fn(),

View file

@ -1,6 +1,10 @@
import { IUserRepository } from '@app/domain';
import { IUserRepository, UserCore } from '@app/domain';
export const newUserRepositoryMock = (reset = true): jest.Mocked<IUserRepository> => {
if (reset) {
UserCore.reset();
}
export const newUserRepositoryMock = (): jest.Mocked<IUserRepository> => {
return {
get: jest.fn(),
getAdmin: jest.fn(),

View file

@ -3307,6 +3307,12 @@ export interface SystemConfigDto {
* @memberof SystemConfigDto
*/
'storageTemplate': SystemConfigStorageTemplateDto;
/**
*
* @type {SystemConfigThemeDto}
* @memberof SystemConfigDto
*/
'theme': SystemConfigThemeDto;
/**
*
* @type {SystemConfigThumbnailDto}
@ -3741,6 +3747,19 @@ export interface SystemConfigTemplateStorageOptionDto {
*/
'yearOptions': Array<string>;
}
/**
*
* @export
* @interface SystemConfigThemeDto
*/
export interface SystemConfigThemeDto {
/**
*
* @type {string}
* @memberof SystemConfigThemeDto
*/
'customCss': string;
}
/**
*
* @export

View file

@ -0,0 +1,53 @@
<script lang="ts">
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
export let value: string;
export let label = '';
export let desc = '';
export let required = false;
export let disabled = false;
export let isEdited = false;
const handleInput = (e: Event) => {
value = (e.target as HTMLInputElement).value;
};
</script>
<div class="mb-4 w-full">
<div class={`flex h-[26px] place-items-center gap-1`}>
<label class={`immich-form-label text-sm`} for={label}>{label}</label>
{#if required}
<div class="text-red-400">*</div>
{/if}
{#if isEdited}
<div
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
class="rounded-full bg-orange-100 px-2 text-[10px] text-orange-900"
>
Unsaved change
</div>
{/if}
</div>
{#if desc}
<p class="immich-form-label pb-2 text-sm" id="{label}-desc">
{desc}
</p>
{:else}
<slot name="desc" />
{/if}
<textarea
class="immich-form-input w-full pb-2"
aria-describedby={desc ? `${label}-desc` : undefined}
aria-labelledby="{label}-label"
id={label}
name={label}
{required}
{value}
on:input={handleInput}
{disabled}
/>
</div>

View file

@ -57,6 +57,7 @@
filetype: 'IMG',
filetypefull: 'IMAGE',
assetId: 'a8312960-e277-447d-b4ea-56717ccba856',
album: 'Album Name',
};
const dt = luxon.DateTime.fromISO(new Date('2022-02-03T04:56:05.250').toISOString());
@ -208,13 +209,26 @@
</div>
</div>
<div id="migration-info" class="mt-4 text-sm">
<p>
Template changes will only apply to new assets. To retroactively apply the template to previously uploaded
assets, run the <a href="/admin/jobs-status" class="text-immich-primary dark:text-immich-dark-primary"
>Storage Migration Job</a
>
</p>
<div id="migration-info" class="mt-2 text-sm">
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">Notes</h3>
<section class="flex flex-col gap-2">
<p>
Template changes will only apply to new assets. To retroactively apply the template to previously
uploaded assets, run the
<a href="/admin/jobs-status" class="text-immich-primary dark:text-immich-dark-primary"
>Storage Migration Job</a
>.
</p>
<p>
The template variable <span class="font-mono">{`{{album}}`}</span> will always be empty for new assets,
so manually running the
<a href="/admin/jobs-status" class="text-immich-primary dark:text-immich-dark-primary"
>Storage Migration Job</a
>
is required in order to successfully use the variable.
</p>
</section>
</div>
<SettingButtonsRow

View file

@ -5,31 +5,25 @@
<div class="p-4 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg">
<div class="flex gap-[50px]">
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">FILE NAME</p>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">FILENAME</p>
<ul>
<li>{`{{filename}}`}</li>
<li>{`{{filename}}`} - IMG_123</li>
<li>{`{{ext}}`} - jpg</li>
</ul>
</div>
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">FILE EXTENSION</p>
<ul>
<li>{`{{ext}}`}</li>
</ul>
</div>
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">FILE TYPE</p>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">FILETYPE</p>
<ul>
<li>{`{{filetype}}`} - VID or IMG</li>
<li>{`{{filetypefull}}`} - VIDEO or IMAGE</li>
</ul>
</div>
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">FILE TYPE</p>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary uppercase">OTHER</p>
<ul>
<li>{`{{assetId}}`} - Asset ID</li>
<li>{`{{album}}`} - Album Name</li>
</ul>
</div>
</div>

View file

@ -0,0 +1,98 @@
<script lang="ts">
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { api, SystemConfigThemeDto } from '@api';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingTextarea from '../setting-textarea.svelte';
export let themeConfig: SystemConfigThemeDto; // this is the config that is being edited
export let disabled = false;
let savedConfig: SystemConfigThemeDto;
let defaultConfig: SystemConfigThemeDto;
async function getConfigs() {
[savedConfig, defaultConfig] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.theme),
api.systemConfigApi.getDefaults().then((res) => res.data.theme),
]);
}
async function saveSetting() {
try {
const { data: current } = await api.systemConfigApi.getConfig();
const { data: updated } = await api.systemConfigApi.updateConfig({
systemConfigDto: {
...current,
theme: themeConfig,
},
});
themeConfig = { ...updated.theme };
savedConfig = { ...updated.theme };
notificationController.show({ message: 'Theme saved', type: NotificationType.Info });
} catch (error) {
handleError(error, 'Unable to save settings');
}
}
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
themeConfig = { ...resetConfig.theme };
savedConfig = { ...resetConfig.theme };
notificationController.show({
message: 'Reset theme to the recent saved theme',
type: NotificationType.Info,
});
}
async function resetToDefault() {
const { data: configs } = await api.systemConfigApi.getDefaults();
themeConfig = { ...configs.theme };
defaultConfig = { ...configs.theme };
notificationController.show({
message: 'Reset theme to default',
type: NotificationType.Info,
});
}
</script>
<div>
{#await getConfigs() then}
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<div class="ml-4">
<SettingTextarea
{disabled}
label="Custom CSS"
desc="Cascading Style Sheets allow the design of Immich to be customized."
bind:value={themeConfig.customCss}
required={true}
isEdited={themeConfig.customCss !== savedConfig.customCss}
/>
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</div>
</div>
</form>
</div>
{/await}
</div>

View file

@ -13,7 +13,6 @@
import type { AssetStore } from '$lib/stores/assets.store';
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
import type { Viewport } from '$lib/stores/assets.store';
import { flip } from 'svelte/animate';
export let assets: AssetResponseDto[];
export let bucketDate: string;
@ -177,7 +176,6 @@
<div
class="absolute"
style="width: {box.width}px; height: {box.height}px; top: {box.top}px; left: {box.left}px"
animate:flip={{ duration: 350 }}
>
<Thumbnail
{asset}

View file

@ -141,25 +141,25 @@
{/await}
</svelte:fragment>
</SideBarButton>
{#if $featureFlags.trash}
<a data-sveltekit-preload-data="hover" href={AppRoute.TRASH} draggable="false">
<SideBarButton title="Trash" logo={TrashCanOutline} isSelected={isTrashSelected}>
<svelte:fragment slot="moreInformation">
{#await getStats({ isTrashed: true })}
<LoadingSpinner />
{:then data}
<div>
<p>{data.videos.toLocaleString($locale)} Videos</p>
<p>{data.images.toLocaleString($locale)} Photos</p>
</div>
{/await}
</svelte:fragment>
</SideBarButton>
</a>
{/if}
</a>
{#if $featureFlags.trash}
<a data-sveltekit-preload-data="hover" href={AppRoute.TRASH} draggable="false">
<SideBarButton title="Trash" logo={TrashCanOutline} isSelected={isTrashSelected}>
<svelte:fragment slot="moreInformation">
{#await getStats({ isTrashed: true })}
<LoadingSpinner />
{:then data}
<div>
<p>{data.videos.toLocaleString($locale)} Videos</p>
<p>{data.images.toLocaleString($locale)} Photos</p>
</div>
{/await}
</svelte:fragment>
</SideBarButton>
</a>
{/if}
<!-- Status Box -->
<div class="mb-6 mt-auto">
<StatusBox />

View file

@ -67,6 +67,7 @@
<svelte:head>
<title>{$page.data.meta?.title || 'Web'} - Immich</title>
<link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/custom.css" />
<meta name="theme-color" content="currentColor" />
<FaviconHeader />
<AppleHeader />

View file

@ -10,6 +10,7 @@
import StorageTemplateSettings from '$lib/components/admin-page/settings/storage-template/storage-template-settings.svelte';
import ThumbnailSettings from '$lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte';
import TrashSettings from '$lib/components/admin-page/settings/trash-settings/trash-settings.svelte';
import ThemeSettings from '$lib/components/admin-page/settings/theme/theme-settings.svelte';
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import { downloadManager } from '$lib/stores/download';
@ -96,6 +97,10 @@
/>
</SettingAccordion>
<SettingAccordion title="Theme Settings" subtitle="Manage customization of the Immich web interface">
<ThemeSettings disabled={$featureFlags.configFile} themeConfig={configs.theme} />
</SettingAccordion>
<SettingAccordion title="Thumbnail Settings" subtitle="Manage the resolution of thumbnail sizes">
<ThumbnailSettings disabled={$featureFlags.configFile} thumbnailConfig={configs.thumbnail} />
</SettingAccordion>

View file

@ -0,0 +1,9 @@
import { RequestHandler, text } from '@sveltejs/kit';
export const GET = (async ({ locals: { api } }) => {
const { customCss } = await api.systemConfigApi.getConfig().then((res) => res.data.theme);
return text(customCss, {
headers: {
'Content-Type': 'text/css',
},
});
}) satisfies RequestHandler;