1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-02-03 01:22:44 +01:00

Compare commits

...

18 commits

Author SHA1 Message Date
Markus
01f2a058a9
Merge 5942d9b52c into 92dff839d0 2025-01-28 08:30:41 -06:00
RiggiG
92dff839d0
fix(web): do not throw error when hash fails ()
change: do not throw error when hash fails
2025-01-28 03:54:56 +00:00
Christian Kündig
fe1e09e51f
fix(server): Allow negative rating (for rejected images) ()
Allow negative rating (for rejected images)
2025-01-27 21:54:29 -06:00
github-actions
f44669447f chore: version v1.125.6 2025-01-28 02:58:27 +00:00
Mert
92412ca2f7
fix(server): person thumbnail generation always being queued ()
* fix person thumbnail generation always being queued

* fix thumbhash comparison

* fix mock
2025-01-27 16:20:18 -06:00
github-actions
64d926581f chore: version v1.125.5 2025-01-27 20:04:50 +00:00
Alex
c139e05170
fix(mobile): locale option causes the datetime filter error out () 2025-01-27 14:02:23 -06:00
Alex
0fe62298e1
fix(server): duplicate detection () 2025-01-27 13:53:59 -06:00
github-actions
e5794e6cfc chore: version v1.125.4 2025-01-27 18:44:12 +00:00
Alex
f6cbc9db06
fix(server): cannot render album page when all assets of an album are in trash ()
* fix(server): cannot render album page when all assets of an album are in trash

* inner join

* add e2e test

* check empty albums too

* render add to album button on empty album

* lint

* count 0 if undefined

* fix album card test

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2025-01-26 21:18:34 -06:00
hely0n
5942d9b52c fix formatting 2025-01-07 19:09:15 +01:00
hely0n
38f903d317 Adjust unit tests 2025-01-07 19:07:07 +01:00
Hely0n
86bf4d1d5c Add new setting to test and config-file 2025-01-06 21:49:32 +01:00
Hely0n
90aaefa2cc preserve HDR if needed 2025-01-06 21:24:02 +01:00
Hely0n
ac50c12173 precise pixelformat check 2025-01-06 20:56:21 +01:00
Hely0n
0e504da241 transcodeHDR Web setting (EN & DE) 2025-01-06 15:54:44 +01:00
Hely0n
71430c3a12 transcodeHDR logic 2025-01-06 15:24:56 +01:00
Hely0n
1e97f71734 transcodeHDR config 2025-01-06 15:24:45 +01:00
45 changed files with 222 additions and 110 deletions

6
cli/package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.2.44",
"version": "2.2.47",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.44",
"version": "2.2.47",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"fast-glob": "^3.3.2",
@ -52,7 +52,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.125.3",
"version": "1.125.6",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View file

@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.44",
"version": "2.2.47",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",

View file

@ -23,6 +23,7 @@ The default configuration looks like this:
"acceptedContainers": ["mov", "ogg", "webm"],
"targetResolution": "720",
"maxBitrate": "0",
"transcodeHDR": true,
"bframes": -1,
"refs": 0,
"gopSize": 0,

View file

