mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' into feat/hero_view_transitions
This commit is contained in:
@@ -1 +0,0 @@
|
||||
24.15.0
|
||||
+13
-2
@@ -55,12 +55,23 @@ run = [
|
||||
env._.path = "./node_modules/.bin"
|
||||
run = "email dev -p 3050 --dir src/emails"
|
||||
|
||||
[tasks.checklist]
|
||||
[tasks.ci-unit]
|
||||
run = [
|
||||
{ task = ":install" },
|
||||
{ task = ":format" },
|
||||
{ task = ":lint" },
|
||||
{ task = ":check" },
|
||||
{ task = ":test-medium --run" },
|
||||
{ task = ":test --run" },
|
||||
]
|
||||
|
||||
[tasks.ci-medium]
|
||||
run = [
|
||||
{ task = ":install" },
|
||||
{ task = ":test-medium --run" },
|
||||
]
|
||||
|
||||
[tasks.checklist]
|
||||
run = [
|
||||
{ task = ":ci-unit" },
|
||||
{ task = ":ci-medium" },
|
||||
]
|
||||
|
||||
+3
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.7.5",
|
||||
"version": "3.0.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -72,7 +72,7 @@
|
||||
"cookie": "^1.0.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cron": "4.4.0",
|
||||
"exiftool-vendored": "^35.0.0",
|
||||
"exiftool-vendored": "^35.20.0",
|
||||
"express": "^5.1.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
@@ -93,7 +93,7 @@
|
||||
"nest-commander": "^3.16.0",
|
||||
"nestjs-cls": "^6.0.0",
|
||||
"nestjs-kysely": "3.1.2",
|
||||
"nestjs-otel": "^7.0.0",
|
||||
"nestjs-otel": "^8.0.0",
|
||||
"nestjs-zod": "^5.3.0",
|
||||
"nodemailer": "^8.0.0",
|
||||
"openid-client": "^6.3.3",
|
||||
@@ -167,9 +167,6 @@
|
||||
"vite-tsconfig-paths": "^6.0.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.15.0"
|
||||
},
|
||||
"overrides": {
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
|
||||
@@ -25,11 +25,11 @@ describe(AlbumController.name, () => {
|
||||
});
|
||||
|
||||
it('should reject an invalid shared param', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/albums?shared=invalid');
|
||||
const { status, body } = await request(ctx.getHttpServer()).get('/albums?isShared=invalid');
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.validationError([
|
||||
{ path: ['shared'], message: 'Invalid option: expected one of "true"|"false"' },
|
||||
{ path: ['isShared'], message: 'Invalid option: expected one of "true"|"false"' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import { MoveRepository } from 'src/repositories/move.repository';
|
||||
import { PersonRepository } from 'src/repositories/person.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { VideoInterfaces } from 'src/types';
|
||||
import { getAssetFile } from 'src/utils/asset.util';
|
||||
import { getConfig } from 'src/utils/config';
|
||||
|
||||
@@ -299,6 +300,11 @@ export class StorageCore {
|
||||
return this.storageRepository.removeEmptyDirs(StorageCore.getBaseFolder(folder));
|
||||
}
|
||||
|
||||
async getVideoInterfaces(): Promise<VideoInterfaces> {
|
||||
const [dri, mali] = await Promise.all([this.getDevices(), this.hasMaliOpenCL()]);
|
||||
return { dri, mali };
|
||||
}
|
||||
|
||||
private savePath(pathType: PathType, id: string, newPath: string) {
|
||||
switch (pathType) {
|
||||
case AssetPathType.Original: {
|
||||
@@ -330,4 +336,26 @@ export class StorageCore {
|
||||
static getTempPathInDir(dir: string): string {
|
||||
return join(dir, `${randomUUID()}.tmp`);
|
||||
}
|
||||
|
||||
private async getDevices() {
|
||||
try {
|
||||
return await this.storageRepository.readdir('/dev/dri');
|
||||
} catch {
|
||||
this.logger.debug('No devices found in /dev/dri.');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async hasMaliOpenCL() {
|
||||
try {
|
||||
const [maliIcdStat, maliDeviceStat] = await Promise.all([
|
||||
this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd'),
|
||||
this.storageRepository.stat('/dev/mali0'),
|
||||
]);
|
||||
return maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
|
||||
} catch {
|
||||
this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU tonemapping');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,6 +395,26 @@ export const columns = {
|
||||
'asset.height',
|
||||
'asset.isEdited',
|
||||
],
|
||||
syncPartnerAsset: [
|
||||
'asset.id',
|
||||
'asset.ownerId',
|
||||
'asset.originalFileName',
|
||||
'asset.thumbhash',
|
||||
'asset.checksum',
|
||||
'asset.fileCreatedAt',
|
||||
'asset.fileModifiedAt',
|
||||
'asset.localDateTime',
|
||||
'asset.type',
|
||||
'asset.deletedAt',
|
||||
'asset.visibility',
|
||||
'asset.duration',
|
||||
'asset.livePhotoVideoId',
|
||||
'asset.stackId',
|
||||
'asset.libraryId',
|
||||
'asset.width',
|
||||
'asset.height',
|
||||
'asset.isEdited',
|
||||
],
|
||||
syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'],
|
||||
syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],
|
||||
syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'],
|
||||
|
||||
@@ -65,10 +65,13 @@ const UpdateAlbumSchema = z
|
||||
|
||||
const GetAlbumsSchema = z
|
||||
.object({
|
||||
shared: stringToBool
|
||||
isOwned: stringToBool
|
||||
.optional()
|
||||
.describe('Filter by shared status: true = only shared, false = not shared, undefined = all owned albums'),
|
||||
assetId: z.uuidv4().optional().describe('Filter albums containing this asset ID (ignores shared parameter)'),
|
||||
.describe('Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter'),
|
||||
isShared: stringToBool
|
||||
.optional()
|
||||
.describe('Filter by shared status: true = only shared, false = not shared, undefined = no filter'),
|
||||
assetId: z.uuidv4().optional().describe('Filter albums containing this asset ID (ignores other parameters)'),
|
||||
})
|
||||
.meta({ id: 'GetAlbumsDto' });
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ where
|
||||
group by
|
||||
"album_asset"."albumId"
|
||||
|
||||
-- AlbumRepository.getOwned
|
||||
-- AlbumRepository.getAll
|
||||
select
|
||||
"album".*,
|
||||
(
|
||||
@@ -242,172 +242,55 @@ from
|
||||
"album"
|
||||
inner join "album_user" on "album_user"."albumId" = "album"."id"
|
||||
and "album_user"."userId" = $2
|
||||
and "album_user"."role" = 'owner'
|
||||
where
|
||||
"album"."deletedAt" is null
|
||||
order by
|
||||
"album"."createdAt" desc
|
||||
|
||||
-- AlbumRepository.getShared
|
||||
select
|
||||
"album".*,
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"album_user"."role",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
(
|
||||
select
|
||||
1
|
||||
) as "dummy"
|
||||
) as obj
|
||||
) as "user"
|
||||
from
|
||||
"album_user"
|
||||
inner join "user" on "user"."id" = "album_user"."userId"
|
||||
where
|
||||
"album_user"."albumId" = "album"."id"
|
||||
order by
|
||||
"album_user"."role",
|
||||
"album_user"."userId" = $1 desc,
|
||||
"user"."name" asc
|
||||
) as agg
|
||||
) as "albumUsers",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"shared_link".*
|
||||
from
|
||||
"shared_link"
|
||||
where
|
||||
"shared_link"."albumId" = "album"."id"
|
||||
) as agg
|
||||
) as "sharedLinks"
|
||||
from
|
||||
"album"
|
||||
inner join (
|
||||
select
|
||||
"album_user"."albumId" as "id"
|
||||
from
|
||||
"album_user"
|
||||
where
|
||||
"album_user"."userId" = $2
|
||||
and "album_user"."albumId" in (
|
||||
select
|
||||
"album_user"."albumId"
|
||||
from
|
||||
"album_user"
|
||||
where
|
||||
"album_user"."role" != 'owner'
|
||||
)
|
||||
union
|
||||
select
|
||||
"shared_link"."albumId" as "id"
|
||||
from
|
||||
"shared_link"
|
||||
where
|
||||
"shared_link"."userId" = $3
|
||||
and "shared_link"."albumId" is not null
|
||||
) as "matching" on "matching"."id" = "album"."id"
|
||||
inner join "album_user" on "album_user"."albumId" = "album"."id"
|
||||
and "album_user"."role" = 'owner'
|
||||
where
|
||||
"album"."deletedAt" is null
|
||||
order by
|
||||
"album"."createdAt" desc
|
||||
|
||||
-- AlbumRepository.getNotShared
|
||||
select
|
||||
"album".*,
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"shared_link".*
|
||||
from
|
||||
"shared_link"
|
||||
where
|
||||
"shared_link"."albumId" = "album"."id"
|
||||
) as agg
|
||||
) as "sharedLinks",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"album_user"."role",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
from
|
||||
(
|
||||
select
|
||||
1
|
||||
) as "dummy"
|
||||
) as obj
|
||||
) as "user"
|
||||
from
|
||||
"album_user"
|
||||
inner join "user" on "user"."id" = "album_user"."userId"
|
||||
where
|
||||
"album_user"."albumId" = "album"."id"
|
||||
order by
|
||||
"album_user"."role",
|
||||
"album_user"."userId" = $1 desc,
|
||||
"user"."name" asc
|
||||
) as agg
|
||||
) as "albumUsers"
|
||||
from
|
||||
"album"
|
||||
inner join "album_user" on "album_user"."albumId" = "album"."id"
|
||||
and "album_user"."userId" = $2
|
||||
and "album_user"."role" = 'owner'
|
||||
where
|
||||
"album"."deletedAt" is null
|
||||
and not exists (
|
||||
select
|
||||
from
|
||||
"album_user" as "au"
|
||||
where
|
||||
"au"."albumId" = "album"."id"
|
||||
and "au"."role" != 'owner'
|
||||
and (
|
||||
exists (
|
||||
select
|
||||
from
|
||||
"album_user" as "au"
|
||||
where
|
||||
"au"."albumId" = "album"."id"
|
||||
and "au"."role" != 'owner'
|
||||
)
|
||||
or exists (
|
||||
select
|
||||
from
|
||||
"shared_link"
|
||||
where
|
||||
"shared_link"."albumId" = "album"."id"
|
||||
)
|
||||
)
|
||||
and not exists (
|
||||
select
|
||||
from
|
||||
"shared_link"
|
||||
where
|
||||
"shared_link"."albumId" = "album"."id"
|
||||
order by
|
||||
"album"."createdAt" desc
|
||||
|
||||
-- AlbumRepository.getAllIds
|
||||
select
|
||||
"album"."id"
|
||||
from
|
||||
"album"
|
||||
inner join "album_user" on "album_user"."albumId" = "album"."id"
|
||||
and "album_user"."userId" = $1
|
||||
where
|
||||
"album"."deletedAt" is null
|
||||
and "album_user"."role" = 'owner'
|
||||
and (
|
||||
exists (
|
||||
select
|
||||
from
|
||||
"album_user" as "au"
|
||||
where
|
||||
"au"."albumId" = "album"."id"
|
||||
and "au"."role" != 'owner'
|
||||
)
|
||||
or exists (
|
||||
select
|
||||
from
|
||||
"shared_link"
|
||||
where
|
||||
"shared_link"."albumId" = "album"."id"
|
||||
)
|
||||
)
|
||||
order by
|
||||
"album"."createdAt" desc
|
||||
|
||||
@@ -42,6 +42,16 @@ select
|
||||
"memory_asset"."memoriesId" = "memory"."id"
|
||||
and "asset"."visibility" = 'timeline'
|
||||
and "asset"."deletedAt" is null
|
||||
and not exists (
|
||||
select
|
||||
$1 as "one"
|
||||
from
|
||||
"asset_face"
|
||||
inner join "person" on "person"."id" = "asset_face"."personId"
|
||||
where
|
||||
"asset_face"."assetId" = "asset"."id"
|
||||
and "person"."isHidden" = $2
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" asc
|
||||
) as agg
|
||||
@@ -51,7 +61,7 @@ from
|
||||
"memory"
|
||||
where
|
||||
"deletedAt" is null
|
||||
and "ownerId" = $1
|
||||
and "ownerId" = $3
|
||||
order by
|
||||
"memoryAt" desc
|
||||
|
||||
@@ -71,6 +81,16 @@ select
|
||||
"memory_asset"."memoriesId" = "memory"."id"
|
||||
and "asset"."visibility" = 'timeline'
|
||||
and "asset"."deletedAt" is null
|
||||
and not exists (
|
||||
select
|
||||
$1 as "one"
|
||||
from
|
||||
"asset_face"
|
||||
inner join "person" on "person"."id" = "asset_face"."personId"
|
||||
where
|
||||
"asset_face"."assetId" = "asset"."id"
|
||||
and "person"."isHidden" = $2
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" asc
|
||||
) as agg
|
||||
@@ -81,14 +101,14 @@ from
|
||||
where
|
||||
(
|
||||
"showAt" is null
|
||||
or "showAt" <= $1
|
||||
or "showAt" <= $3
|
||||
)
|
||||
and (
|
||||
"hideAt" is null
|
||||
or "hideAt" >= $2
|
||||
or "hideAt" >= $4
|
||||
)
|
||||
and "deletedAt" is null
|
||||
and "ownerId" = $3
|
||||
and "ownerId" = $5
|
||||
order by
|
||||
"memoryAt" desc
|
||||
|
||||
|
||||
@@ -739,7 +739,6 @@ select
|
||||
"asset"."localDateTime",
|
||||
"asset"."type",
|
||||
"asset"."deletedAt",
|
||||
"asset"."isFavorite",
|
||||
"asset"."visibility",
|
||||
"asset"."duration",
|
||||
"asset"."livePhotoVideoId",
|
||||
@@ -748,14 +747,15 @@ select
|
||||
"asset"."width",
|
||||
"asset"."height",
|
||||
"asset"."isEdited",
|
||||
$1 as "isFavorite",
|
||||
"asset"."updateId"
|
||||
from
|
||||
"asset" as "asset"
|
||||
where
|
||||
"asset"."updateId" < $1
|
||||
and "asset"."updateId" <= $2
|
||||
and "asset"."updateId" >= $3
|
||||
and "ownerId" = $4
|
||||
"asset"."updateId" < $2
|
||||
and "asset"."updateId" <= $3
|
||||
and "asset"."updateId" >= $4
|
||||
and "ownerId" = $5
|
||||
order by
|
||||
"asset"."updateId" asc
|
||||
|
||||
@@ -791,7 +791,6 @@ select
|
||||
"asset"."localDateTime",
|
||||
"asset"."type",
|
||||
"asset"."deletedAt",
|
||||
"asset"."isFavorite",
|
||||
"asset"."visibility",
|
||||
"asset"."duration",
|
||||
"asset"."livePhotoVideoId",
|
||||
@@ -800,19 +799,20 @@ select
|
||||
"asset"."width",
|
||||
"asset"."height",
|
||||
"asset"."isEdited",
|
||||
$1 as "isFavorite",
|
||||
"asset"."updateId"
|
||||
from
|
||||
"asset" as "asset"
|
||||
where
|
||||
"asset"."updateId" < $1
|
||||
and "asset"."updateId" > $2
|
||||
"asset"."updateId" < $2
|
||||
and "asset"."updateId" > $3
|
||||
and "ownerId" in (
|
||||
select
|
||||
"sharedById"
|
||||
from
|
||||
"partner"
|
||||
where
|
||||
"sharedWithId" = $3
|
||||
"sharedWithId" = $4
|
||||
)
|
||||
order by
|
||||
"asset"."updateId" asc
|
||||
|
||||
@@ -13,7 +13,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { columns } from 'src/database';
|
||||
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AlbumUserCreateDto } from 'src/dtos/album.dto';
|
||||
import { AlbumUserCreateDto, MapAlbumDto } from 'src/dtos/album.dto';
|
||||
import { AlbumUserRole } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
@@ -183,98 +183,48 @@ export class AlbumRepository {
|
||||
);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getOwned(ownerId: string) {
|
||||
private buildAlbumBaseQuery(ownerId: string, { isOwned, isShared }: { isOwned?: boolean; isShared?: boolean }) {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.innerJoin('album_user', (join) =>
|
||||
join
|
||||
.onRef('album_user.albumId', '=', 'album.id')
|
||||
.on('album_user.userId', '=', ownerId)
|
||||
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
|
||||
join.onRef('album_user.albumId', '=', 'album.id').on('album_user.userId', '=', ownerId),
|
||||
)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.$if(isOwned === true, (qb) => qb.where('album_user.role', '=', sql.lit(AlbumUserRole.Owner)))
|
||||
.$if(isOwned === false, (qb) => qb.where('album_user.role', '!=', sql.lit(AlbumUserRole.Owner)))
|
||||
.$if(isShared !== undefined, (qb) =>
|
||||
qb.where((eb) => {
|
||||
const isSharedAlbum = eb.or([
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('album_user as au')
|
||||
.whereRef('au.albumId', '=', 'album.id')
|
||||
.where('au.role', '!=', sql.lit(AlbumUserRole.Owner)),
|
||||
),
|
||||
eb.exists(eb.selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id')),
|
||||
]);
|
||||
return isShared ? isSharedAlbum : eb.not(isSharedAlbum);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, { isOwned: true, isShared: true }] })
|
||||
getAll(ownerId: string, options: { isOwned?: boolean; isShared?: boolean } = {}): Promise<MapAlbumDto[]> {
|
||||
return this.buildAlbumBaseQuery(ownerId, options)
|
||||
.selectAll('album')
|
||||
.select(withAlbumUsers(ownerId))
|
||||
.select(withSharedLink)
|
||||
.orderBy('album.createdAt', 'desc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get albums shared with and shared by owner.
|
||||
*/
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getShared(ownerId: string) {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.innerJoin(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom('album_user')
|
||||
.select('album_user.albumId as id')
|
||||
.where('album_user.userId', '=', ownerId)
|
||||
.where(
|
||||
'album_user.albumId',
|
||||
'in',
|
||||
eb
|
||||
.selectFrom('album_user')
|
||||
.select('album_user.albumId')
|
||||
.where('album_user.role', '!=', sql.lit(AlbumUserRole.Owner)),
|
||||
)
|
||||
.union(
|
||||
eb
|
||||
.selectFrom('shared_link')
|
||||
.where('shared_link.userId', '=', ownerId)
|
||||
.where('shared_link.albumId', 'is not', null)
|
||||
.select('shared_link.albumId as id')
|
||||
.$narrowType<{ id: NotNull }>(),
|
||||
)
|
||||
.as('matching'),
|
||||
(join) => join.onRef('matching.id', '=', 'album.id'),
|
||||
)
|
||||
.innerJoin('album_user', (join) =>
|
||||
join.onRef('album_user.albumId', '=', 'album.id').on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
|
||||
)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.select(withAlbumUsers(ownerId))
|
||||
.select(withSharedLink)
|
||||
.orderBy('album.createdAt', 'desc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get albums of owner that are _not_ shared
|
||||
*/
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getNotShared(ownerId: string) {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.innerJoin('album_user', (join) =>
|
||||
join
|
||||
.onRef('album_user.albumId', '=', 'album.id')
|
||||
.on('album_user.userId', '=', ownerId)
|
||||
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
|
||||
)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom('album_user as au')
|
||||
.whereRef('au.albumId', '=', 'album.id')
|
||||
.where('au.role', '!=', sql.lit(AlbumUserRole.Owner)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(exists(selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id'))),
|
||||
)
|
||||
.select(withSharedLink)
|
||||
.select(withAlbumUsers(ownerId))
|
||||
@GenerateSql({ params: [DummyValue.UUID, { isOwned: true, isShared: true }] })
|
||||
async getAllIds(ownerId: string, options: { isOwned?: boolean; isShared?: boolean } = {}): Promise<string[]> {
|
||||
const rows = await this.buildAlbumBaseQuery(ownerId, options)
|
||||
.select('album.id')
|
||||
.orderBy('album.createdAt', 'desc')
|
||||
.execute();
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
async restoreAll(userId: string): Promise<void> {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
|
||||
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { citiesFile, excludePaths, IWorker } from 'src/constants';
|
||||
import { citiesFile, IWorker } from 'src/constants';
|
||||
import { Telemetry } from 'src/decorators';
|
||||
import { EnvSchema } from 'src/dtos/env.dto';
|
||||
import {
|
||||
@@ -328,10 +328,6 @@ const getEnv = (): EnvData => {
|
||||
otel: {
|
||||
metrics: {
|
||||
hostMetrics: telemetries.has(ImmichTelemetry.Host),
|
||||
apiMetrics: {
|
||||
enable: telemetries.has(ImmichTelemetry.Api),
|
||||
ignoreRoutes: excludePaths,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ describe(MediaRepository.name, () => {
|
||||
|
||||
describe('applyEdits (single actions)', () => {
|
||||
it('should apply crop edit correctly', async () => {
|
||||
const result = await sut['applyEdits'](
|
||||
const result = sut['applyEdits'](
|
||||
sharp({
|
||||
create: {
|
||||
width: 1000,
|
||||
@@ -98,7 +98,7 @@ describe(MediaRepository.name, () => {
|
||||
expect(metadata.height).toBe(300);
|
||||
});
|
||||
it('should apply rotate edit correctly', async () => {
|
||||
const result = await sut['applyEdits'](
|
||||
const result = sut['applyEdits'](
|
||||
sharp({
|
||||
create: {
|
||||
width: 500,
|
||||
@@ -123,7 +123,7 @@ describe(MediaRepository.name, () => {
|
||||
});
|
||||
|
||||
it('should apply mirror edit correctly', async () => {
|
||||
const resultHorizontal = await sut['applyEdits'](sharp(await buildTestQuadImage()), [
|
||||
const resultHorizontal = sut['applyEdits'](sharp(await buildTestQuadImage()), [
|
||||
{
|
||||
action: AssetEditAction.Mirror,
|
||||
parameters: {
|
||||
@@ -142,7 +142,7 @@ describe(MediaRepository.name, () => {
|
||||
expect(await getPixelColor(bufferHorizontal, 10, 990)).toEqual({ r: 255, g: 255, b: 0 });
|
||||
expect(await getPixelColor(bufferHorizontal, 990, 990)).toEqual({ r: 0, g: 0, b: 255 });
|
||||
|
||||
const resultVertical = await sut['applyEdits'](sharp(await buildTestQuadImage()), [
|
||||
const resultVertical = sut['applyEdits'](sharp(await buildTestQuadImage()), [
|
||||
{
|
||||
action: AssetEditAction.Mirror,
|
||||
parameters: {
|
||||
@@ -170,7 +170,7 @@ describe(MediaRepository.name, () => {
|
||||
describe('applyEdits (multiple sequential edits)', () => {
|
||||
it('should apply horizontal mirror then vertical mirror (equivalent to 180° rotation)', async () => {
|
||||
const imageBuffer = await buildTestQuadImage();
|
||||
const result = await sut['applyEdits'](sharp(imageBuffer), [
|
||||
const result = sut['applyEdits'](sharp(imageBuffer), [
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
|
||||
]);
|
||||
@@ -188,7 +188,7 @@ describe(MediaRepository.name, () => {
|
||||
|
||||
it('should apply rotate 90° then horizontal mirror', async () => {
|
||||
const imageBuffer = await buildTestQuadImage();
|
||||
const result = await sut['applyEdits'](sharp(imageBuffer), [
|
||||
const result = sut['applyEdits'](sharp(imageBuffer), [
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
]);
|
||||
@@ -206,7 +206,7 @@ describe(MediaRepository.name, () => {
|
||||
|
||||
it('should apply 180° rotation', async () => {
|
||||
const imageBuffer = await buildTestQuadImage();
|
||||
const result = await sut['applyEdits'](sharp(imageBuffer), [
|
||||
const result = sut['applyEdits'](sharp(imageBuffer), [
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 180 } },
|
||||
]);
|
||||
|
||||
@@ -223,7 +223,7 @@ describe(MediaRepository.name, () => {
|
||||
|
||||
it('should apply 270° rotations', async () => {
|
||||
const imageBuffer = await buildTestQuadImage();
|
||||
const result = await sut['applyEdits'](sharp(imageBuffer), [
|
||||
const result = sut['applyEdits'](sharp(imageBuffer), [
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 270 } },
|
||||
]);
|
||||
|
||||
@@ -240,7 +240,7 @@ describe(MediaRepository.name, () => {
|
||||
|
||||
it('should apply crop then rotate 90°', async () => {
|
||||
const imageBuffer = await buildTestQuadImage();
|
||||
const result = await sut['applyEdits'](sharp(imageBuffer), [
|
||||
const result = sut['applyEdits'](sharp(imageBuffer), [
|
||||
{ action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 1000, height: 500 } },
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
|
||||
]);
|
||||
@@ -256,7 +256,7 @@ describe(MediaRepository.name, () => {
|
||||
|
||||
it('should apply rotate 90° then crop', async () => {
|
||||
const imageBuffer = await buildTestQuadImage();
|
||||
const result = await sut['applyEdits'](sharp(imageBuffer), [
|
||||
const result = sut['applyEdits'](sharp(imageBuffer), [
|
||||
{ action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 1000 } },
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
|
||||
]);
|
||||
@@ -272,7 +272,7 @@ describe(MediaRepository.name, () => {
|
||||
|
||||
it('should apply vertical mirror then horizontal mirror then rotate 90°', async () => {
|
||||
const imageBuffer = await buildTestQuadImage();
|
||||
const result = await sut['applyEdits'](sharp(imageBuffer), [
|
||||
const result = sut['applyEdits'](sharp(imageBuffer), [
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
|
||||
@@ -291,7 +291,7 @@ describe(MediaRepository.name, () => {
|
||||
|
||||
it('should apply crop to single quadrant then mirror', async () => {
|
||||
const imageBuffer = await buildTestQuadImage();
|
||||
const result = await sut['applyEdits'](sharp(imageBuffer), [
|
||||
const result = sut['applyEdits'](sharp(imageBuffer), [
|
||||
{ action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 500 } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
]);
|
||||
@@ -309,7 +309,7 @@ describe(MediaRepository.name, () => {
|
||||
|
||||
it('should apply all operations: crop, rotate, mirror', async () => {
|
||||
const imageBuffer = await buildTestQuadImage();
|
||||
const result = await sut['applyEdits'](sharp(imageBuffer), [
|
||||
const result = sut['applyEdits'](sharp(imageBuffer), [
|
||||
{ action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 1000 } },
|
||||
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
|
||||
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
|
||||
|
||||
@@ -77,34 +77,20 @@ export class MediaRepository {
|
||||
* @returns ExtractResult if succeeded, or null if failed
|
||||
*/
|
||||
async extract(input: string): Promise<ExtractResult | null> {
|
||||
try {
|
||||
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input);
|
||||
return { buffer, format: RawExtractedFormat.Jpeg };
|
||||
} catch (error: any) {
|
||||
this.logger.debug(`Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input);
|
||||
return { buffer, format: RawExtractedFormat.Jpeg };
|
||||
} catch (error: any) {
|
||||
this.logger.debug(`Could not extract JPEG buffer from image, trying PreviewJXL next: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input);
|
||||
return { buffer, format: RawExtractedFormat.Jxl };
|
||||
} catch (error: any) {
|
||||
this.logger.debug(`Could not extract PreviewJXL buffer from image, trying PreviewImage next: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input);
|
||||
return { buffer, format: RawExtractedFormat.Jpeg };
|
||||
} catch (error: any) {
|
||||
this.logger.debug(`Could not extract preview buffer from image: ${error}`);
|
||||
return null;
|
||||
for (const { tag, format } of [
|
||||
{ tag: 'JpgFromRaw2', format: RawExtractedFormat.Jpeg },
|
||||
{ tag: 'JpgFromRaw', format: RawExtractedFormat.Jpeg },
|
||||
{ tag: 'PreviewJXL', format: RawExtractedFormat.Jxl },
|
||||
{ tag: 'PreviewImage', format: RawExtractedFormat.Jpeg },
|
||||
]) {
|
||||
try {
|
||||
const buffer = await exiftool.extractBinaryTagToBuffer(tag, input);
|
||||
return { buffer, format };
|
||||
} catch (error: any) {
|
||||
this.logger.debug(`Could not extract ${tag} buffer from image: ${error}`);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async writeExif(tags: Partial<Exif>, output: string): Promise<boolean> {
|
||||
@@ -162,49 +148,45 @@ export class MediaRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async decodeImage(input: string | Buffer, options: DecodeToBufferOptions) {
|
||||
const pipeline = await this.getImageDecodingPipeline(input, options);
|
||||
return pipeline.raw().toBuffer({ resolveWithObject: true });
|
||||
decodeImage(input: string | Buffer, options: DecodeToBufferOptions) {
|
||||
return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true });
|
||||
}
|
||||
|
||||
private async applyEdits(pipeline: sharp.Sharp, edits: AssetEditActionItem[]): Promise<sharp.Sharp> {
|
||||
const affineEditOperations = edits.filter((edit) => edit.action !== 'crop');
|
||||
const matrix = createAffineMatrix(affineEditOperations);
|
||||
|
||||
private applyEdits(pipeline: sharp.Sharp, edits: AssetEditActionItem[]): sharp.Sharp {
|
||||
const crop = edits.find((edit) => edit.action === 'crop');
|
||||
const dimensions = await pipeline.metadata();
|
||||
|
||||
if (crop) {
|
||||
pipeline = pipeline.extract({
|
||||
left: crop ? Math.round(crop.parameters.x) : 0,
|
||||
top: crop ? Math.round(crop.parameters.y) : 0,
|
||||
width: crop ? Math.round(crop.parameters.width) : dimensions.width || 0,
|
||||
height: crop ? Math.round(crop.parameters.height) : dimensions.height || 0,
|
||||
left: Math.round(crop.parameters.x),
|
||||
top: Math.round(crop.parameters.y),
|
||||
width: Math.round(crop.parameters.width),
|
||||
height: Math.round(crop.parameters.height),
|
||||
});
|
||||
}
|
||||
|
||||
const { a, b, c, d } = matrix;
|
||||
pipeline = pipeline.affine([
|
||||
[a, b],
|
||||
[c, d],
|
||||
]);
|
||||
const affineEditOperations = edits.filter((edit) => edit.action !== 'crop');
|
||||
if (affineEditOperations.length > 0) {
|
||||
const { a, b, c, d } = createAffineMatrix(affineEditOperations);
|
||||
pipeline = pipeline.affine([
|
||||
[a, b],
|
||||
[c, d],
|
||||
]);
|
||||
}
|
||||
|
||||
return pipeline;
|
||||
}
|
||||
|
||||
async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise<void> {
|
||||
const pipeline = await this.getImageDecodingPipeline(input, options);
|
||||
const decoded = pipeline.toFormat(options.format, {
|
||||
quality: options.quality,
|
||||
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
|
||||
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
|
||||
progressive: options.progressive,
|
||||
});
|
||||
|
||||
await decoded.toFile(output);
|
||||
await this.getImageDecodingPipeline(input, options)
|
||||
.toFormat(options.format, {
|
||||
quality: options.quality,
|
||||
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
|
||||
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
|
||||
progressive: options.progressive,
|
||||
})
|
||||
.toFile(output);
|
||||
}
|
||||
|
||||
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
|
||||
private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
|
||||
let pipeline = sharp(input, {
|
||||
// some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes
|
||||
failOn: options.processInvalidImages ? 'none' : 'error',
|
||||
@@ -228,7 +210,7 @@ export class MediaRepository {
|
||||
}
|
||||
|
||||
if (options.edits && options.edits.length > 0) {
|
||||
pipeline = await this.applyEdits(pipeline, options.edits);
|
||||
pipeline = this.applyEdits(pipeline, options.edits);
|
||||
}
|
||||
|
||||
if (options.size !== undefined) {
|
||||
@@ -238,19 +220,18 @@ export class MediaRepository {
|
||||
}
|
||||
|
||||
async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise<Buffer> {
|
||||
const [{ rgbaToThumbHash }, decodingPipeline] = await Promise.all([
|
||||
import('thumbhash'),
|
||||
this.getImageDecodingPipeline(input, {
|
||||
colorspace: options.colorspace,
|
||||
processInvalidImages: options.processInvalidImages,
|
||||
raw: options.raw,
|
||||
edits: options.edits,
|
||||
}),
|
||||
]);
|
||||
const { rgbaToThumbHash } = await import('thumbhash');
|
||||
|
||||
const pipeline = decodingPipeline.resize(100, 100, { fit: 'inside', withoutEnlargement: true }).raw().ensureAlpha();
|
||||
|
||||
const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
|
||||
const { data, info } = await this.getImageDecodingPipeline(input, {
|
||||
colorspace: options.colorspace,
|
||||
processInvalidImages: options.processInvalidImages,
|
||||
raw: options.raw,
|
||||
edits: options.edits,
|
||||
})
|
||||
.resize(100, 100, { fit: 'inside', withoutEnlargement: true })
|
||||
.raw()
|
||||
.ensureAlpha()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
return Buffer.from(rgbaToThumbHash(info.width, info.height, data));
|
||||
}
|
||||
@@ -274,23 +255,23 @@ export class MediaRepository {
|
||||
index: stream.index,
|
||||
height,
|
||||
width: dar ? Math.round(height * dar) : this.parseInt(stream.width),
|
||||
codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name,
|
||||
profile: this.parseVideoProfile(stream.codec_name, stream.profile as string | undefined),
|
||||
codecName: stream.codec_name === 'h265' ? 'hevc' : (stream.codec_name ?? null),
|
||||
profile: this.parseVideoProfile(stream.codec_name, stream.profile as string | undefined) ?? null,
|
||||
level: this.parseOptionalInt(stream.level),
|
||||
frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames),
|
||||
frameRate: this.parseFrameRate(stream.avg_frame_rate ?? stream.r_frame_rate),
|
||||
timeBase: this.parseRational(stream.time_base)?.den,
|
||||
timeBase: this.parseRational(stream.time_base)?.den ?? null,
|
||||
rotation: this.parseInt(stream.rotation),
|
||||
bitrate: this.parseInt(stream.bit_rate),
|
||||
pixelFormat: stream.pix_fmt || 'yuv420p',
|
||||
colorPrimaries: this.parseEnum(ColorPrimaries, stream.color_primaries) ?? ColorPrimaries.Unknown,
|
||||
colorMatrix: this.parseEnum(ColorMatrix, stream.color_space) ?? ColorMatrix.Unknown,
|
||||
colorTransfer: this.parseEnum(ColorTransfer, stream.color_transfer) ?? ColorTransfer.Unknown,
|
||||
dvProfile: this.parseOptionalInt(stream.dv_profile) as DvProfile | undefined,
|
||||
dvProfile: this.parseOptionalInt(stream.dv_profile) as DvProfile | null,
|
||||
dvLevel: this.parseOptionalInt(stream.dv_level),
|
||||
dvBlSignalCompatibilityId: this.parseOptionalInt(stream.dv_bl_signal_compatibility_id) as
|
||||
| DvSignalCompatibility
|
||||
| undefined,
|
||||
dvBlSignalCompatibilityId: this.parseOptionalInt(
|
||||
stream.dv_bl_signal_compatibility_id,
|
||||
) as DvSignalCompatibility | null,
|
||||
};
|
||||
}),
|
||||
audioStreams: results.streams
|
||||
@@ -298,9 +279,9 @@ export class MediaRepository {
|
||||
.sort((a, b) => this.compareStreams(a, b))
|
||||
.map((stream) => ({
|
||||
index: stream.index,
|
||||
codecName: stream.codec_name,
|
||||
codecName: stream.codec_name ?? null,
|
||||
profile:
|
||||
stream.codec_name === 'aac' ? this.parseEnum(AacProfile, stream.profile as string | undefined) : undefined,
|
||||
stream.codec_name === 'aac' ? this.parseEnum(AacProfile, stream.profile as string | undefined) : null,
|
||||
bitrate: this.parseInt(stream.bit_rate),
|
||||
})),
|
||||
};
|
||||
@@ -449,29 +430,29 @@ export class MediaRepository {
|
||||
return Number.parseFloat(value as string) || 0;
|
||||
}
|
||||
|
||||
private parseOptionalInt(value: string | number | undefined): number | undefined {
|
||||
private parseOptionalInt(value: string | number | undefined): number | null {
|
||||
const parsed = Number.parseInt(value as string);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
private parseEnum<E extends Record<string, number | string>>(enumObj: E, value?: string) {
|
||||
return value ? (enumObj[pascalCase(value)] as Extract<E[keyof E], number> | undefined) : undefined;
|
||||
return value ? ((enumObj[pascalCase(value)] as Extract<E[keyof E], number> | undefined) ?? null) : null;
|
||||
}
|
||||
|
||||
/** Parse a rational like "60000/1001" or "1/600" into `{ num, den }`. */
|
||||
private parseRational(value: string | undefined): { num: number; den: number } | undefined {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const [num, den = 1] = value.split('/').map(Number);
|
||||
if (num && den) {
|
||||
return { num, den };
|
||||
private parseRational(value: string | undefined): { num: number; den: number } | null {
|
||||
if (value) {
|
||||
const [num, den = 1] = value.split('/').map(Number);
|
||||
if (num && den) {
|
||||
return { num, den };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private parseFrameRate(value: string | undefined): number | undefined {
|
||||
private parseFrameRate(value: string | undefined): number | null {
|
||||
const r = this.parseRational(value);
|
||||
return r ? r.num / r.den : undefined;
|
||||
return r ? r.num / r.den : null;
|
||||
}
|
||||
|
||||
private getDar(dar: string | undefined): number {
|
||||
@@ -498,6 +479,7 @@ export class MediaRepository {
|
||||
return this.parseEnum(Av1Profile, profile);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private compareStreams(a: FfprobeStream, b: FfprobeStream): number {
|
||||
|
||||
@@ -66,9 +66,21 @@ export class MemoryRepository implements IBulkAsset {
|
||||
.selectAll('asset')
|
||||
.innerJoin('memory_asset', 'asset.id', 'memory_asset.assetId')
|
||||
.whereRef('memory_asset.memoriesId', '=', 'memory.id')
|
||||
.orderBy('asset.fileCreatedAt', 'asc')
|
||||
.where('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
|
||||
.where('asset.deletedAt', 'is', null),
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.where((eb) =>
|
||||
eb.not(
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('asset_face')
|
||||
.innerJoin('person', 'person.id', 'asset_face.personId')
|
||||
.select((eb) => eb.val(1).as('one'))
|
||||
.whereRef('asset_face.assetId', '=', 'asset.id')
|
||||
.where('person.isHidden', '=', true),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy('asset.fileCreatedAt', 'asc'),
|
||||
).as('assets'),
|
||||
)
|
||||
.selectAll('memory')
|
||||
|
||||
@@ -595,7 +595,8 @@ class PartnerAssetsSync extends BaseSync {
|
||||
@GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID], stream: true })
|
||||
getBackfill(options: SyncBackfillOptions, partnerId: string) {
|
||||
return this.backfillQuery('asset', options)
|
||||
.select(columns.syncAsset)
|
||||
.select(columns.syncPartnerAsset)
|
||||
.select(sql.val(false).as('isFavorite'))
|
||||
.select('asset.updateId')
|
||||
.where('ownerId', '=', partnerId)
|
||||
.stream();
|
||||
@@ -614,7 +615,8 @@ class PartnerAssetsSync extends BaseSync {
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('asset', options)
|
||||
.select(columns.syncAsset)
|
||||
.select(columns.syncPartnerAsset)
|
||||
.select(sql.val(false).as('isFavorite'))
|
||||
.select('asset.updateId')
|
||||
.where('ownerId', 'in', (eb) =>
|
||||
eb.selectFrom('partner').select(['sharedById']).where('sharedWithId', '=', options.userId),
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic
|
||||
import { snakeCase, startCase } from 'lodash';
|
||||
import { MetricService } from 'nestjs-otel';
|
||||
import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { excludePaths, serverVersion } from 'src/constants';
|
||||
import { ImmichTelemetry, MetadataKey } from 'src/enum';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
@@ -60,6 +60,9 @@ export const bootstrapTelemetry = (port: number) => {
|
||||
if (instance) {
|
||||
throw new Error('OpenTelemetry SDK already started');
|
||||
}
|
||||
|
||||
const { telemetry } = new ConfigRepository().getEnv();
|
||||
|
||||
instance = new NodeSDK({
|
||||
resource: resourceFromAttributes({
|
||||
[ATTR_SERVICE_NAME]: `immich`,
|
||||
@@ -68,7 +71,10 @@ export const bootstrapTelemetry = (port: number) => {
|
||||
metricReader: new PrometheusExporter({ port }),
|
||||
contextManager: new AsyncLocalStorageContextManager(),
|
||||
instrumentations: [
|
||||
new HttpInstrumentation(),
|
||||
new HttpInstrumentation({
|
||||
enabled: telemetry.metrics.has(ImmichTelemetry.Api),
|
||||
ignoreIncomingRequestHook: (request) => excludePaths.some((item) => request.url?.startsWith(item)),
|
||||
}),
|
||||
new IORedisInstrumentation(),
|
||||
new NestInstrumentation(),
|
||||
new PgInstrumentation(),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
// isFavorite was incorrectly included in partner asset sync on server <=2.X.X
|
||||
await sql`DELETE FROM session_sync_checkpoint WHERE type in ('PartnerAssetV1', 'PartnerAssetBackfillV1')`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// Not implemented
|
||||
}
|
||||
@@ -26,18 +26,16 @@ describe(AlbumService.name, () => {
|
||||
|
||||
describe('getStatistics', () => {
|
||||
it('should get the album count', async () => {
|
||||
mocks.album.getOwned.mockResolvedValue([]);
|
||||
mocks.album.getShared.mockResolvedValue([]);
|
||||
mocks.album.getNotShared.mockResolvedValue([]);
|
||||
mocks.album.getAll.mockResolvedValue([]);
|
||||
await expect(sut.getStatistics(authStub.admin)).resolves.toEqual({
|
||||
owned: 0,
|
||||
shared: 0,
|
||||
notShared: 0,
|
||||
});
|
||||
|
||||
expect(mocks.album.getOwned).toHaveBeenCalledWith(authStub.admin.user.id);
|
||||
expect(mocks.album.getShared).toHaveBeenCalledWith(authStub.admin.user.id);
|
||||
expect(mocks.album.getNotShared).toHaveBeenCalledWith(authStub.admin.user.id);
|
||||
expect(mocks.album.getAll).toHaveBeenCalledWith(authStub.admin.user.id, { isOwned: true });
|
||||
expect(mocks.album.getAll).toHaveBeenCalledWith(authStub.admin.user.id, { isShared: true });
|
||||
expect(mocks.album.getAll).toHaveBeenCalledWith(authStub.admin.user.id, { isOwned: true, isShared: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,7 +44,7 @@ describe(AlbumService.name, () => {
|
||||
const album = AlbumFactory.from().albumUser().build();
|
||||
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
|
||||
const sharedWithUserAlbum = AlbumFactory.from().owner(owner).albumUser().build();
|
||||
mocks.album.getOwned.mockResolvedValue([getForAlbum(album), getForAlbum(sharedWithUserAlbum)]);
|
||||
mocks.album.getAll.mockResolvedValue([getForAlbum(album), getForAlbum(sharedWithUserAlbum)]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
@@ -68,6 +66,7 @@ describe(AlbumService.name, () => {
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toEqual(album.id);
|
||||
expect(result[1].id).toEqual(sharedWithUserAlbum.id);
|
||||
expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, { isOwned: undefined, isShared: undefined });
|
||||
});
|
||||
|
||||
it('gets list of albums that have a specific asset', async () => {
|
||||
@@ -98,7 +97,7 @@ describe(AlbumService.name, () => {
|
||||
it('gets list of albums that are shared', async () => {
|
||||
const album = AlbumFactory.from().albumUser().build();
|
||||
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
|
||||
mocks.album.getShared.mockResolvedValue([getForAlbum(album)]);
|
||||
mocks.album.getAll.mockResolvedValue([getForAlbum(album)]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
@@ -109,16 +108,16 @@ describe(AlbumService.name, () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await sut.getAll(AuthFactory.create(owner), { shared: true });
|
||||
const result = await sut.getAll(AuthFactory.create(owner), { isShared: true });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toEqual(album.id);
|
||||
expect(mocks.album.getShared).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, expect.objectContaining({ isShared: true }));
|
||||
});
|
||||
|
||||
it('gets list of albums that are NOT shared', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
|
||||
mocks.album.getNotShared.mockResolvedValue([getForAlbum(album)]);
|
||||
mocks.album.getAll.mockResolvedValue([getForAlbum(album)]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
@@ -129,17 +128,66 @@ describe(AlbumService.name, () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await sut.getAll(AuthFactory.create(owner), { shared: false });
|
||||
const result = await sut.getAll(AuthFactory.create(owner), { isShared: false });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toEqual(album.id);
|
||||
expect(mocks.album.getNotShared).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, expect.objectContaining({ isShared: false }));
|
||||
});
|
||||
|
||||
it('gets only owned albums when isOwned=true', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
|
||||
mocks.album.getAll.mockResolvedValue([getForAlbum(album)]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{ albumId: album.id, assetCount: 0, startDate: null, endDate: null, lastModifiedAssetTimestamp: null },
|
||||
]);
|
||||
|
||||
const result = await sut.getAll(AuthFactory.create(owner), { isOwned: true });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, expect.objectContaining({ isOwned: true }));
|
||||
});
|
||||
|
||||
it('gets only shared-with-me albums when isOwned=false', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
|
||||
mocks.album.getAll.mockResolvedValue([getForAlbum(album)]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{ albumId: album.id, assetCount: 0, startDate: null, endDate: null, lastModifiedAssetTimestamp: null },
|
||||
]);
|
||||
|
||||
const result = await sut.getAll(AuthFactory.create(owner), { isOwned: false });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, expect.objectContaining({ isOwned: false }));
|
||||
});
|
||||
|
||||
it('gets owned shared-out albums when isOwned=true and isShared=true', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
|
||||
mocks.album.getAll.mockResolvedValue([getForAlbum(album)]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{ albumId: album.id, assetCount: 0, startDate: null, endDate: null, lastModifiedAssetTimestamp: null },
|
||||
]);
|
||||
|
||||
const result = await sut.getAll(AuthFactory.create(owner), { isOwned: true, isShared: true });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, { isOwned: true, isShared: true });
|
||||
});
|
||||
|
||||
it('returns empty list when isOwned=false and isShared=false', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
|
||||
mocks.album.getAll.mockResolvedValue([]);
|
||||
|
||||
const result = await sut.getAll(AuthFactory.create(owner), { isOwned: false, isShared: false });
|
||||
expect(result).toHaveLength(0);
|
||||
expect(mocks.album.getAll).toHaveBeenCalledWith(owner.id, { isOwned: false, isShared: false });
|
||||
});
|
||||
});
|
||||
|
||||
it('counts assets correctly', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
|
||||
mocks.album.getOwned.mockResolvedValue([getForAlbum(album)]);
|
||||
mocks.album.getAll.mockResolvedValue([getForAlbum(album)]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
@@ -153,7 +201,7 @@ describe(AlbumService.name, () => {
|
||||
const result = await sut.getAll(AuthFactory.create(owner), {});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].assetCount).toEqual(1);
|
||||
expect(mocks.album.getOwned).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.album.getAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
CreateAlbumDto,
|
||||
GetAlbumsDto,
|
||||
mapAlbum,
|
||||
MapAlbumDto,
|
||||
UpdateAlbumDto,
|
||||
UpdateAlbumUserDto,
|
||||
} from 'src/dtos/album.dto';
|
||||
@@ -26,9 +25,9 @@ import { getPreferences } from 'src/utils/preferences';
|
||||
export class AlbumService extends BaseService {
|
||||
async getStatistics(auth: AuthDto): Promise<AlbumStatisticsResponseDto> {
|
||||
const [owned, shared, notShared] = await Promise.all([
|
||||
this.albumRepository.getOwned(auth.user.id),
|
||||
this.albumRepository.getShared(auth.user.id),
|
||||
this.albumRepository.getNotShared(auth.user.id),
|
||||
this.albumRepository.getAll(auth.user.id, { isOwned: true }),
|
||||
this.albumRepository.getAll(auth.user.id, { isShared: true }),
|
||||
this.albumRepository.getAll(auth.user.id, { isOwned: true, isShared: false }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -38,18 +37,18 @@ export class AlbumService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
|
||||
async getAll(
|
||||
{ user: { id: ownerId } }: AuthDto,
|
||||
{ assetId, isOwned, isShared }: GetAlbumsDto,
|
||||
): Promise<AlbumResponseDto[]> {
|
||||
await this.albumRepository.updateThumbnails();
|
||||
|
||||
let albums: MapAlbumDto[];
|
||||
if (assetId) {
|
||||
albums = await this.albumRepository.getByAssetId(ownerId, assetId);
|
||||
} else if (shared === true) {
|
||||
albums = await this.albumRepository.getShared(ownerId);
|
||||
} else if (shared === false) {
|
||||
albums = await this.albumRepository.getNotShared(ownerId);
|
||||
} else {
|
||||
albums = await this.albumRepository.getOwned(ownerId);
|
||||
const albums = assetId
|
||||
? await this.albumRepository.getByAssetId(ownerId, assetId)
|
||||
: await this.albumRepository.getAll(ownerId, { isOwned, isShared });
|
||||
|
||||
if (albums.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get asset count for each album. Then map the result to an object:
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { PartnerFactory } from 'test/factories/partner.factory';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { getForAlbum, getForPartner } from 'test/mappers';
|
||||
import { getForPartner } from 'test/mappers';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(MapService.name, () => {
|
||||
@@ -82,15 +82,15 @@ describe(MapService.name, () => {
|
||||
};
|
||||
mocks.partner.getAll.mockResolvedValue([]);
|
||||
mocks.map.getMapMarkers.mockResolvedValue([marker]);
|
||||
mocks.album.getOwned.mockResolvedValue([getForAlbum(AlbumFactory.create())]);
|
||||
mocks.album.getShared.mockResolvedValue([
|
||||
getForAlbum(AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()),
|
||||
]);
|
||||
const album1 = AlbumFactory.create();
|
||||
const album2 = AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build();
|
||||
mocks.album.getAllIds.mockResolvedValue([album1.id, album2.id]);
|
||||
|
||||
const markers = await sut.getMapMarkers(auth, { withSharedAlbums: true });
|
||||
|
||||
expect(markers).toHaveLength(1);
|
||||
expect(markers[0]).toEqual(marker);
|
||||
expect(mocks.album.getAllIds).toHaveBeenCalledWith(auth.user.id);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,15 +13,7 @@ export class MapService extends BaseService {
|
||||
userIds.push(...partnerIds);
|
||||
}
|
||||
|
||||
// TODO convert to SQL join
|
||||
const albumIds: string[] = [];
|
||||
if (options.withSharedAlbums) {
|
||||
const [ownedAlbums, sharedAlbums] = await Promise.all([
|
||||
this.albumRepository.getOwned(auth.user.id),
|
||||
this.albumRepository.getShared(auth.user.id),
|
||||
]);
|
||||
albumIds.push(...ownedAlbums.map((album) => album.id), ...sharedAlbums.map((album) => album.id));
|
||||
}
|
||||
const albumIds = options.withSharedAlbums ? await this.albumRepository.getAllIds(auth.user.id) : [];
|
||||
|
||||
return this.mapRepository.getMapMarkers(userIds, albumIds, options);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotNull, ShallowDehydrateObject } from 'kysely';
|
||||
import { ShallowDehydrateObject } from 'kysely';
|
||||
import { OutputInfo } from 'sharp';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { Exif } from 'src/database';
|
||||
@@ -1937,7 +1937,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
describe('handleVideoConversion', () => {
|
||||
let asset: ReturnType<typeof AssetFactory.create> & {
|
||||
videoStream: VideoStreamInfo & { timeBase: NotNull };
|
||||
videoStream: VideoStreamInfo & { timeBase: number };
|
||||
audioStream: AudioStreamInfo | null;
|
||||
format: VideoFormat;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
AudioCodec,
|
||||
Colorspace,
|
||||
ImageFormat,
|
||||
ImmichWorker,
|
||||
JobName,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
@@ -60,10 +61,9 @@ type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForG
|
||||
export class MediaService extends BaseService {
|
||||
videoInterfaces: VideoInterfaces = { dri: [], mali: false };
|
||||
|
||||
@OnEvent({ name: 'AppBootstrap' })
|
||||
@OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Microservices] })
|
||||
async onBootstrap() {
|
||||
const [dri, mali] = await Promise.all([this.getDevices(), this.hasMaliOpenCL()]);
|
||||
this.videoInterfaces = { dri, mali };
|
||||
this.videoInterfaces = await this.storageCore.getVideoInterfaces();
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.AssetGenerateThumbnailsQueueAll, queue: QueueName.ThumbnailGeneration })
|
||||
@@ -789,28 +789,6 @@ export class MediaService extends BaseService {
|
||||
return extractedSize >= targetSize;
|
||||
}
|
||||
|
||||
private async getDevices() {
|
||||
try {
|
||||
return await this.storageRepository.readdir('/dev/dri');
|
||||
} catch {
|
||||
this.logger.debug('No devices found in /dev/dri.');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async hasMaliOpenCL() {
|
||||
try {
|
||||
const [maliIcdStat, maliDeviceStat] = await Promise.all([
|
||||
this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd'),
|
||||
this.storageRepository.stat('/dev/mali0'),
|
||||
]);
|
||||
return maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
|
||||
} catch {
|
||||
this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU tonemapping');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async syncFiles(
|
||||
oldFiles: (AssetFile & { isProgressive: boolean; isTransparent: boolean })[],
|
||||
newFiles: UpsertFileOptions[],
|
||||
|
||||
@@ -28,25 +28,26 @@ describe(MemoryService.name, () => {
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should search memories', async () => {
|
||||
it('should search memories with assets', async () => {
|
||||
const [userId] = newUuids();
|
||||
|
||||
const asset = AssetFactory.create();
|
||||
const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build();
|
||||
const memory2 = MemoryFactory.create({ ownerId: userId });
|
||||
|
||||
mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]);
|
||||
|
||||
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: memory1.id, assets: [expect.objectContaining({ id: asset.id })] }),
|
||||
expect.objectContaining({ id: memory2.id, assets: [] }),
|
||||
expect.objectContaining({
|
||||
id: memory1.id,
|
||||
assets: expect.arrayContaining([expect.objectContaining({ id: asset.id })]),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should map ', async () => {
|
||||
it('should map empty result', async () => {
|
||||
mocks.memory.search.mockResolvedValue([]);
|
||||
|
||||
await expect(sut.search(factory.auth(), {})).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Memory } from 'src/database';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
@@ -71,7 +72,9 @@ export class MemoryService extends BaseService {
|
||||
|
||||
async search(auth: AuthDto, dto: MemorySearchDto) {
|
||||
const memories = await this.memoryRepository.search(auth.user.id, dto);
|
||||
return memories.map((memory) => mapMemory(memory, auth));
|
||||
return memories
|
||||
.filter((memory: Memory) => memory.assets && memory.assets.length > 0)
|
||||
.map((memory: Memory) => mapMemory(memory, auth));
|
||||
}
|
||||
|
||||
statistics(auth: AuthDto, dto: MemorySearchDto) {
|
||||
|
||||
@@ -672,7 +672,7 @@ describe(MetadataService.name, () => {
|
||||
colorPrimaries: 9,
|
||||
colorTransfer: 16,
|
||||
colorMatrix: 9,
|
||||
dvProfile: undefined,
|
||||
dvProfile: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
+10
-10
@@ -89,26 +89,26 @@ export interface VideoStreamInfo {
|
||||
height: number;
|
||||
width: number;
|
||||
rotation: number;
|
||||
codecName?: string;
|
||||
profile?: H264Profile | HevcProfile | Av1Profile;
|
||||
level?: number;
|
||||
codecName: string | null;
|
||||
profile: H264Profile | HevcProfile | Av1Profile | null;
|
||||
level: number | null;
|
||||
frameCount: number;
|
||||
frameRate?: number;
|
||||
timeBase?: number;
|
||||
frameRate: number | null;
|
||||
timeBase: number | null;
|
||||
bitrate: number;
|
||||
pixelFormat: string;
|
||||
colorPrimaries: ColorPrimaries;
|
||||
colorMatrix: ColorMatrix;
|
||||
colorTransfer: ColorTransfer;
|
||||
dvProfile?: DvProfile;
|
||||
dvLevel?: number;
|
||||
dvBlSignalCompatibilityId?: DvSignalCompatibility;
|
||||
dvProfile: DvProfile | null;
|
||||
dvLevel: number | null;
|
||||
dvBlSignalCompatibilityId: DvSignalCompatibility | null;
|
||||
}
|
||||
|
||||
export interface AudioStreamInfo {
|
||||
index: number;
|
||||
codecName?: string;
|
||||
profile?: AacProfile;
|
||||
codecName: string | null;
|
||||
profile: AacProfile | null;
|
||||
bitrate: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { AssetFileType, AssetVisibility, DatabaseExtension, ExifOrientation } fr
|
||||
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AudioStreamInfo, VectorExtension, VideoFormat, VideoStreamInfo } from 'src/types';
|
||||
import { AudioStreamInfo, VectorExtension, VideoFormat, VideoPacketInfo, VideoStreamInfo } from 'src/types';
|
||||
|
||||
export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyConfig => {
|
||||
return {
|
||||
@@ -146,7 +146,7 @@ export function withVideoStream(eb: ExpressionBuilder<DB, 'asset_exif' | 'asset_
|
||||
'asset_video.dvBlSignalCompatibilityId',
|
||||
])
|
||||
.where('asset_video.assetId', 'is not', sql.lit(null)),
|
||||
).$castTo<(VideoStreamInfo & { timeBase: NotNull }) | null>();
|
||||
).$castTo<(VideoStreamInfo & { timeBase: number }) | null>();
|
||||
}
|
||||
|
||||
export function withVideoFormat(eb: ExpressionBuilder<DB, 'asset' | 'asset_video'>) {
|
||||
@@ -158,6 +158,22 @@ export function withVideoFormat(eb: ExpressionBuilder<DB, 'asset' | 'asset_video
|
||||
).$castTo<VideoFormat | null>();
|
||||
}
|
||||
|
||||
export function withVideoPackets(eb: ExpressionBuilder<DB, 'asset' | 'asset_keyframe'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom(dummy)
|
||||
.where('asset_keyframe.assetId', 'is not', sql.lit(null))
|
||||
.select([
|
||||
'asset_keyframe.pts as keyframePts',
|
||||
'asset_keyframe.accDuration as keyframeAccDuration',
|
||||
'asset_keyframe.ownDuration as keyframeOwnDuration',
|
||||
'asset_keyframe.totalDuration',
|
||||
'asset_keyframe.packetCount',
|
||||
'asset_keyframe.outputFrames',
|
||||
]),
|
||||
).$castTo<VideoPacketInfo | null>();
|
||||
}
|
||||
|
||||
export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
||||
return qb
|
||||
.leftJoin('smart_search', 'asset.id', 'smart_search.assetId')
|
||||
|
||||
Vendored
+220
-12
@@ -1,5 +1,13 @@
|
||||
import { NotNull } from 'kysely';
|
||||
import { ColorMatrix, ColorPrimaries, ColorTransfer, DvProfile, DvSignalCompatibility } from 'src/enum';
|
||||
import {
|
||||
AacProfile,
|
||||
ColorMatrix,
|
||||
ColorPrimaries,
|
||||
ColorTransfer,
|
||||
DvProfile,
|
||||
DvSignalCompatibility,
|
||||
H264Profile,
|
||||
HevcProfile,
|
||||
} from 'src/enum';
|
||||
import { AudioStreamInfo, VideoFormat, VideoInfo, VideoStreamInfo } from 'src/types';
|
||||
|
||||
const probeStubDefaultFormat: VideoFormat = {
|
||||
@@ -22,11 +30,17 @@ const probeStubDefaultVideoStream: VideoStreamInfo[] = [
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: HevcProfile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
];
|
||||
|
||||
const probeStubDefaultAudioStream: AudioStreamInfo[] = [{ index: 3, codecName: 'mp3', bitrate: 100 }];
|
||||
const probeStubDefaultAudioStream: AudioStreamInfo[] = [{ index: 3, codecName: 'mp3', bitrate: 100, profile: null }];
|
||||
|
||||
const probeStubDefault: VideoInfo = {
|
||||
format: probeStubDefaultFormat,
|
||||
@@ -53,7 +67,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: HevcProfile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
@@ -67,7 +87,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: HevcProfile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
@@ -81,16 +107,22 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: HevcProfile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
multipleAudioStreams: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [
|
||||
{ index: 2, codecName: 'mp3', bitrate: 102 },
|
||||
{ index: 1, codecName: 'mp3', bitrate: 101 },
|
||||
{ index: 0, codecName: 'mp3', bitrate: 100 },
|
||||
{ index: 2, codecName: 'mp3', bitrate: 102, profile: null },
|
||||
{ index: 1, codecName: 'mp3', bitrate: 101, profile: null },
|
||||
{ index: 0, codecName: 'mp3', bitrate: 100, profile: null },
|
||||
],
|
||||
}),
|
||||
noHeight: Object.freeze<VideoInfo>({
|
||||
@@ -108,7 +140,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: HevcProfile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -127,7 +165,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: HevcProfile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -159,7 +203,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Smpte2084,
|
||||
bitrate: 0,
|
||||
pixelFormat: 'yuv420p10le',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: H264Profile.High10,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -178,7 +228,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p10le',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: H264Profile.High10,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -197,7 +253,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p10le',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: H264Profile.High10,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -216,7 +278,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: H264Profile.High,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -235,7 +303,13 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: H264Profile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -254,27 +328,33 @@ export const videoInfoStub = {
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
pixelFormat: 'yuv420p',
|
||||
frameRate: 60,
|
||||
timeBase: 600,
|
||||
profile: H264Profile.Main,
|
||||
level: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
audioStreamAac: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [{ index: 1, codecName: 'aac', bitrate: 100 }],
|
||||
audioStreams: [{ index: 1, codecName: 'aac', bitrate: 100, profile: AacProfile.Lc }],
|
||||
}),
|
||||
audioStreamMp3: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [{ index: 1, codecName: 'mp3', bitrate: 100 }],
|
||||
audioStreams: [{ index: 1, codecName: 'mp3', bitrate: 100, profile: null }],
|
||||
}),
|
||||
audioStreamOpus: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [{ index: 1, codecName: 'opus', bitrate: 100 }],
|
||||
audioStreams: [{ index: 1, codecName: 'opus', bitrate: 100, profile: null }],
|
||||
}),
|
||||
audioStreamUnknown: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [
|
||||
{ index: 0, codecName: 'aac', bitrate: 100 },
|
||||
{ index: 1, codecName: 'unknown', bitrate: 200 },
|
||||
{ index: 0, codecName: 'aac', bitrate: 100, profile: AacProfile.Lc },
|
||||
{ index: 1, codecName: 'unknown', bitrate: 200, profile: null },
|
||||
],
|
||||
}),
|
||||
matroskaContainer: Object.freeze<VideoInfo>({
|
||||
@@ -340,6 +420,9 @@ export const videoInfoStub = {
|
||||
colorMatrix: ColorMatrix.Bt2020Nc,
|
||||
colorTransfer: ColorTransfer.Smpte2084,
|
||||
timeBase: 600,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
dvLevel: null,
|
||||
dvProfile: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -393,7 +476,7 @@ export const videoInfoStub = {
|
||||
};
|
||||
|
||||
interface SelectedStreams {
|
||||
videoStream: VideoStreamInfo & { timeBase: NotNull };
|
||||
videoStream: VideoStreamInfo & { timeBase: number };
|
||||
audioStream: AudioStreamInfo | null;
|
||||
format: VideoFormat;
|
||||
}
|
||||
@@ -407,3 +490,128 @@ const toSelectedStreams = (info: VideoInfo) => ({
|
||||
export const probeStub = Object.fromEntries(
|
||||
Object.entries(videoInfoStub).map(([key, info]) => [key, toSelectedStreams(info)]),
|
||||
) as Record<keyof typeof videoInfoStub, SelectedStreams>;
|
||||
|
||||
export const eiffelTower = {
|
||||
originalPath: 'eiffel-tower.mp4',
|
||||
videoStream: {
|
||||
index: 0,
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
rotation: 0,
|
||||
codecName: 'h264',
|
||||
profile: H264Profile.High,
|
||||
level: 40,
|
||||
frameCount: 557,
|
||||
frameRate: 24.908_004_845_459_07,
|
||||
timeBase: 90_000,
|
||||
bitrate: 5_128_622,
|
||||
pixelFormat: 'yuv420p',
|
||||
colorPrimaries: ColorPrimaries.Smpte170M,
|
||||
colorTransfer: ColorTransfer.Smpte170M,
|
||||
colorMatrix: ColorMatrix.Smpte170M,
|
||||
dvProfile: null,
|
||||
dvLevel: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
},
|
||||
audioStream: { codecName: 'aac', bitrate: 125_629, index: 1, profile: AacProfile.Lc },
|
||||
packets: {
|
||||
totalDuration: 2_012_441,
|
||||
packetCount: 557,
|
||||
outputFrames: 557,
|
||||
keyframePts: [0, 462_502, 925_004, 1_210_454, 1_387_506, 1_542_878, 1_850_008],
|
||||
keyframeAccDuration: [3613, 466_077, 928_541, 1_213_968, 1_391_005, 1_546_364, 1_853_469],
|
||||
keyframeOwnDuration: [3613, 3613, 3613, 3613, 3613, 3613, 3613],
|
||||
},
|
||||
format: {
|
||||
formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
|
||||
formatLongName: 'QuickTime / MOV',
|
||||
duration: 22_616,
|
||||
bitrate: 5_128_622,
|
||||
},
|
||||
};
|
||||
|
||||
export const waterfall = {
|
||||
originalPath: 'waterfall.mp4',
|
||||
videoStream: {
|
||||
index: 2,
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
rotation: -90,
|
||||
codecName: 'hevc',
|
||||
profile: HevcProfile.Main,
|
||||
level: 156,
|
||||
frameCount: 309,
|
||||
frameRate: 29.829_901_982_867_92,
|
||||
timeBase: 90_000,
|
||||
bitrate: 43_363_499,
|
||||
pixelFormat: 'yuvj420p',
|
||||
colorPrimaries: ColorPrimaries.Bt709,
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
dvProfile: null,
|
||||
dvLevel: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
},
|
||||
audioStream: { codecName: 'aac', bitrate: 191_878, index: 1, profile: null },
|
||||
packets: {
|
||||
totalDuration: 932_286,
|
||||
packetCount: 309,
|
||||
outputFrames: 309,
|
||||
keyframePts: [0, 89_987, 179_974, 269_961, 359_948, 449_936, 539_923, 629_910, 725_166, 815_273, 905_295],
|
||||
keyframeAccDuration: [
|
||||
2999, 92_987, 182_974, 272_961, 362_948, 452_934, 542_922, 632_909, 728_175, 818_274, 908_296,
|
||||
],
|
||||
keyframeOwnDuration: [2999, 3000, 3000, 3000, 3000, 2998, 2999, 2999, 3009, 3001, 3001],
|
||||
},
|
||||
format: {
|
||||
formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
|
||||
formatLongName: 'QuickTime / MOV',
|
||||
duration: 10_359,
|
||||
bitrate: 43_363_499,
|
||||
},
|
||||
};
|
||||
|
||||
export const train = {
|
||||
originalPath: 'train.mov',
|
||||
videoStream: {
|
||||
index: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
rotation: -90,
|
||||
codecName: 'hevc',
|
||||
profile: HevcProfile.Main10,
|
||||
level: 123,
|
||||
frameCount: 1229,
|
||||
frameRate: 56.536_072_989_342_94,
|
||||
timeBase: 600,
|
||||
bitrate: 12_595_191,
|
||||
pixelFormat: 'yuv420p10le',
|
||||
colorPrimaries: ColorPrimaries.Bt2020,
|
||||
colorTransfer: ColorTransfer.AribStdB67,
|
||||
colorMatrix: ColorMatrix.Bt2020Nc,
|
||||
dvProfile: DvProfile.Dvhe08,
|
||||
dvLevel: 5,
|
||||
dvBlSignalCompatibilityId: DvSignalCompatibility.Hlg,
|
||||
},
|
||||
audioStream: { codecName: 'aac', bitrate: 175_477, index: 1, profile: AacProfile.Lc },
|
||||
packets: {
|
||||
totalDuration: 12_290,
|
||||
packetCount: 1229,
|
||||
outputFrames: 1303,
|
||||
keyframePts: [
|
||||
0, 601, 1201, 1802, 2402, 3003, 3604, 4204, 4805, 5405, 6006, 6607, 7207, 7808, 8408, 9009, 9609, 10_210, 10_811,
|
||||
11_411, 12_062, 12_703,
|
||||
],
|
||||
keyframeAccDuration: [
|
||||
10, 580, 1180, 1780, 2380, 2980, 3580, 4180, 4780, 5380, 5980, 6580, 7180, 7780, 8380, 8980, 9580, 10_180, 10_780,
|
||||
11_380, 11_780, 12_100,
|
||||
],
|
||||
keyframeOwnDuration: [10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10],
|
||||
},
|
||||
format: {
|
||||
formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
|
||||
formatLongName: 'QuickTime / MOV',
|
||||
duration: 21_738,
|
||||
bitrate: 12_595_191,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotNull, Selectable, ShallowDehydrateObject } from 'kysely';
|
||||
import { Selectable, ShallowDehydrateObject } from 'kysely';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import { ActivityTable } from 'src/schema/tables/activity.table';
|
||||
@@ -156,7 +156,7 @@ export const getForGenerateThumbnail = (asset: ReturnType<AssetFactory['build']>
|
||||
files: asset.files.map((file) => getDehydrated(file)),
|
||||
exifInfo: getDehydrated(asset.exifInfo),
|
||||
edits: asset.edits.map(({ action, parameters }) => ({ action, parameters })) as AssetEditActionItem[],
|
||||
videoStream: null as (VideoStreamInfo & { timeBase: NotNull }) | null,
|
||||
videoStream: null as (VideoStreamInfo & { timeBase: number }) | null,
|
||||
audioStream: null as AudioStreamInfo | null,
|
||||
format: null as VideoFormat | null,
|
||||
});
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { resolve } from 'node:path';
|
||||
import {
|
||||
AacProfile,
|
||||
AssetType,
|
||||
ColorMatrix,
|
||||
ColorPrimaries,
|
||||
ColorTransfer,
|
||||
DvProfile,
|
||||
DvSignalCompatibility,
|
||||
H264Profile,
|
||||
HevcProfile,
|
||||
} from 'src/enum';
|
||||
import { AssetType } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { withAudioStream, withVideoFormat, withVideoPackets, withVideoStream } from 'src/utils/database';
|
||||
import { eiffelTower, train, waterfall } from 'test/fixtures/media.stub';
|
||||
import { ExifTestContext, testAssetsDir } from 'test/medium.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
@@ -21,122 +13,30 @@ beforeAll(async () => {
|
||||
database = await getKyselyDB();
|
||||
});
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
file: 'eiffel-tower.mp4',
|
||||
video: {
|
||||
codecName: 'h264',
|
||||
formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
|
||||
formatLongName: 'QuickTime / MOV',
|
||||
pixelFormat: 'yuv420p',
|
||||
bitrate: 5_128_622,
|
||||
frameCount: 557,
|
||||
timeBase: 90_000,
|
||||
index: 0,
|
||||
profile: H264Profile.High,
|
||||
level: 40,
|
||||
colorPrimaries: ColorPrimaries.Smpte170M,
|
||||
colorTransfer: ColorTransfer.Smpte170M,
|
||||
colorMatrix: ColorMatrix.Smpte170M,
|
||||
dvProfile: null,
|
||||
dvLevel: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
},
|
||||
audio: { codecName: 'aac', bitrate: 125_629, index: 1, profile: AacProfile.Lc },
|
||||
keyframes: {
|
||||
totalDuration: 2_012_441,
|
||||
packetCount: 557,
|
||||
outputFrames: 557,
|
||||
pts: [0, 462_502, 925_004, 1_210_454, 1_387_506, 1_542_878, 1_850_008],
|
||||
accDuration: [3613, 466_077, 928_541, 1_213_968, 1_391_005, 1_546_364, 1_853_469],
|
||||
ownDuration: [3613, 3613, 3613, 3613, 3613, 3613, 3613],
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'waterfall.mp4',
|
||||
video: {
|
||||
codecName: 'hevc',
|
||||
formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
|
||||
formatLongName: 'QuickTime / MOV',
|
||||
pixelFormat: 'yuvj420p',
|
||||
bitrate: 43_363_499,
|
||||
frameCount: 309,
|
||||
timeBase: 90_000,
|
||||
index: 2,
|
||||
profile: HevcProfile.Main,
|
||||
level: 156,
|
||||
colorPrimaries: ColorPrimaries.Bt709,
|
||||
colorTransfer: ColorTransfer.Bt709,
|
||||
colorMatrix: ColorMatrix.Bt709,
|
||||
dvProfile: null,
|
||||
dvLevel: null,
|
||||
dvBlSignalCompatibilityId: null,
|
||||
},
|
||||
audio: { codecName: 'aac', bitrate: 191_878, index: 1, profile: null },
|
||||
keyframes: {
|
||||
totalDuration: 932_286,
|
||||
packetCount: 309,
|
||||
outputFrames: 309,
|
||||
pts: [0, 89_987, 179_974, 269_961, 359_948, 449_936, 539_923, 629_910, 725_166, 815_273, 905_295],
|
||||
accDuration: [2999, 92_987, 182_974, 272_961, 362_948, 452_934, 542_922, 632_909, 728_175, 818_274, 908_296],
|
||||
ownDuration: [2999, 3000, 3000, 3000, 3000, 2998, 2999, 2999, 3009, 3001, 3001],
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'train.mov',
|
||||
video: {
|
||||
codecName: 'hevc',
|
||||
formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
|
||||
formatLongName: 'QuickTime / MOV',
|
||||
pixelFormat: 'yuv420p10le',
|
||||
bitrate: 12_595_191,
|
||||
frameCount: 1229,
|
||||
timeBase: 600,
|
||||
index: 0,
|
||||
profile: HevcProfile.Main10,
|
||||
level: 123,
|
||||
colorPrimaries: ColorPrimaries.Bt2020,
|
||||
colorTransfer: ColorTransfer.AribStdB67,
|
||||
colorMatrix: ColorMatrix.Bt2020Nc,
|
||||
dvProfile: DvProfile.Dvhe08,
|
||||
dvLevel: 5,
|
||||
dvBlSignalCompatibilityId: DvSignalCompatibility.Hlg,
|
||||
},
|
||||
audio: { codecName: 'aac', bitrate: 175_477, index: 1, profile: AacProfile.Lc },
|
||||
keyframes: {
|
||||
totalDuration: 12_290,
|
||||
packetCount: 1229,
|
||||
outputFrames: 1303,
|
||||
pts: [
|
||||
0, 601, 1201, 1802, 2402, 3003, 3604, 4204, 4805, 5405, 6006, 6607, 7207, 7808, 8408, 9009, 9609, 10_210,
|
||||
10_811, 11_411, 12_062, 12_703,
|
||||
],
|
||||
accDuration: [
|
||||
10, 580, 1180, 1780, 2380, 2980, 3580, 4180, 4780, 5380, 5980, 6580, 7180, 7780, 8380, 8980, 9580, 10_180,
|
||||
10_780, 11_380, 11_780, 12_100,
|
||||
],
|
||||
ownDuration: [10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const isExpected = <T extends keyof DB>(name: T, id: string, expected: Omit<DB[T], 'assetId'>) => {
|
||||
const { table, ref } = database.dynamic;
|
||||
const res = database.selectFrom(table(name).as('t')).selectAll().where(ref('assetId'), '=', id).executeTakeFirst();
|
||||
return expect(res).resolves.toEqual({ ...expected, assetId: id });
|
||||
};
|
||||
const fixtures = [eiffelTower, waterfall, train];
|
||||
|
||||
describe('video metadata extraction', () => {
|
||||
it.each(fixtures)('$file', async ({ file, video, audio, keyframes }) => {
|
||||
it.each(fixtures)('$originalPath', async ({ originalPath: path, videoStream, audioStream, packets, format }) => {
|
||||
const ctx = new ExifTestContext(database);
|
||||
const { user } = await ctx.newUser();
|
||||
const originalPath = resolve(testAssetsDir, 'videos', file);
|
||||
const originalPath = resolve(testAssetsDir, 'videos', path);
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath, type: AssetType.Video });
|
||||
|
||||
await ctx.sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
await isExpected('asset_audio', asset.id, audio);
|
||||
await isExpected('asset_video', asset.id, video);
|
||||
await isExpected('asset_keyframe', asset.id, keyframes);
|
||||
const result = await database
|
||||
.selectFrom('asset')
|
||||
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.innerJoin('asset_video', 'asset.id', 'asset_video.assetId')
|
||||
.innerJoin('asset_keyframe', 'asset.id', 'asset_keyframe.assetId')
|
||||
.leftJoin('asset_audio', 'asset.id', 'asset_audio.assetId')
|
||||
.where('asset.id', '=', asset.id)
|
||||
.select((eb) => withVideoStream(eb).$notNull().as('videoStream'))
|
||||
.select((eb) => withAudioStream(eb).as('audioStream'))
|
||||
.select((eb) => withVideoPackets(eb).$notNull().as('packets'))
|
||||
.select((eb) => withVideoFormat(eb).$notNull().as('format'))
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(result).toEqual({ videoStream, audioStream, packets, format });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,10 +54,6 @@ export const envData: EnvData = {
|
||||
otel: {
|
||||
metrics: {
|
||||
hostMetrics: false,
|
||||
apiMetrics: {
|
||||
enable: false,
|
||||
ignoreRoutes: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user