mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat(server)!: add owned filter to albums API
BREAKING CHANGE: GET /albums with no parameters now returns all accessible albums (owned + shared-with-me) instead of only owned albums.
This commit is contained in:
@@ -65,10 +65,13 @@ const UpdateAlbumSchema = z
|
||||
|
||||
const GetAlbumsSchema = z
|
||||
.object({
|
||||
owned: stringToBool
|
||||
.optional()
|
||||
.describe('Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter'),
|
||||
shared: 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 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' });
|
||||
|
||||
|
||||
@@ -201,6 +201,81 @@ export class AlbumRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getAllAccessible(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),
|
||||
)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.where(({ exists, selectFrom }) =>
|
||||
exists(
|
||||
selectFrom('album_user as au')
|
||||
.whereRef('au.albumId', '=', 'album.id')
|
||||
.where('au.role', '=', sql.lit(AlbumUserRole.Owner)),
|
||||
),
|
||||
)
|
||||
.select(withAlbumUsers(ownerId))
|
||||
.select(withSharedLink)
|
||||
.orderBy('album.createdAt', 'desc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getSharedWithMe(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(({ exists, selectFrom }) =>
|
||||
exists(
|
||||
selectFrom('album_user as au')
|
||||
.whereRef('au.albumId', '=', 'album.id')
|
||||
.where('au.role', '=', sql.lit(AlbumUserRole.Owner)),
|
||||
),
|
||||
)
|
||||
.select(withAlbumUsers(ownerId))
|
||||
.select(withSharedLink)
|
||||
.orderBy('album.createdAt', 'desc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getOwnedAndShared(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(({ or, exists, selectFrom }) =>
|
||||
or([
|
||||
exists(
|
||||
selectFrom('album_user as au')
|
||||
.whereRef('au.albumId', '=', 'album.id')
|
||||
.where('au.role', '!=', sql.lit(AlbumUserRole.Owner)),
|
||||
),
|
||||
exists(selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id')),
|
||||
]),
|
||||
)
|
||||
.select(withAlbumUsers(ownerId))
|
||||
.select(withSharedLink)
|
||||
.orderBy('album.createdAt', 'desc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get albums shared with and shared by owner.
|
||||
*/
|
||||
|
||||
@@ -46,7 +46,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.getAllAccessible.mockResolvedValue([getForAlbum(album), getForAlbum(sharedWithUserAlbum)]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
@@ -134,12 +134,60 @@ describe(AlbumService.name, () => {
|
||||
expect(result[0].id).toEqual(album.id);
|
||||
expect(mocks.album.getNotShared).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('gets only owned albums when owned=true', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
|
||||
mocks.album.getOwned.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), { owned: true });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mocks.album.getOwned).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('gets only shared-with-me albums when owned=false', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
|
||||
mocks.album.getSharedWithMe.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), { owned: false });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mocks.album.getSharedWithMe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('gets owned shared-out albums when owned=true and shared=true', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
|
||||
mocks.album.getOwnedAndShared.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), { owned: true, shared: true });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mocks.album.getOwnedAndShared).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns empty list when owned=false and shared=false', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
|
||||
|
||||
const result = await sut.getAll(AuthFactory.create(owner), { owned: false, shared: false });
|
||||
expect(result).toHaveLength(0);
|
||||
expect(mocks.album.getMetadataForIds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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.getAllAccessible.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.getAllAccessible).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
|
||||
@@ -38,18 +38,35 @@ export class AlbumService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
|
||||
async getAll(
|
||||
{ user: { id: ownerId } }: AuthDto,
|
||||
{ assetId, owned, shared }: GetAlbumsDto,
|
||||
): Promise<AlbumResponseDto[]> {
|
||||
await this.albumRepository.updateThumbnails();
|
||||
|
||||
let albums: MapAlbumDto[];
|
||||
if (assetId) {
|
||||
albums = await this.albumRepository.getByAssetId(ownerId, assetId);
|
||||
} else if (owned === false && shared === false) {
|
||||
albums = [];
|
||||
} else if (owned === false) {
|
||||
albums = await this.albumRepository.getSharedWithMe(ownerId);
|
||||
} else if (shared === true) {
|
||||
albums = await this.albumRepository.getShared(ownerId);
|
||||
albums =
|
||||
owned === true
|
||||
? await this.albumRepository.getOwnedAndShared(ownerId)
|
||||
: await this.albumRepository.getShared(ownerId);
|
||||
} else if (shared === false) {
|
||||
albums = await this.albumRepository.getNotShared(ownerId);
|
||||
} else {
|
||||
albums = await this.albumRepository.getOwned(ownerId);
|
||||
albums =
|
||||
owned === true
|
||||
? await this.albumRepository.getOwned(ownerId)
|
||||
: await this.albumRepository.getAllAccessible(ownerId);
|
||||
}
|
||||
|
||||
if (albums.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get asset count for each album. Then map the result to an object:
|
||||
|
||||
Reference in New Issue
Block a user