@ -1,4 +1,16 @@
[
{
"label": "v1.125.6",
"url": "https://v1.125.6.archive.immich.app"
},
{
"label": "v1.125.5",
"url": "https://v1.125.5.archive.immich.app"
},
{
"label": "v1.125.4",
"url": "https://v1.125.4.archive.immich.app"
},
{
"label": "v1.125.3",
"url": "https://v1.125.3.archive.immich.app"

8
e2e/package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.125.3",
"version": "1.125.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.125.3",
"version": "1.125.6",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@ -45,7 +45,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.2.44",
"version": "2.2.47",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@ -92,7 +92,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.125.3",
"version": "1.125.6",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View file

@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.125.3",
"version": "1.125.6",
"description": "",
"main": "index.js",
"type": "module",

View file

@ -22,82 +22,92 @@ const user1NotShared = 'user1NotShared';
const user2SharedUser = 'user2SharedUser';
const user2SharedLink = 'user2SharedLink';
const user2NotShared = 'user2NotShared';
const user4DeletedAsset = 'user4DeletedAsset';
const user4Empty = 'user4Empty';
describe('/albums', () => {
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user1Asset1: AssetMediaResponseDto;
let user1Asset2: AssetMediaResponseDto;
let user4Asset1: AssetMediaResponseDto;
let user1Albums: AlbumResponseDto[];
let user2: LoginResponseDto;
let user2Albums: AlbumResponseDto[];
let deletedAssetAlbum: AlbumResponseDto;
let user3: LoginResponseDto; // deleted
let user4: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
[user1, user2, user3] = await Promise.all([
[user1, user2, user3, user4] = await Promise.all([
utils.userSetup(admin.accessToken, createUserDto.user1),
utils.userSetup(admin.accessToken, createUserDto.user2),
utils.userSetup(admin.accessToken, createUserDto.user3),
utils.userSetup(admin.accessToken, createUserDto.user4),
]);
[user1Asset1, user1Asset2] = await Promise.all([
[user1Asset1, user1Asset2, user4Asset1] = await Promise.all([
utils.createAsset(user1.accessToken, { isFavorite: true }),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
user1Albums = await Promise.all([
utils.createAlbum(user1.accessToken, {
albumName: user1SharedEditorUser,
albumUsers: [
{ userId: admin.userId, role: AlbumUserRole.Editor },
{ userId: user2.userId, role: AlbumUserRole.Editor },
],
assetIds: [user1Asset1.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1SharedLink,
assetIds: [user1Asset1.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1NotShared,
assetIds: [user1Asset1.id, user1Asset2.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1SharedViewerUser,
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
assetIds: [user1Asset1.id],
[user1Albums, user2Albums, deletedAssetAlbum] = await Promise.all([
Promise.all([
utils.createAlbum(user1.accessToken, {
albumName: user1SharedEditorUser,
albumUsers: [
{ userId: admin.userId, role: AlbumUserRole.Editor },
{ userId: user2.userId, role: AlbumUserRole.Editor },
],
assetIds: [user1Asset1.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1SharedLink,
assetIds: [user1Asset1.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1NotShared,
assetIds: [user1Asset1.id, user1Asset2.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1SharedViewerUser,
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
assetIds: [user1Asset1.id],
}),
]),
Promise.all([
utils.createAlbum(user2.accessToken, {
albumName: user2SharedUser,
albumUsers: [
{ userId: user1.userId, role: AlbumUserRole.Editor },
{ userId: user3.userId, role: AlbumUserRole.Editor },
],
}),
utils.createAlbum(user2.accessToken, { albumName: user2SharedLink }),
utils.createAlbum(user2.accessToken, { albumName: user2NotShared }),
]),
utils.createAlbum(user4.accessToken, { albumName: user4DeletedAsset }),
utils.createAlbum(user4.accessToken, { albumName: user4Empty }),
utils.createAlbum(user3.accessToken, {
albumName: 'Deleted',
albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }],
}),
]);
user2Albums = await Promise.all([
utils.createAlbum(user2.accessToken, {
albumName: user2SharedUser,
albumUsers: [
{ userId: user1.userId, role: AlbumUserRole.Editor },
{ userId: user3.userId, role: AlbumUserRole.Editor },
],
}),
utils.createAlbum(user2.accessToken, { albumName: user2SharedLink }),
utils.createAlbum(user2.accessToken, { albumName: user2NotShared }),
]);
await utils.createAlbum(user3.accessToken, {
albumName: 'Deleted',
albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }],
});
await addAssetsToAlbum(
{ id: user2Albums[0].id, bulkIdsDto: { ids: [user1Asset1.id, user1Asset2.id] } },
{ headers: asBearerAuth(user1.accessToken) },
);
user2Albums[0] = await getAlbumInfo({ id: user2Albums[0].id }, { headers: asBearerAuth(user2.accessToken) });
await Promise.all([
addAssetsToAlbum(
{ id: user2Albums[0].id, bulkIdsDto: { ids: [user1Asset1.id, user1Asset2.id] } },
{ headers: asBearerAuth(user1.accessToken) },
),
addAssetsToAlbum(
{ id: deletedAssetAlbum.id, bulkIdsDto: { ids: [user4Asset1.id] } },
{ headers: asBearerAuth(user4.accessToken) },
),
// add shared link to user1SharedLink album
utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
@ -110,7 +120,11 @@ describe('/albums', () => {
}),
]);
await deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) });
[user2Albums[0]] = await Promise.all([
getAlbumInfo({ id: user2Albums[0].id }, { headers: asBearerAuth(user2.accessToken) }),
deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) }),
utils.deleteAssets(user1.accessToken, [user4Asset1.id]),
]);
});
describe('GET /albums', () => {
@ -287,6 +301,25 @@ describe('/albums', () => {
expect(status).toBe(200);
expect(body).toHaveLength(5);
});
it('should return empty albums and albums where all assets are deleted', async () => {
const { status, body } = await request(app).get('/albums').set('Authorization', `Bearer ${user4.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: user4.userId,
albumName: user4DeletedAsset,
shared: false,
}),
expect.objectContaining({
ownerId: user4.userId,
albumName: user4Empty,
shared: false,
}),
]),
);
});
});
describe('GET /albums/:id', () => {

View file

@ -701,6 +701,20 @@ describe('/asset', () => {
expect(status).toEqual(200);
});
it('should set the negative rating', async () => {
const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ rating: -1 });
expect(body).toMatchObject({
id: user1Assets[0].id,
exifInfo: expect.objectContaining({
rating: -1,
}),
});
expect(status).toEqual(200);
});
it('should reject invalid rating', async () => {
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: null }]) {
const { status, body } = await request(app)

View file

@ -322,8 +322,10 @@
"transcoding_threads_description": "Höhere Werte führen zu einer schnelleren Kodierung, lassen dem Server jedoch weniger Spielraum für die Verarbeitung anderer Aufgaben im aktiven Zustand. Dieser Wert sollte nicht höher sein als die Anzahl der CPU-Kerne. Maximiert die Auslastung, wenn der Wert auf 0 gesetzt wird.",
"transcoding_tone_mapping": "Farbton-Mapping",
"transcoding_tone_mapping_description": "Versucht, das Aussehen von HDR-Videos bei der Konvertierung in SDR beizubehalten. Jeder Algorithmus geht unterschiedliche Kompromisse bei Farbe, Details und Helligkeit ein. Hable bewahrt Details, Mobius bewahrt die Farbe und Reinhard bewahrt die Helligkeit.",
"transcoding_transcode_hdr": "HDR-Videos transkodieren",
"transcoding_transcode_hdr_setting_description": "Erzwinge die Transkodierung von HDR-Videos für optimale Kompatibilität (empfohlen)",
"transcoding_transcode_policy": "Transcodierungsrichtlinie",
"transcoding_transcode_policy_description": "Richtlinie, wann ein Video transkodiert werden soll. HDR-Videos werden immer transkodiert (außer wenn die Transkodierung deaktiviert ist).",
"transcoding_transcode_policy_description": "Richtlinie, wann ein Video transkodiert werden soll. HDR-Videos werden immer transkodiert, es sei denn, diese Option wurde deaktiviert oder die Transkodierung ist insgesamt ausgeschaltet.",
"transcoding_two_pass_encoding": "Two-Pass Codierung",
"transcoding_two_pass_encoding_setting_description": "Führt eine Transkodierung in zwei Durchgängen durch, um besser kodierte Videos zu erzeugen. Wenn die maximale Bitrate aktiviert ist (erforderlich für die Verwendung mit H.264 und HEVC), verwendet dieser Modus einen Bitratenbereich, der auf der maximalen Bitrate basiert, und ignoriert CRF. Für VP9 kann CRF verwendet werden, wenn die maximale Bitrate deaktiviert ist.",
"transcoding_video_codec": "Video-Codec",

View file

@ -322,8 +322,10 @@
"transcoding_threads_description": "Higher values lead to faster encoding, but leave less room for the server to process other tasks while active. This value should not be more than the number of CPU cores. Maximizes utilization if set to 0.",
"transcoding_tone_mapping": "Tone-mapping",
"transcoding_tone_mapping_description": "Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness.",
"transcoding_transcode_hdr": "Transcode HDR Videos",
"transcoding_transcode_hdr_setting_description": "Force transcoding of HDR videos for optimal compatibility (recommended)",
"transcoding_transcode_policy": "Transcode policy",
"transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos will always be transcoded (except if transcoding is disabled).",
"transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos will always be transcoded unless disabled below or transcoding is disabled in general.",
"transcoding_two_pass_encoding": "Two-pass encoding",
"transcoding_two_pass_encoding_setting_description": "Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled.",
"transcoding_video_codec": "Video codec",
@ -1350,4 +1352,4 @@
"yes": "Yes",
"you_dont_have_any_shared_links": "You don't have any shared links",
"zoom_image": "Zoom Image"
}
}

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.125.3"
version = "1.125.6"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"

View file

@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 179,
"android.injected.version.name" => "1.125.3",
"android.injected.version.code" => 182,
"android.injected.version.name" => "1.125.6",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View file

@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release"
lane :release do
increment_version_number(
version_number: "1.125.3"
version_number: "1.125.6"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View file

@ -277,7 +277,6 @@ class SearchPage extends HookConsumerWidget {
fieldEndHintText: 'end_date'.tr(),
initialEntryMode: DatePickerEntryMode.calendar,
keyboardType: TextInputType.text,
locale: context.locale,
);
if (date == null) {

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.125.3+179
version: 1.125.6+182
environment:
sdk: '>=3.3.0 <4.0.0'

View file

@ -7454,7 +7454,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.125.3",
"version": "1.125.6",
"contact": {}
},
"tags": [],
@ -7951,7 +7951,7 @@
},
"rating": {
"maximum": 5,
"minimum": 0,
"minimum": -1,
"type": "number"
}
},
@ -11863,6 +11863,9 @@
}
]
},
"transcodeHDR": {
"type": "boolean"
},
"twoPass": {
"type": "boolean"
}
@ -11888,6 +11891,7 @@
"threads",
"tonemap",
"transcode",
"transcodeHDR",
"twoPass"
],
"type": "object"
@ -12780,7 +12784,7 @@
},
"rating": {
"maximum": 5,
"minimum": 0,
"minimum": -1,
"type": "number"
}
},

View file

@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.125.3",
"version": "1.125.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.125.3",
"version": "1.125.6",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View file

@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.125.3",
"version": "1.125.6",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View file

@ -1,6 +1,6 @@
/**
* Immich
* 1.125.3
* 1.125.6
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/

View file

@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.125.3",
"version": "1.125.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.125.3",
"version": "1.125.6",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@nestjs/bullmq": "^11.0.0",

View file

@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.125.3",
"version": "1.125.6",
"description": "",
"author": "",
"private": true,

View file

@ -33,6 +33,7 @@ export interface SystemConfig {
acceptedContainers: VideoContainer[];
targetResolution: string;
maxBitrate: string;
transcodeHDR: boolean;
bframes: number;
refs: number;
gopSize: number;
@ -182,6 +183,7 @@ export const defaults = Object.freeze<SystemConfig>({
acceptedContainers: [VideoContainer.MOV, VideoContainer.OGG, VideoContainer.WEBM],
targetResolution: '720',
maxBitrate: '0',
transcodeHDR: true,
bframes: -1,
refs: 0,
gopSize: 0,

View file

@ -52,7 +52,7 @@ export class UpdateAssetBase {
@Optional()
@IsInt()
@Max(5)
@Min(0)
@Min(-1)
rating?: number;
}

View file

@ -106,6 +106,9 @@ export class SystemConfigFFmpegDto {
@IsString()
maxBitrate!: string;
@ValidateBoolean()
transcodeHDR!: boolean;
@IsInt()
@Min(-1)
@Max(16)

View file

@ -193,7 +193,7 @@ export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
return qb
.leftJoin('smart_search', 'assets.id', 'smart_search.assetId')
.select(sql<number[]>`smart_search.embedding`.as('embedding'));
.select((eb) => eb.fn.toJson(eb.table('smart_search')).as('smartSearch'));
}
export function withFaces(eb: ExpressionBuilder<DB, 'assets'>) {

View file

@ -207,8 +207,8 @@ select
count("assets"."id")::int as "assetCount"
from
"albums"
left join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id"
left join "assets" on "assets"."id" = "album_assets"."assetsId"
inner join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id"
inner join "assets" on "assets"."id" = "album_assets"."assetsId"
where
"albums"."id" in ($1)
and "assets"."deletedAt" is null

View file

@ -124,8 +124,8 @@ export class AlbumRepository implements IAlbumRepository {
return this.db
.selectFrom('albums')
.leftJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id')
.leftJoin('assets', 'assets.id', 'album_assets.assetsId')
.innerJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id')
.innerJoin('assets', 'assets.id', 'album_assets.assetsId')
.select('albums.id as albumId')
.select((eb) => eb.fn.min('assets.fileCreatedAt').as('startDate'))
.select((eb) => eb.fn.max('assets.fileCreatedAt').as('endDate'))

View file

@ -495,7 +495,6 @@ export class AssetRepository implements IAssetRepository {
.$if(property === WithoutProperty.THUMBNAIL, (qb) =>
qb
.innerJoin('asset_job_status as job_status', 'assetId', 'assets.id')
.select(withFiles)
.where('assets.isVisible', '=', true)
.where((eb) =>
eb.or([

View file

@ -100,7 +100,6 @@ export class PersonRepository implements IPersonRepository {
.$if(!!options.personId, (qb) => qb.where('asset_faces.personId', '=', options.personId!))
.$if(!!options.sourceType, (qb) => qb.where('asset_faces.sourceType', '=', options.sourceType!))
.$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!))
.$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!))
.stream() as AsyncIterableIterator<AssetFaceEntity>;
}
@ -109,7 +108,7 @@ export class PersonRepository implements IPersonRepository {
.selectFrom('person')
.selectAll('person')
.$if(!!options.ownerId, (qb) => qb.where('person.ownerId', '=', options.ownerId!))
.$if(!!options.thumbnailPath, (qb) => qb.where('person.thumbnailPath', '=', options.thumbnailPath!))
.$if(options.thumbnailPath !== undefined, (qb) => qb.where('person.thumbnailPath', '=', options.thumbnailPath!))
.$if(options.faceAssetId === null, (qb) => qb.where('person.faceAssetId', 'is', null))
.$if(!!options.faceAssetId, (qb) => qb.where('person.faceAssetId', '=', options.faceAssetId!))
.$if(options.isHidden !== undefined, (qb) => qb.where('person.isHidden', '=', options.isHidden!))

View file

@ -64,9 +64,9 @@ export class AlbumService extends BaseService {
return {
...mapAlbumWithoutAssets(album),
sharedLinks: undefined,
startDate: albumMetadata[album.id].startDate ?? undefined,
endDate: albumMetadata[album.id].endDate ?? undefined,
assetCount: albumMetadata[album.id].assetCount,
startDate: albumMetadata[album.id]?.startDate ?? undefined,
endDate: albumMetadata[album.id]?.endDate ?? undefined,
assetCount: albumMetadata[album.id]?.assetCount ?? 0,
lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt,
};
}),
@ -83,9 +83,9 @@ export class AlbumService extends BaseService {
return {
...mapAlbum(album, withAssets, auth),
startDate: albumMetadataForIds.startDate ?? undefined,
endDate: albumMetadataForIds.endDate ?? undefined,
assetCount: albumMetadataForIds.assetCount,
startDate: albumMetadataForIds?.startDate ?? undefined,
endDate: albumMetadataForIds?.endDate ?? undefined,
assetCount: albumMetadataForIds?.assetCount ?? 0,
lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt,
};
}

View file

@ -2313,9 +2313,9 @@ describe(MediaService.name, () => {
);
});
it('should tonemap when policy is required and video is hdr', async () => {
it('should tonemap when policy is required, video is hdr and transcodeHDR is enabled', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } });
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED, transcodeHDR: true } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -2333,9 +2333,9 @@ describe(MediaService.name, () => {
);
});
it('should tonemap when policy is optimal and video is hdr', async () => {
it('should tonemap when policy is optimal, video is hdr and transcodeHDR is enabled', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } });
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL, transcodeHDR: true } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
@ -2353,9 +2353,9 @@ describe(MediaService.name, () => {
);
});
it('should transcode when policy is required and video is not yuv420p', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit);
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } });
it('should transcode when policy is required and pixelformat is not supported', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamYuv444p);
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED, transcodeHDR: true } });
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(

View file

@ -194,7 +194,7 @@ export class MediaService extends BaseService {
await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path)));
}
if (asset.thumbhash != generated.thumbhash) {
if (!asset.thumbhash || Buffer.compare(asset.thumbhash, generated.thumbhash) !== 0) {
await this.assetRepository.update({ id: asset.id, thumbhash: generated.thumbhash });
}
@ -432,9 +432,12 @@ export class MediaService extends BaseService {
const targetRes = Number.parseInt(ffmpegConfig.targetResolution);
const isLargerThanTargetRes = scalingEnabled && Math.min(stream.height, stream.width) > targetRes;
const isLargerThanTargetBitrate = stream.bitrate > this.parseBitrateToBps(ffmpegConfig.maxBitrate);
const supportedPixelFormats: string[] = ['yuv420p', 'yuvj420p', 'yuva420p', 'yuv420p10le'];
const isTargetVideoCodec = ffmpegConfig.acceptedVideoCodecs.includes(stream.codecName as VideoCodec);
const isRequired = !isTargetVideoCodec || !stream.pixelFormat.endsWith('420p');
const isTargetDynamicRange = !ffmpegConfig.transcodeHDR || !stream.isHDR;
const isRequired =
!isTargetVideoCodec || !isTargetDynamicRange || !supportedPixelFormats.includes(stream.pixelFormat);
switch (ffmpegConfig.transcode) {
case TranscodePolicy.DISABLED: {

View file

@ -1162,6 +1162,17 @@ describe(MetadataService.name, () => {
}),
);
});
it('should handle valid negative rating value', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
mockReadTags({ Rating: -1 });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(assetMock.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({
rating: -1,
}),
);
});
});
describe('handleQueueSidecar', () => {

View file

@ -204,7 +204,7 @@ export class MetadataService extends BaseService {
// comments
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
profileDescription: exifTags.ProfileDescription || null,
rating: validateRange(exifTags.Rating, 0, 5),
rating: validateRange(exifTags.Rating, -1, 5),
// grouping
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,

View file

@ -60,6 +60,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
acceptedVideoCodecs: [VideoCodec.H264],
acceptedContainers: [VideoContainer.MOV, VideoContainer.OGG, VideoContainer.WEBM],
maxBitrate: '0',
transcodeHDR: true,
bframes: -1,
refs: 0,
gopSize: 0,

View file

@ -159,9 +159,13 @@ export class BaseConfig implements VideoCodecSWConfig {
options.push(`scale=${this.getScaling(videoStream)}`);
}
options.push(...this.getToneMapping(videoStream));
if (options.length === 0 && !videoStream.pixelFormat.endsWith('420p')) {
options.push(`format=yuv420p`);
if (!this.config.transcodeHDR && videoStream.isHDR) {
options.push(`format=yuv420p10le`);
} else {
options.push(...this.getToneMapping(videoStream));
if (options.length === 0 && !videoStream.pixelFormat.endsWith('420p')) {
options.push(`format=yuv420p`);
}
}
return options;

View file

@ -182,6 +182,22 @@ export const probeStub = {
},
],
}),
videoStreamYuv444p: Object.freeze<VideoInfo>({
...probeStubDefault,
videoStreams: [
{
index: 0,
height: 480,
width: 480,
codecName: 'h264',
frameCount: 100,
rotation: 0,
isHDR: false,
bitrate: 0,
pixelFormat: 'yuv444p',
},
],
}),
audioStreamAac: Object.freeze<VideoInfo>({
...probeStubDefault,
audioStreams: [{ index: 1, codecName: 'aac', frameCount: 100 }],

View file

@ -4,7 +4,7 @@ import { Mocked, vitest } from 'vitest';
export const newMediaRepositoryMock = (): Mocked<IMediaRepository> => {
return {
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
generateThumbhash: vitest.fn().mockImplementation(() => Promise.resolve()),
generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')),
decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }),
extract: vitest.fn().mockResolvedValue(false),
probe: vitest.fn(),

6
web/package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "immich-web",
"version": "1.125.3",
"version": "1.125.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.125.3",
"version": "1.125.6",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
@ -75,7 +75,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.125.3",
"version": "1.125.6",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View file

@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.125.3",
"version": "1.125.6",
"license": "GNU Affero General Public License version 3",
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000",

View file

@ -151,6 +151,14 @@
sortBy(savedConfig.ffmpeg.acceptedContainers),
)}
/>
<SettingSwitch
title={$t('admin.transcoding_transcode_hdr')}
{disabled}
subtitle={$t('admin.transcoding_transcode_hdr_setting_description')}
bind:checked={config.ffmpeg.transcodeHDR}
isEdited={config.ffmpeg.transcodeHDR !== savedConfig.ffmpeg.transcodeHDR}
/>
</div>
</SettingAccordion>

View file

@ -157,7 +157,6 @@ async function fileUploader(
}
} catch (error) {
console.error(`Error calculating sha1 file=${assetFile.name})`, error);
throw error;
}
}