diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 8e981c58242c4d..c071ae282be5e1 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -97,7 +97,7 @@ services: command: ['./run.sh', '-disable-reporting'] ports: - 3000:3000 - image: grafana/grafana:12.4.7-ubuntu@sha256:d327f509400334fbea74a282d287f20c3187222741ec99e76f253cac16c90129 + image: grafana/grafana:12.4.8-ubuntu@sha256:473cfc1712694135a5d9dc012dc573f2a48e51efb9ac0da50418918f3cbd8ad3 volumes: - grafana-data:/var/lib/grafana diff --git a/e2e/src/specs/server/api/memory.e2e-spec.ts b/e2e/src/specs/server/api/memory.e2e-spec.ts deleted file mode 100644 index 38c6ba875aad64..00000000000000 --- a/e2e/src/specs/server/api/memory.e2e-spec.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { - AssetMediaResponseDto, - LoginResponseDto, - MemoryResponseDto, - MemoryType, - createMemory, - getMemory, -} from '@immich/sdk'; -import { createUserDto } from 'src/fixtures'; -import { app, asBearerAuth, utils } from 'src/utils'; -import request from 'supertest'; -import { beforeAll, describe, expect, it } from 'vitest'; - -describe('/memories', () => { - let admin: LoginResponseDto; - let user: LoginResponseDto; - let adminAsset: AssetMediaResponseDto; - let userAsset1: AssetMediaResponseDto; - let userMemory: MemoryResponseDto; - - beforeAll(async () => { - await utils.resetDatabase(); - - admin = await utils.adminSetup(); - user = await utils.userSetup(admin.accessToken, createUserDto.user1); - [adminAsset, userAsset1] = await Promise.all([ - utils.createAsset(admin.accessToken), - utils.createAsset(user.accessToken), - ]); - userMemory = await createMemory( - { - memoryCreateDto: { - type: MemoryType.OnThisDay, - memoryAt: new Date(2021).toISOString(), - data: { year: 2021 }, - assetIds: [], - }, - }, - { headers: asBearerAuth(user.accessToken) }, - ); - }); - - describe('GET /memories/:id', () => { - it('should get the memory', async () => { - const { status, body } = await request(app) - .get(`/memories/${userMemory.id}`) - .set('Authorization', `Bearer ${user.accessToken}`); - expect(status).toBe(200); - expect(body).toMatchObject({ id: userMemory.id }); - }); - }); - - describe('PUT /memories/:id', () => { - it('should update the memory', async () => { - const before = await getMemory({ id: userMemory.id }, { headers: asBearerAuth(user.accessToken) }); - expect(before.isSaved).toBe(false); - - const { status, body } = await request(app) - .put(`/memories/${userMemory.id}`) - .send({ isSaved: true }) - .set('Authorization', `Bearer ${user.accessToken}`); - expect(status).toBe(200); - expect(body).toMatchObject({ - id: userMemory.id, - isSaved: true, - }); - }); - }); - - describe('PUT /memories/:id/assets', () => { - it('should require asset access', async () => { - const { status, body } = await request(app) - .put(`/memories/${userMemory.id}/assets`) - .send({ ids: [adminAsset.id] }) - .set('Authorization', `Bearer ${user.accessToken}`); - expect(status).toBe(200); - expect(body).toHaveLength(1); - expect(body[0]).toEqual({ - id: adminAsset.id, - success: false, - error: 'no_permission', - }); - }); - - it('should add assets to the memory', async () => { - const { status, body } = await request(app) - .put(`/memories/${userMemory.id}/assets`) - .send({ ids: [userAsset1.id] }) - .set('Authorization', `Bearer ${user.accessToken}`); - expect(status).toBe(200); - expect(body).toHaveLength(1); - expect(body[0]).toEqual({ id: userAsset1.id, success: true }); - }); - }); - - describe('DELETE /memories/:id/assets', () => { - it('should only remove assets in the memory', async () => { - const { status, body } = await request(app) - .delete(`/memories/${userMemory.id}/assets`) - .send({ ids: [adminAsset.id] }) - .set('Authorization', `Bearer ${user.accessToken}`); - expect(status).toBe(200); - expect(body).toHaveLength(1); - expect(body[0]).toEqual({ - id: adminAsset.id, - success: false, - error: 'not_found', - }); - }); - - it('should remove assets from the memory', async () => { - const { status, body } = await request(app) - .delete(`/memories/${userMemory.id}/assets`) - .send({ ids: [userAsset1.id] }) - .set('Authorization', `Bearer ${user.accessToken}`); - expect(status).toBe(200); - expect(body).toHaveLength(1); - expect(body[0]).toEqual({ id: userAsset1.id, success: true }); - }); - }); - - describe('DELETE /memories/:id', () => { - it('should delete the memory', async () => { - const { status } = await request(app) - .delete(`/memories/${userMemory.id}`) - .send({ isSaved: true }) - .set('Authorization', `Bearer ${user.accessToken}`); - expect(status).toBe(204); - }); - }); -}); diff --git a/mobile/lib/domain/models/config/cleanup_config.dart b/mobile/lib/domain/models/config/cleanup_config.dart index 4b34814492c2f4..e87494e9a7b5ec 100644 --- a/mobile/lib/domain/models/config/cleanup_config.dart +++ b/mobile/lib/domain/models/config/cleanup_config.dart @@ -1,48 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/constants/enums.dart'; -class CleanupConfig { - final bool keepFavorites; - final AssetKeepType keepMediaType; - final List keepAlbumIds; - final int cutoffDaysAgo; - final bool defaultsInitialized; - - const CleanupConfig({ - this.keepFavorites = true, - this.keepMediaType = AssetKeepType.none, - this.keepAlbumIds = const [], - this.cutoffDaysAgo = -1, - this.defaultsInitialized = false, - }); - - CleanupConfig copyWith({ - bool? keepFavorites, - AssetKeepType? keepMediaType, - List? keepAlbumIds, - int? cutoffDaysAgo, - bool? defaultsInitialized, - }) => .new( - keepFavorites: keepFavorites ?? this.keepFavorites, - keepMediaType: keepMediaType ?? this.keepMediaType, - keepAlbumIds: keepAlbumIds ?? this.keepAlbumIds, - cutoffDaysAgo: cutoffDaysAgo ?? this.cutoffDaysAgo, - defaultsInitialized: defaultsInitialized ?? this.defaultsInitialized, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is CleanupConfig && - other.keepFavorites == keepFavorites && - other.keepMediaType == keepMediaType && - other.keepAlbumIds == keepAlbumIds && - other.cutoffDaysAgo == cutoffDaysAgo && - other.defaultsInitialized == defaultsInitialized); - - @override - int get hashCode => Object.hash(keepFavorites, keepMediaType, keepAlbumIds, cutoffDaysAgo, defaultsInitialized); - - @override - String toString() => - 'CleanupConfig(keepFavorites: $keepFavorites, keepMediaType: $keepMediaType, keepAlbumIds: $keepAlbumIds, cutoffDaysAgo: $cutoffDaysAgo, defaultsInitialized: $defaultsInitialized)'; +part 'cleanup_config.freezed.dart'; + +@freezed +abstract class CleanupConfig with _$CleanupConfig { + const factory CleanupConfig({ + @Default(true) bool keepFavorites, + @Default(AssetKeepType.none) AssetKeepType keepMediaType, + @Default([]) List keepAlbumIds, + @Default(-1) int cutoffDaysAgo, + @Default(false) bool defaultsInitialized, + }) = _CleanupConfig; } diff --git a/mobile/lib/domain/models/config/map_config.dart b/mobile/lib/domain/models/config/map_config.dart index 85cac80081e472..bf82b889331bcc 100644 --- a/mobile/lib/domain/models/config/map_config.dart +++ b/mobile/lib/domain/models/config/map_config.dart @@ -1,25 +1,24 @@ import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/utils/option.dart'; -class MapConfig { - final int relativeDays; - final bool favoritesOnly; - final bool includeArchived; - final ThemeMode themeMode; - final bool withPartners; - final DateTime? customFrom; - final DateTime? customTo; +part 'map_config.freezed.dart'; - const MapConfig({ - this.relativeDays = 0, - this.favoritesOnly = false, - this.includeArchived = false, - this.themeMode = .system, - this.withPartners = false, - this.customFrom, - this.customTo, - }); +@Freezed(copyWith: false) +abstract class MapConfig with _$MapConfig { + const MapConfig._(); + const factory MapConfig({ + @Default(0) int relativeDays, + @Default(false) bool favoritesOnly, + @Default(false) bool includeArchived, + @Default(ThemeMode.system) ThemeMode themeMode, + @Default(false) bool withPartners, + DateTime? customFrom, + DateTime? customTo, + }) = _MapConfig; + + // We patch `customFrom` and `customTo`, which prevents us from using Freezed `copyWith` MapConfig copyWith({ int? relativeDays, bool? favoritesOnly, @@ -37,24 +36,4 @@ class MapConfig { customFrom: customFrom.patch(this.customFrom), customTo: customTo.patch(this.customTo), ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is MapConfig && - other.relativeDays == relativeDays && - other.favoritesOnly == favoritesOnly && - other.includeArchived == includeArchived && - other.themeMode == themeMode && - other.withPartners == withPartners && - other.customFrom == customFrom && - other.customTo == customTo); - - @override - int get hashCode => - Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners, customFrom, customTo); - - @override - String toString() => - 'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners, customFrom: $customFrom, customTo: $customTo)'; } diff --git a/mobile/lib/domain/models/config/network_config.dart b/mobile/lib/domain/models/config/network_config.dart index 979b6a2eb7310c..97e0167a94008f 100644 --- a/mobile/lib/domain/models/config/network_config.dart +++ b/mobile/lib/domain/models/config/network_config.dart @@ -1,21 +1,21 @@ -import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/utils/option.dart'; -class NetworkConfig { - final bool autoEndpointSwitching; - final String? preferredWifiName; - final String? localEndpoint; - final List externalEndpointList; - final Map customHeaders; +part 'network_config.freezed.dart'; - const NetworkConfig({ - this.autoEndpointSwitching = false, - this.preferredWifiName, - this.localEndpoint, - this.externalEndpointList = const [], - this.customHeaders = const {}, - }); +@Freezed(copyWith: false) +abstract class NetworkConfig with _$NetworkConfig { + const NetworkConfig._(); + const factory NetworkConfig({ + @Default(false) bool autoEndpointSwitching, + String? preferredWifiName, + String? localEndpoint, + @Default([]) List externalEndpointList, + @Default({}) Map customHeaders, + }) = _NetworkConfig; + + // We patch `preferredWifiName` and `localEndpoint`, which prevents us from using Freezed `copyWith` NetworkConfig copyWith({ bool? autoEndpointSwitching, Option? preferredWifiName, @@ -29,27 +29,4 @@ class NetworkConfig { externalEndpointList: externalEndpointList ?? this.externalEndpointList, customHeaders: customHeaders ?? this.customHeaders, ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is NetworkConfig && - other.autoEndpointSwitching == autoEndpointSwitching && - other.preferredWifiName == preferredWifiName && - other.localEndpoint == localEndpoint && - listEquals(other.externalEndpointList, externalEndpointList) && - mapEquals(other.customHeaders, customHeaders)); - - @override - int get hashCode => Object.hash( - autoEndpointSwitching, - preferredWifiName, - localEndpoint, - Object.hashAll(externalEndpointList), - Object.hashAllUnordered(customHeaders.entries.map((e) => Object.hash(e.key, e.value))), - ); - - @override - String toString() => - 'NetworkConfig(autoEndpointSwitching: $autoEndpointSwitching, preferredWifiName: $preferredWifiName, localEndpoint: $localEndpoint, externalEndpointList: $externalEndpointList, customHeaders: $customHeaders)'; } diff --git a/mobile/lib/domain/models/stack.model.dart b/mobile/lib/domain/models/stack.model.dart index 4e88a02c6cd449..a016d6cd3ddc66 100644 --- a/mobile/lib/domain/models/stack.model.dart +++ b/mobile/lib/domain/models/stack.model.dart @@ -15,22 +15,8 @@ abstract class Stack with _$Stack { }) = _Stack; } -class StackResponse { - final String id; - final String primaryAssetId; - final List assetIds; - - const StackResponse({required this.id, required this.primaryAssetId, required this.assetIds}); - - @override - bool operator ==(covariant StackResponse other) { - if (identical(this, other)) { - return true; - } - - return other.id == id && other.primaryAssetId == primaryAssetId && other.assetIds == assetIds; - } - - @override - int get hashCode => id.hashCode ^ primaryAssetId.hashCode ^ assetIds.hashCode; +@freezed +abstract class StackResponse with _$StackResponse { + const factory StackResponse({required String id, required String primaryAssetId, required List assetIds}) = + _StackResponse; } diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index be1b0c5fb84346..d662dd3425fc42 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -1,5 +1,8 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; +part 'store.model.freezed.dart'; + /// Key for each possible value in the `Store`. /// Defines the data type for each value enum StoreKey { @@ -63,30 +66,7 @@ enum StoreKey { Type get type => T; } -class StoreDto { - final StoreKey key; - final T? value; - - const StoreDto(this.key, this.value); - - @override - String toString() { - return ''' -StoreDto: { - key: $key, - value: ${value ?? ''}, -}'''; - } - - @override - bool operator ==(covariant StoreDto other) { - if (identical(this, other)) { - return true; - } - - return other.key == key && other.value == value; - } - - @override - int get hashCode => key.hashCode ^ value.hashCode; +@freezed +abstract class StoreDto with _$StoreDto { + const factory StoreDto(StoreKey key, T? value) = _StoreDto; } diff --git a/mobile/lib/domain/models/time_range.model.dart b/mobile/lib/domain/models/time_range.model.dart index 2727a9d5c822f3..b0b990d6db3075 100644 --- a/mobile/lib/domain/models/time_range.model.dart +++ b/mobile/lib/domain/models/time_range.model.dart @@ -1,14 +1,17 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/utils/option.dart'; -class TimeRange { - final DateTime? from; - final DateTime? to; +part 'time_range.model.freezed.dart'; - const TimeRange({this.from, this.to}); +@Freezed(copyWith: false) +abstract class TimeRange with _$TimeRange { + const TimeRange._(); - TimeRange copyWith({Option? from, Option? to}) { - return TimeRange(from: from.patch(this.from), to: to.patch(this.to)); - } + const factory TimeRange({DateTime? from, DateTime? to}) = _TimeRange; + + // Patching is custom, which prevents using Freezed `copyWith` + TimeRange copyWith({Option? from, Option? to}) => + TimeRange(from: from.patch(this.from), to: to.patch(this.to)); TimeRange clearFrom() => TimeRange(to: to); TimeRange clearTo() => TimeRange(from: from); diff --git a/mobile/lib/domain/models/user.model.dart b/mobile/lib/domain/models/user.model.dart index 181fddf5811479..e48992980b2464 100644 --- a/mobile/lib/domain/models/user.model.dart +++ b/mobile/lib/domain/models/user.model.dart @@ -35,96 +35,31 @@ enum AvatarColor { } // TODO: Rename to User once Isar is removed -class UserDto { - final String id; - final String email; - final String name; - final bool isAdmin; - final DateTime? updatedAt; - - final AvatarColor avatarColor; +@Freezed(equal: false) +abstract class UserDto with _$UserDto { + const UserDto._(); - final bool memoryEnabled; - final bool inTimeline; - - final bool isPartnerSharedBy; - final bool isPartnerSharedWith; - - final int quotaUsageInBytes; - final int quotaSizeInBytes; + const factory UserDto({ + required String id, + required String email, + required String name, + @Default(false) bool isAdmin, + DateTime? updatedAt, + required DateTime profileChangedAt, + @Default(AvatarColor.primary) AvatarColor avatarColor, + @Default(true) bool memoryEnabled, + @Default(false) bool inTimeline, + @Default(false) bool isPartnerSharedBy, + @Default(false) bool isPartnerSharedWith, + @Default(false) bool hasProfileImage, + @Default(0) int quotaUsageInBytes, + @Default(0) int quotaSizeInBytes, + }) = _UserDto; bool get hasQuota => quotaSizeInBytes > 0; - final bool hasProfileImage; - final DateTime profileChangedAt; - - const UserDto({ - required this.id, - required this.email, - required this.name, - this.isAdmin = false, - this.updatedAt, - required this.profileChangedAt, - this.avatarColor = AvatarColor.primary, - this.memoryEnabled = true, - this.inTimeline = false, - this.isPartnerSharedBy = false, - this.isPartnerSharedWith = false, - this.hasProfileImage = false, - this.quotaUsageInBytes = 0, - this.quotaSizeInBytes = 0, - }); - - @override - String toString() { - return '''User: { -id: $id, -email: $email, -name: $name, -isAdmin: $isAdmin, -updatedAt: $updatedAt, -avatarColor: $avatarColor, -memoryEnabled: $memoryEnabled, -inTimeline: $inTimeline, -isPartnerSharedBy: $isPartnerSharedBy, -isPartnerSharedWith: $isPartnerSharedWith, -hasProfileImage: $hasProfileImage -profileChangedAt: $profileChangedAt -}'''; - } - - UserDto copyWith({ - String? id, - String? email, - String? name, - bool? isAdmin, - DateTime? updatedAt, - AvatarColor? avatarColor, - bool? memoryEnabled, - bool? inTimeline, - bool? isPartnerSharedBy, - bool? isPartnerSharedWith, - bool? hasProfileImage, - DateTime? profileChangedAt, - int? quotaSizeInBytes, - int? quotaUsageInBytes, - }) => UserDto( - id: id ?? this.id, - email: email ?? this.email, - name: name ?? this.name, - isAdmin: isAdmin ?? this.isAdmin, - updatedAt: updatedAt ?? this.updatedAt, - avatarColor: avatarColor ?? this.avatarColor, - memoryEnabled: memoryEnabled ?? this.memoryEnabled, - inTimeline: inTimeline ?? this.inTimeline, - isPartnerSharedBy: isPartnerSharedBy ?? this.isPartnerSharedBy, - isPartnerSharedWith: isPartnerSharedWith ?? this.isPartnerSharedWith, - hasProfileImage: hasProfileImage ?? this.hasProfileImage, - profileChangedAt: profileChangedAt ?? this.profileChangedAt, - quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes, - quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes, - ); - + // We use [DateTime.isAtSameMomentAs] for comparing across timezones. As Freezed doesn't support custom equality, we need to have our own `==` for now + // TODO(agg23): Switch to newtypes to fix equality @override bool operator ==(covariant UserDto other) { if (identical(this, other)) { diff --git a/mobile/lib/models/download/livephotos_medatada.model.dart b/mobile/lib/models/download/livephotos_medatada.model.dart index 833a9ffca77c60..40795142bcf2f3 100644 --- a/mobile/lib/models/download/livephotos_medatada.model.dart +++ b/mobile/lib/models/download/livephotos_medatada.model.dart @@ -1,17 +1,16 @@ import 'dart:convert'; -enum LivePhotosPart { video, image } +import 'package:freezed_annotation/freezed_annotation.dart'; -class LivePhotosMetadata { - // enum - LivePhotosPart part; +part 'livephotos_medatada.model.freezed.dart'; - String id; - LivePhotosMetadata({required this.part, required this.id}); +enum LivePhotosPart { video, image } - LivePhotosMetadata copyWith({LivePhotosPart? part, String? id}) { - return LivePhotosMetadata(part: part ?? this.part, id: id ?? this.id); - } +@Freezed(fromJson: false, toJson: false) +abstract class LivePhotosMetadata with _$LivePhotosMetadata { + const LivePhotosMetadata._(); + + const factory LivePhotosMetadata({required LivePhotosPart part, required String id}) = _LivePhotosMetadata; Map toMap() { return {'part': part.index, 'id': id}; @@ -25,19 +24,4 @@ class LivePhotosMetadata { factory LivePhotosMetadata.fromJson(String source) => LivePhotosMetadata.fromMap(json.decode(source) as Map); - - @override - String toString() => 'LivePhotosMetadata(part: $part, id: $id)'; - - @override - bool operator ==(covariant LivePhotosMetadata other) { - if (identical(this, other)) { - return true; - } - - return other.part == part && other.id == id; - } - - @override - int get hashCode => part.hashCode ^ id.hashCode; } diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 03660204b92e4c..4c996da12d2a45 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -7,15 +7,11 @@ import 'package:immich_mobile/utils/option.dart'; part 'search_filter.model.freezed.dart'; -class SearchLocationFilter { - String? country; - String? state; - String? city; - SearchLocationFilter({this.country, this.state, this.city}); +@Freezed(fromJson: false, toJson: false) +abstract class SearchLocationFilter with _$SearchLocationFilter { + const SearchLocationFilter._(); - SearchLocationFilter copyWith({String? country, String? state, String? city}) { - return SearchLocationFilter(country: country ?? this.country, state: state ?? this.state, city: city ?? this.city); - } + const factory SearchLocationFilter({String? country, String? state, String? city}) = _SearchLocationFilter; Map toMap() { return {'country': country, 'state': state, 'city': city}; @@ -33,31 +29,13 @@ class SearchLocationFilter { factory SearchLocationFilter.fromJson(String source) => SearchLocationFilter.fromMap(json.decode(source) as Map); - - @override - String toString() => 'SearchLocationFilter(country: $country, state: $state, city: $city)'; - - @override - bool operator ==(covariant SearchLocationFilter other) { - if (identical(this, other)) { - return true; - } - - return other.country == country && other.state == state && other.city == city; - } - - @override - int get hashCode => country.hashCode ^ state.hashCode ^ city.hashCode; } -class SearchCameraFilter { - String? make; - String? model; - SearchCameraFilter({this.make, this.model}); +@Freezed(fromJson: false, toJson: false) +abstract class SearchCameraFilter with _$SearchCameraFilter { + const SearchCameraFilter._(); - SearchCameraFilter copyWith({String? make, String? model}) { - return SearchCameraFilter(make: make ?? this.make, model: model ?? this.model); - } + const factory SearchCameraFilter({String? make, String? model}) = _SearchCameraFilter; Map toMap() { return {'make': make, 'model': model}; @@ -74,31 +52,13 @@ class SearchCameraFilter { factory SearchCameraFilter.fromJson(String source) => SearchCameraFilter.fromMap(json.decode(source) as Map); - - @override - String toString() => 'SearchCameraFilter(make: $make, model: $model)'; - - @override - bool operator ==(covariant SearchCameraFilter other) { - if (identical(this, other)) { - return true; - } - - return other.make == make && other.model == model; - } - - @override - int get hashCode => make.hashCode ^ model.hashCode; } -class SearchDateFilter { - DateTime? takenBefore; - DateTime? takenAfter; - SearchDateFilter({this.takenBefore, this.takenAfter}); +@Freezed(fromJson: false, toJson: false) +abstract class SearchDateFilter with _$SearchDateFilter { + const SearchDateFilter._(); - SearchDateFilter copyWith({DateTime? takenBefore, DateTime? takenAfter}) { - return SearchDateFilter(takenBefore: takenBefore ?? this.takenBefore, takenAfter: takenAfter ?? this.takenAfter); - } + const factory SearchDateFilter({DateTime? takenBefore, DateTime? takenAfter}) = _SearchDateFilter; Map toMap() { return { @@ -118,31 +78,15 @@ class SearchDateFilter { factory SearchDateFilter.fromJson(String source) => SearchDateFilter.fromMap(json.decode(source) as Map); - - @override - String toString() => 'SearchDateFilter(takenBefore: $takenBefore, takenAfter: $takenAfter)'; - - @override - bool operator ==(covariant SearchDateFilter other) { - if (identical(this, other)) { - return true; - } - - return other.takenBefore == takenBefore && other.takenAfter == takenAfter; - } - - @override - int get hashCode => takenBefore.hashCode ^ takenAfter.hashCode; } -class SearchRatingFilter { - /// none = no filter; some(null) = filter for unrated; some(1-5) = filter for that rating - Option rating; - SearchRatingFilter({this.rating = const Option.none()}); +@Freezed(fromJson: false, toJson: false) +abstract class SearchRatingFilter with _$SearchRatingFilter { + const SearchRatingFilter._(); - SearchRatingFilter copyWith({Option? rating}) { - return SearchRatingFilter(rating: rating ?? this.rating); - } + /// [rating]: none = no filter; some(null) = filter for unrated; some(1-5) = filter for that rating + // TODO(agg23): Switch to enum + const factory SearchRatingFilter({@Default(Option.none()) Option rating}) = _SearchRatingFilter; Map toMap() { if (rating.isNone) { @@ -153,7 +97,7 @@ class SearchRatingFilter { factory SearchRatingFilter.fromMap(Map map) { if (!(map['active'] as bool? ?? false)) { - return SearchRatingFilter(); + return const SearchRatingFilter(); } return SearchRatingFilter(rating: Option.some(map['value'] as int?)); } @@ -162,21 +106,6 @@ class SearchRatingFilter { factory SearchRatingFilter.fromJson(String source) => SearchRatingFilter.fromMap(json.decode(source) as Map); - - @override - String toString() => 'SearchRatingFilter(rating: $rating)'; - - @override - bool operator ==(covariant SearchRatingFilter other) { - if (identical(this, other)) { - return true; - } - - return other.rating == rating; - } - - @override - int get hashCode => rating.hashCode; } @freezed @@ -185,40 +114,26 @@ abstract class SearchDisplayFilters with _$SearchDisplayFilters { _SearchDisplayFilters; } -class SearchFilter { - String? context; - String? filename; - String? description; - String? ocr; - String? language; - String? assetId; - List? tagIds; - Set people; - SearchLocationFilter location; - SearchCameraFilter camera; - SearchDateFilter date; - SearchRatingFilter rating; - SearchDisplayFilters display; - - // Enum - AssetType mediaType; +@freezed +abstract class SearchFilter with _$SearchFilter { + const SearchFilter._(); - SearchFilter({ - this.context, - this.filename, - this.description, - this.ocr, - this.language, - this.assetId, - this.tagIds, - required this.people, - required this.location, - required this.camera, - required this.date, - required this.display, - required this.rating, - required this.mediaType, - }); + const factory SearchFilter({ + String? context, + String? filename, + String? description, + String? ocr, + String? language, + String? assetId, + List? tagIds, + required Set people, + required SearchLocationFilter location, + required SearchCameraFilter camera, + required SearchDateFilter date, + required SearchRatingFilter rating, + required SearchDisplayFilters display, + required AssetType mediaType, + }) = _SearchFilter; bool get isEmpty { return (context == null || (context != null && context!.isEmpty)) && @@ -241,83 +156,4 @@ class SearchFilter { rating.rating.isNone && mediaType == AssetType.other; } - - SearchFilter copyWith({ - String? context, - String? filename, - String? description, - String? language, - String? ocr, - String? assetId, - Set? people, - List? tagIds, - SearchLocationFilter? location, - SearchCameraFilter? camera, - SearchDateFilter? date, - SearchDisplayFilters? display, - SearchRatingFilter? rating, - AssetType? mediaType, - }) { - return SearchFilter( - context: context ?? this.context, - filename: filename ?? this.filename, - description: description ?? this.description, - language: language ?? this.language, - ocr: ocr ?? this.ocr, - assetId: assetId ?? this.assetId, - people: people ?? this.people, - location: location ?? this.location, - camera: camera ?? this.camera, - date: date ?? this.date, - display: display ?? this.display, - rating: rating ?? this.rating, - mediaType: mediaType ?? this.mediaType, - tagIds: tagIds ?? this.tagIds, - ); - } - - @override - String toString() { - return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tagIds: $tagIds, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)'; - } - - @override - bool operator ==(covariant SearchFilter other) { - if (identical(this, other)) { - return true; - } - - return other.context == context && - other.filename == filename && - other.description == description && - other.language == language && - other.ocr == ocr && - other.assetId == assetId && - other.people == people && - other.tagIds == tagIds && - other.location == location && - other.camera == camera && - other.date == date && - other.display == display && - other.rating == rating && - other.mediaType == mediaType; - } - - @override - int get hashCode { - return context.hashCode ^ - filename.hashCode ^ - description.hashCode ^ - language.hashCode ^ - ocr.hashCode ^ - assetId.hashCode ^ - people.hashCode ^ - tagIds.hashCode ^ - location.hashCode ^ - camera.hashCode ^ - date.hashCode ^ - display.hashCode ^ - rating.hashCode ^ - mediaType.hashCode; - } } diff --git a/mobile/lib/models/server_info/server_config.model.dart b/mobile/lib/models/server_info/server_config.model.dart index 15bbe41485f70d..4293d098b91f8a 100644 --- a/mobile/lib/models/server_info/server_config.model.dart +++ b/mobile/lib/models/server_info/server_config.model.dart @@ -1,52 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:openapi/api.dart'; -class ServerConfig { - final int trashDays; - final String oauthButtonText; - final String externalDomain; - final String mapDarkStyleUrl; - final String mapLightStyleUrl; - - const ServerConfig({ - required this.trashDays, - required this.oauthButtonText, - required this.externalDomain, - required this.mapDarkStyleUrl, - required this.mapLightStyleUrl, - }); - - ServerConfig copyWith({int? trashDays, String? oauthButtonText, String? externalDomain}) { - return ServerConfig( - trashDays: trashDays ?? this.trashDays, - oauthButtonText: oauthButtonText ?? this.oauthButtonText, - externalDomain: externalDomain ?? this.externalDomain, - mapDarkStyleUrl: mapDarkStyleUrl, - mapLightStyleUrl: mapLightStyleUrl, - ); - } - - @override - String toString() => - 'ServerConfig(trashDays: $trashDays, oauthButtonText: $oauthButtonText, externalDomain: $externalDomain)'; - - ServerConfig.fromDto(ServerConfigDto dto) - : trashDays = dto.trashDays, - oauthButtonText = dto.oauthButtonText, - externalDomain = dto.externalDomain, - mapDarkStyleUrl = dto.mapDarkStyleUrl, - mapLightStyleUrl = dto.mapLightStyleUrl; - - @override - bool operator ==(covariant ServerConfig other) { - if (identical(this, other)) { - return true; - } - - return other.trashDays == trashDays && - other.oauthButtonText == oauthButtonText && - other.externalDomain == externalDomain; - } - - @override - int get hashCode => trashDays.hashCode ^ oauthButtonText.hashCode ^ externalDomain.hashCode; +part 'server_config.model.freezed.dart'; + +@freezed +abstract class ServerConfig with _$ServerConfig { + const factory ServerConfig({ + required int trashDays, + required String oauthButtonText, + required String externalDomain, + required String mapDarkStyleUrl, + required String mapLightStyleUrl, + }) = _ServerConfig; + + factory ServerConfig.fromDto(ServerConfigDto dto) => ServerConfig( + trashDays: dto.trashDays, + oauthButtonText: dto.oauthButtonText, + externalDomain: dto.externalDomain, + mapDarkStyleUrl: dto.mapDarkStyleUrl, + mapLightStyleUrl: dto.mapLightStyleUrl, + ); } diff --git a/mobile/lib/models/upload/share_intent_attachment.model.dart b/mobile/lib/models/upload/share_intent_attachment.model.dart index 3643020bc89845..2629bf3a0d5c6e 100644 --- a/mobile/lib/models/upload/share_intent_attachment.model.dart +++ b/mobile/lib/models/upload/share_intent_attachment.model.dart @@ -1,33 +1,27 @@ import 'dart:convert'; import 'dart:io'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:path/path.dart'; +part 'share_intent_attachment.model.freezed.dart'; + enum ShareIntentAttachmentType { image, video } enum UploadStatus { enqueued, running, complete, failed } -class ShareIntentAttachment { - final String path; - - // enum - final ShareIntentAttachmentType type; - - // enum - final UploadStatus status; - - final double uploadProgress; +@Freezed(fromJson: false, toJson: false, equal: false) +abstract class ShareIntentAttachment with _$ShareIntentAttachment { + const ShareIntentAttachment._(); - final int fileLength; - - ShareIntentAttachment({ - required this.path, - required this.type, - required this.status, - this.uploadProgress = 0, - this.fileLength = 0, - }); + const factory ShareIntentAttachment({ + required String path, + required ShareIntentAttachmentType type, + required UploadStatus status, + @Default(0.0) double uploadProgress, + @Default(0) int fileLength, + }) = _ShareIntentAttachment; int get id => hash(path); @@ -39,23 +33,7 @@ class ShareIntentAttachment { bool get isVideo => type == ShareIntentAttachmentType.video; - String? _fileSize; - - String get fileSize => _fileSize ??= formatHumanReadableBytes(fileLength, 2); - - ShareIntentAttachment copyWith({ - String? path, - ShareIntentAttachmentType? type, - UploadStatus? status, - double? uploadProgress, - }) { - return ShareIntentAttachment( - path: path ?? this.path, - type: type ?? this.type, - status: status ?? this.status, - uploadProgress: uploadProgress ?? this.uploadProgress, - ); - } + String get fileSize => formatHumanReadableBytes(fileLength, 2); Map toMap() { return { @@ -80,18 +58,14 @@ class ShareIntentAttachment { factory ShareIntentAttachment.fromJson(String source) => ShareIntentAttachment.fromMap(json.decode(source) as Map); + // Identity is sourced from the backing file, not from upload progress @override - String toString() { - return 'ShareIntentAttachment(path: $path, type: $type, status: $status, uploadProgress: $uploadProgress)'; - } - - @override - bool operator ==(covariant ShareIntentAttachment other) { + bool operator ==(Object other) { if (identical(this, other)) { return true; } - return other.path == path && other.type == type; + return other is ShareIntentAttachment && other.path == path && other.type == type; } @override diff --git a/mobile/lib/presentation/actions/similar_photos.action.dart b/mobile/lib/presentation/actions/similar_photos.action.dart index d8f63f245dce96..53bb29f09ae5a2 100644 --- a/mobile/lib/presentation/actions/similar_photos.action.dart +++ b/mobile/lib/presentation/actions/similar_photos.action.dart @@ -28,11 +28,11 @@ class SimilarPhotosAction extends ActionBuilder { .new( assetId: assetId, people: {}, - location: .new(), - camera: .new(), - date: .new(), + location: const .new(), + camera: const .new(), + date: const .new(), display: const .new(isNotInAlbum: false, isArchive: false, isFavorite: false), - rating: .new(), + rating: const .new(), mediaType: .other, ), ); diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index e364e0d2f8efd3..1f2fe506b2d56e 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -53,11 +53,11 @@ class DriftSearchPage extends HookConsumerWidget { final filter = useState( SearchFilter( people: {}, - location: SearchLocationFilter(), - camera: SearchCameraFilter(), - date: SearchDateFilter(), + location: const SearchLocationFilter(), + camera: const SearchCameraFilter(), + date: const SearchDateFilter(), display: const SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), - rating: SearchRatingFilter(), + rating: const SearchRatingFilter(), mediaType: AssetType.other, language: "${context.locale.languageCode}-${context.locale.countryCode}", tagIds: [], @@ -208,7 +208,7 @@ class DriftSearchPage extends HookConsumerWidget { void handleClear() { locationCurrentFilterWidget.value = null; - search(filter.value.copyWith(location: SearchLocationFilter())); + search(filter.value.copyWith(location: const SearchLocationFilter())); } void handleApply() { @@ -256,7 +256,7 @@ class DriftSearchPage extends HookConsumerWidget { void handleClear() { cameraCurrentFilterWidget.value = null; - search(filter.value.copyWith(camera: SearchCameraFilter())); + search(filter.value.copyWith(camera: const SearchCameraFilter())); } void handleApply() { @@ -290,7 +290,7 @@ class DriftSearchPage extends HookConsumerWidget { dateInputFilter.value = selectedDate; if (selectedDate == null) { dateRangeCurrentFilterWidget.value = null; - search(filter.value.copyWith(date: SearchDateFilter())); + search(filter.value.copyWith(date: const SearchDateFilter())); return; } @@ -419,7 +419,7 @@ class DriftSearchPage extends HookConsumerWidget { void handleClear() { ratingCurrentFilterWidget.value = null; - search(filter.value.copyWith(rating: SearchRatingFilter())); + search(filter.value.copyWith(rating: const SearchRatingFilter())); } void handleApply() { diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index 521f7d0f77d4be..b66063f73789f1 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -47,8 +47,8 @@ class _AlbumSelectorState extends ConsumerState { List sortedAlbums = []; List shownAlbums = []; - AlbumFilter filter = AlbumFilter(query: "", mode: QuickFilterMode.all); - AlbumSort sort = AlbumSort(mode: AlbumSortMode.lastModified, isReverse: true); + AlbumFilter filter = const AlbumFilter(query: "", mode: QuickFilterMode.all); + AlbumSort sort = const AlbumSort(mode: AlbumSortMode.lastModified, isReverse: true); @override void initState() { diff --git a/mobile/lib/presentation/widgets/map/map.state.dart b/mobile/lib/presentation/widgets/map/map.state.dart index eedf3aadf5491f..bc6436fc345171 100644 --- a/mobile/lib/presentation/widgets/map/map.state.dart +++ b/mobile/lib/presentation/widgets/map/map.state.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/time_range.model.dart'; @@ -12,25 +13,23 @@ import 'package:immich_mobile/providers/map/map_state.provider.dart'; import 'package:immich_mobile/utils/option.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -class MapState { - final ThemeMode themeMode; - final LatLngBounds bounds; - final bool onlyFavorites; - final bool includeArchived; - final bool withPartners; - final int relativeDays; - final TimeRange timeRange; - - const MapState({ - this.themeMode = ThemeMode.system, - required this.bounds, - this.onlyFavorites = false, - this.includeArchived = false, - this.withPartners = false, - this.relativeDays = 0, - this.timeRange = const TimeRange(), - }); +part 'map.state.freezed.dart'; +@Freezed(equal: false) +abstract class MapState with _$MapState { + const MapState._(); + + const factory MapState({ + @Default(ThemeMode.system) ThemeMode themeMode, + required LatLngBounds bounds, + @Default(false) bool onlyFavorites, + @Default(false) bool includeArchived, + @Default(false) bool withPartners, + @Default(0) int relativeDays, + @Default(TimeRange()) TimeRange timeRange, + }) = _MapState; + + // We only care about bounds changes, overriding Freezed @override bool operator ==(covariant MapState other) { return bounds == other.bounds; @@ -39,26 +38,6 @@ class MapState { @override int get hashCode => bounds.hashCode; - MapState copyWith({ - LatLngBounds? bounds, - ThemeMode? themeMode, - bool? onlyFavorites, - bool? includeArchived, - bool? withPartners, - int? relativeDays, - TimeRange? timeRange, - }) { - return MapState( - bounds: bounds ?? this.bounds, - themeMode: themeMode ?? this.themeMode, - onlyFavorites: onlyFavorites ?? this.onlyFavorites, - includeArchived: includeArchived ?? this.includeArchived, - withPartners: withPartners ?? this.withPartners, - relativeDays: relativeDays ?? this.relativeDays, - timeRange: timeRange ?? this.timeRange, - ); - } - TimelineMapOptions toOptions() => TimelineMapOptions( bounds: bounds, onlyFavorites: onlyFavorites, diff --git a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart index a2298313ace721..635e35875533f4 100644 --- a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; @@ -14,6 +15,8 @@ import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/utils/debounce.dart'; import 'package:intl/intl.dart' hide TextDirection; +part 'scrubber.widget.freezed.dart'; + /// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged /// for quick navigation of the BoxScrollView. class Scrubber extends ConsumerStatefulWidget { @@ -596,25 +599,12 @@ class _SlideFadeTransition extends StatelessWidget { } } -class _Segment { - final DateTime date; - final double startOffset; - final String scrollLabel; - final bool showSegment; - - const _Segment({required this.date, required this.startOffset, required this.scrollLabel, this.showSegment = false}); - - _Segment copyWith({DateTime? date, double? startOffset, String? scrollLabel, bool? showSegment}) { - return _Segment( - date: date ?? this.date, - startOffset: startOffset ?? this.startOffset, - scrollLabel: scrollLabel ?? this.scrollLabel, - showSegment: showSegment ?? this.showSegment, - ); - } - - @override - String toString() { - return 'Segment(scrollLabel: $scrollLabel, date: $date)'; - } +@freezed +abstract class _Segment with _$Segment { + const factory _Segment({ + required DateTime date, + required double startOffset, + required String scrollLabel, + @Default(false) bool showSegment, + }) = __Segment; } diff --git a/mobile/lib/providers/album/pending_album_uploads.provider.dart b/mobile/lib/providers/album/pending_album_uploads.provider.dart index 7b1188891ba49c..44d279c7893796 100644 --- a/mobile/lib/providers/album/pending_album_uploads.provider.dart +++ b/mobile/lib/providers/album/pending_album_uploads.provider.dart @@ -1,15 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -class PendingAlbumUpload { - final LocalAsset asset; - final double progress; - final bool failed; +part 'pending_album_uploads.provider.freezed.dart'; - const PendingAlbumUpload({required this.asset, this.progress = 0.0, this.failed = false}); - - PendingAlbumUpload copyWith({double? progress, bool? failed}) => - PendingAlbumUpload(asset: asset, progress: progress ?? this.progress, failed: failed ?? this.failed); +@freezed +abstract class PendingAlbumUpload with _$PendingAlbumUpload { + const factory PendingAlbumUpload({ + required LocalAsset asset, + @Default(0.0) double progress, + @Default(false) bool failed, + }) = _PendingAlbumUpload; } class AlbumPendingUploadsNotifier extends AutoDisposeFamilyNotifier, String> { diff --git a/mobile/lib/providers/asset_viewer/video_player_provider.dart b/mobile/lib/providers/asset_viewer/video_player_provider.dart index 74d697a2eae8b1..16df19540fb2e8 100644 --- a/mobile/lib/providers/asset_viewer/video_player_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_provider.dart @@ -1,26 +1,22 @@ import 'dart:async'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; -enum VideoPlaybackStatus { paused, playing, buffering, completed } - -class VideoPlayerState { - final Duration position; - final Duration duration; - final VideoPlaybackStatus status; +part 'video_player_provider.freezed.dart'; - const VideoPlayerState({required this.position, required this.duration, required this.status}); +enum VideoPlaybackStatus { paused, playing, buffering, completed } - VideoPlayerState copyWith({Duration? position, Duration? duration, VideoPlaybackStatus? status}) { - return VideoPlayerState( - position: position ?? this.position, - duration: duration ?? this.duration, - status: status ?? this.status, - ); - } +@freezed +abstract class VideoPlayerState with _$VideoPlayerState { + const factory VideoPlayerState({ + required Duration position, + required Duration duration, + required VideoPlaybackStatus status, + }) = _VideoPlayerState; } const _defaultState = VideoPlayerState( @@ -221,7 +217,7 @@ class VideoPlayerNotifier extends StateNotifier { state = state.copyWith( position: position, - status: state.status == VideoPlaybackStatus.buffering ? VideoPlaybackStatus.playing : null, + status: state.status == VideoPlaybackStatus.buffering ? VideoPlaybackStatus.playing : state.status, ); } diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index 99b84cf95fbafb..57482bdd113764 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -15,18 +15,9 @@ import 'package:logging/logging.dart'; part 'drift_backup.provider.freezed.dart'; -class EnqueueStatus { - final int enqueueCount; - final int totalCount; - - const EnqueueStatus({required this.enqueueCount, required this.totalCount}); - - EnqueueStatus copyWith({int? enqueueCount, int? totalCount}) { - return EnqueueStatus(enqueueCount: enqueueCount ?? this.enqueueCount, totalCount: totalCount ?? this.totalCount); - } - - @override - String toString() => 'EnqueueStatus(enqueueCount: $enqueueCount, totalCount: $totalCount)'; +@freezed +abstract class EnqueueStatus with _$EnqueueStatus { + const factory EnqueueStatus({required int enqueueCount, required int totalCount}) = _EnqueueStatus; } @freezed diff --git a/mobile/lib/providers/cleanup.provider.dart b/mobile/lib/providers/cleanup.provider.dart index 4316b4eb00c0c3..df51c36fdf16e4 100644 --- a/mobile/lib/providers/cleanup.provider.dart +++ b/mobile/lib/providers/cleanup.provider.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -8,48 +9,20 @@ import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/cleanup.service.dart'; -class CleanupState { - final DateTime? selectedDate; - final List assetsToDelete; - final int totalBytes; - final bool isScanning; - final bool isDeleting; - final AssetKeepType keepMediaType; - final bool keepFavorites; - final Set keepAlbumIds; - - const CleanupState({ - this.selectedDate, - this.assetsToDelete = const [], - this.totalBytes = 0, - this.isScanning = false, - this.isDeleting = false, - this.keepMediaType = AssetKeepType.none, - this.keepFavorites = true, - this.keepAlbumIds = const {}, - }); - - CleanupState copyWith({ +part 'cleanup.provider.freezed.dart'; + +@freezed +abstract class CleanupState with _$CleanupState { + const factory CleanupState({ DateTime? selectedDate, - List? assetsToDelete, - int? totalBytes, - bool? isScanning, - bool? isDeleting, - AssetKeepType? keepMediaType, - bool? keepFavorites, - Set? keepAlbumIds, - }) { - return CleanupState( - selectedDate: selectedDate ?? this.selectedDate, - assetsToDelete: assetsToDelete ?? this.assetsToDelete, - totalBytes: totalBytes ?? this.totalBytes, - isScanning: isScanning ?? this.isScanning, - isDeleting: isDeleting ?? this.isDeleting, - keepMediaType: keepMediaType ?? this.keepMediaType, - keepFavorites: keepFavorites ?? this.keepFavorites, - keepAlbumIds: keepAlbumIds ?? this.keepAlbumIds, - ); - } + @Default([]) List assetsToDelete, + @Default(0) int totalBytes, + @Default(false) bool isScanning, + @Default(false) bool isDeleting, + @Default(AssetKeepType.none) AssetKeepType keepMediaType, + @Default(true) bool keepFavorites, + @Default({}) Set keepAlbumIds, + }) = _CleanupState; } final cleanupProvider = StateNotifierProvider((ref) { diff --git a/mobile/lib/utils/album_filter.utils.dart b/mobile/lib/utils/album_filter.utils.dart index 8f9363d4d99f64..daa8aff613a1f7 100644 --- a/mobile/lib/utils/album_filter.utils.dart +++ b/mobile/lib/utils/album_filter.utils.dart @@ -1,25 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; -class AlbumFilter { - String? userId; - String? query; - QuickFilterMode mode; +part 'album_filter.utils.freezed.dart'; - AlbumFilter({required this.mode, this.userId, this.query}); - - AlbumFilter copyWith({String? userId, String? query, QuickFilterMode? mode}) { - return AlbumFilter(userId: userId ?? this.userId, query: query ?? this.query, mode: mode ?? this.mode); - } +@freezed +abstract class AlbumFilter with _$AlbumFilter { + const factory AlbumFilter({required QuickFilterMode mode, String? userId, String? query}) = _AlbumFilter; } -class AlbumSort { - AlbumSortMode mode; - bool isReverse; - - AlbumSort({required this.mode, this.isReverse = false}); - - AlbumSort copyWith({AlbumSortMode? mode, bool? isReverse}) { - return AlbumSort(mode: mode ?? this.mode, isReverse: isReverse ?? this.isReverse); - } +@freezed +abstract class AlbumSort with _$AlbumSort { + const factory AlbumSort({required AlbumSortMode mode, @Default(false) bool isReverse}) = _AlbumSort; } diff --git a/mobile/lib/widgets/common/date_time_picker.dart b/mobile/lib/widgets/common/date_time_picker.dart index 86154c308e3a89..6b4f714e5fb4bb 100644 --- a/mobile/lib/widgets/common/date_time_picker.dart +++ b/mobile/lib/widgets/common/date_time_picker.dart @@ -1,6 +1,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; @@ -9,6 +10,8 @@ import 'package:intl/intl.dart'; import 'package:timezone/timezone.dart' as tz; import 'package:timezone/timezone.dart'; +part 'date_time_picker.freezed.dart'; + Future showDateTimePicker({ required BuildContext context, DateTime? initialDateTime, @@ -165,39 +168,19 @@ class _DateTimePicker extends HookWidget { } } -class _TimeZoneOffset implements Comparable<_TimeZoneOffset> { - final String display; - final Location location; +@freezed +abstract class _TimeZoneOffset with _$TimeZoneOffset implements Comparable<_TimeZoneOffset> { + const _TimeZoneOffset._(); - const _TimeZoneOffset({required this.display, required this.location}); + const factory _TimeZoneOffset({required String display, required Location location}) = __TimeZoneOffset; - _TimeZoneOffset copyWith({String? display, Location? location}) { - return _TimeZoneOffset(display: display ?? this.display, location: location ?? this.location); - } + factory _TimeZoneOffset.fromLocation(tz.Location l) => + _TimeZoneOffset(display: _getFormattedOffset(l.currentTimeZone.offset, l), location: l); int get offsetInMilliseconds => location.currentTimeZone.offset; - _TimeZoneOffset.fromLocation(tz.Location l) - : display = _getFormattedOffset(l.currentTimeZone.offset, l), - location = l; - @override int compareTo(_TimeZoneOffset other) { return offsetInMilliseconds.compareTo(other.offsetInMilliseconds); } - - @override - String toString() => '_TimeZoneOffset(display: $display, location: $location)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - return other is _TimeZoneOffset && other.display == display && other.offsetInMilliseconds == offsetInMilliseconds; - } - - @override - int get hashCode => display.hashCode ^ offsetInMilliseconds.hashCode ^ location.hashCode; } diff --git a/mobile/test/providers/asset_viewer/download_provider_test.dart b/mobile/test/providers/asset_viewer/download_provider_test.dart index 2e2694475d0c79..2c5804747ab995 100644 --- a/mobile/test/providers/asset_viewer/download_provider_test.dart +++ b/mobile/test/providers/asset_viewer/download_provider_test.dart @@ -69,12 +69,12 @@ void main() { fakeAsync((async) { final image = _task( 'live-image', - metaData: LivePhotosMetadata(part: LivePhotosPart.image, id: 'live-1').toJson(), + metaData: const LivePhotosMetadata(part: LivePhotosPart.image, id: 'live-1').toJson(), ); final video = _task( 'live-video', filename: 'photo.MOV', - metaData: LivePhotosMetadata(part: LivePhotosPart.video, id: 'live-1').toJson(), + metaData: const LivePhotosMetadata(part: LivePhotosPart.video, id: 'live-1').toJson(), ); onProgress(TaskProgressUpdate(image, 0.9)); onProgress(TaskProgressUpdate(video, 0.9)); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4322e843df2791..0b832e3c08e5b5 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3465,6 +3465,266 @@ "x-immich-permission": "apiKey.rotate" } }, + "/asset-files": { + "get": { + "description": "Returns all matching asset files.", + "operationId": "searchAssetFiles", + "parameters": [ + { + "name": "assetId", + "required": true, + "in": "query", + "description": "Asset ID to filter files by", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, + { + "name": "isEdited", + "required": false, + "in": "query", + "description": "The file was generated from an edit", + "schema": { + "type": "boolean" + } + }, + { + "name": "isProgressive", + "required": false, + "in": "query", + "description": "The file is a progressively encoded JPEG", + "schema": { + "type": "boolean" + } + }, + { + "name": "isTransparent", + "required": false, + "in": "query", + "description": "The file is transparent", + "schema": { + "type": "boolean" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "description": "Filter by type of file", + "schema": { + "$ref": "#/components/schemas/AssetFileType" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetFileResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Search asset files", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "assetFile.read", + "x-immich-state": "Alpha" + } + }, + "/asset-files/{id}": { + "delete": { + "description": "Delete a file and remove it from the database.", + "operationId": "deleteAssetFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Delete an asset file", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "assetFile.delete", + "x-immich-state": "Alpha" + }, + "get": { + "description": "Returns metadata about a specific asset file.", + "operationId": "getAssetFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetFileResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve an asset file", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "assetFile.read", + "x-immich-state": "Alpha" + } + }, + "/asset-files/{id}/download": { + "get": { + "description": "Serve the contents of a specific asset file.", + "operationId": "downloadAssetFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Download an asset file", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "assetFile.download", + "x-immich-state": "Alpha" + } + }, "/assets": { "delete": { "description": "Deletes multiple assets at the same time.", @@ -17105,6 +17365,10 @@ "name": "Assets", "description": "An asset is an image or video that has been uploaded to Immich." }, + { + "name": "Asset files", + "description": "An asset file is a file associated with an asset, including edited versions, thumbnails, etc." + }, { "name": "Authentication", "description": "Endpoints related to user authentication, including OAuth." @@ -19529,6 +19793,71 @@ ], "type": "object" }, + "AssetFileResponseDto": { + "properties": { + "createdAt": { + "description": "Creation date", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "id": { + "description": "Asset file ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "isEdited": { + "description": "The file was generated from an edit", + "type": "boolean" + }, + "isProgressive": { + "description": "The file is a progressively encoded JPEG", + "type": "boolean" + }, + "isTransparent": { + "description": "The file is transparent", + "type": "boolean" + }, + "path": { + "description": "File path", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/AssetFileType" + }, + "updatedAt": { + "description": "Update date", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + } + }, + "required": [ + "createdAt", + "id", + "isEdited", + "isProgressive", + "isTransparent", + "path", + "type", + "updatedAt" + ], + "type": "object" + }, + "AssetFileType": { + "description": "Type of file", + "enum": [ + "fullsize", + "preview", + "thumbnail", + "sidecar", + "encoded_video" + ], + "type": "string" + }, "AssetIdErrorReason": { "description": "Error reason if failed", "enum": [ @@ -22921,6 +23250,9 @@ "asset.upload", "asset.copy", "asset.derive", + "assetFile.read", + "assetFile.delete", + "assetFile.download", "asset.edit.get", "asset.edit.create", "asset.edit.delete", diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index d562f6877c77a5..2834c63669c7a6 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -1005,6 +1005,23 @@ export type ApiKeyUpdateDto = { /** List of permissions */ permissions?: Permission[]; }; +export type AssetFileResponseDto = { + /** Creation date */ + createdAt: string; + /** Asset file ID */ + id: string; + /** The file was generated from an edit */ + isEdited: boolean; + /** The file is a progressively encoded JPEG */ + isProgressive: boolean; + /** The file is transparent */ + isTransparent: boolean; + /** File path */ + path: string; + "type": AssetFileType; + /** Update date */ + updatedAt: string; +}; export type AssetBulkDeleteDto = { /** Force delete even if in use */ force?: boolean; @@ -4330,6 +4347,66 @@ export function rotateApiKey({ id }: { method: "POST" })); } +/** + * Search asset files + */ +export function searchAssetFiles({ assetId, isEdited, isProgressive, isTransparent, $type }: { + assetId: string; + isEdited?: boolean; + isProgressive?: boolean; + isTransparent?: boolean; + $type?: AssetFileType; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetFileResponseDto[]; + }>(`/asset-files${QS.query(QS.explode({ + assetId, + isEdited, + isProgressive, + isTransparent, + "type": $type + }))}`, { + ...opts + })); +} +/** + * Delete an asset file + */ +export function deleteAssetFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/asset-files/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +/** + * Retrieve an asset file + */ +export function getAssetFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetFileResponseDto; + }>(`/asset-files/${encodeURIComponent(id)}`, { + ...opts + })); +} +/** + * Download an asset file + */ +export function downloadAssetFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchBlob<{ + status: 200; + data: Blob; + }>(`/asset-files/${encodeURIComponent(id)}/download`, { + ...opts + })); +} /** * Delete assets */ @@ -7649,6 +7726,9 @@ export enum Permission { AssetUpload = "asset.upload", AssetCopy = "asset.copy", AssetDerive = "asset.derive", + AssetFileRead = "assetFile.read", + AssetFileDelete = "assetFile.delete", + AssetFileDownload = "assetFile.download", AssetEditGet = "asset.edit.get", AssetEditCreate = "asset.edit.create", AssetEditDelete = "asset.edit.delete", @@ -7794,6 +7874,13 @@ export enum Permission { AdminSessionRead = "adminSession.read", AdminAuthUnlinkAll = "adminAuth.unlinkAll" } +export enum AssetFileType { + Fullsize = "fullsize", + Preview = "preview", + Thumbnail = "thumbnail", + Sidecar = "sidecar", + EncodedVideo = "encoded_video" +} export enum AssetMediaStatus { Created = "created", Duplicate = "duplicate" diff --git a/server/src/constants.ts b/server/src/constants.ts index 0ab0838547d615..856a6ffc579e36 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -148,6 +148,7 @@ export const endpointTags: Record = { [ApiTag.Albums]: 'An album is a collection of assets that can be shared with other users or via shared links.', [ApiTag.ApiKeys]: 'An api key can be used to programmatically access the Immich API.', [ApiTag.Assets]: 'An asset is an image or video that has been uploaded to Immich.', + [ApiTag.AssetFiles]: 'An asset file is a file associated with an asset, including edited versions, thumbnails, etc.', [ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.', [ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.', [ApiTag.ClusterGroups]: diff --git a/server/src/controllers/asset-file.controller.ts b/server/src/controllers/asset-file.controller.ts new file mode 100644 index 00000000000000..5d50f54eead309 --- /dev/null +++ b/server/src/controllers/asset-file.controller.ts @@ -0,0 +1,72 @@ +import { Controller, Delete, Get, HttpCode, HttpStatus, Next, Param, Query, Res } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { NextFunction, Response } from 'express'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { AssetFileResponseDto, AssetFileSearchDto } from 'src/dtos/asset-file.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { ApiTag, Permission } from 'src/enum'; +import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { AssetFileService } from 'src/services/asset-file.service'; +import { sendFile } from 'src/utils/file'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags(ApiTag.AssetFiles) +@Controller('asset-files') +export class AssetFilesController { + constructor( + private service: AssetFileService, + private logger: LoggingRepository, + ) {} + + @Get() + @Authenticated({ permission: Permission.AssetFileRead }) + @Endpoint({ + summary: 'Search asset files', + description: 'Returns all matching asset files.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + searchAssetFiles(@Auth() auth: AuthDto, @Query() dto: AssetFileSearchDto): Promise { + return this.service.search(auth, dto); + } + + @Get(':id') + @Authenticated({ permission: Permission.AssetFileRead }) + @Endpoint({ + summary: 'Retrieve an asset file', + description: 'Returns metadata about a specific asset file.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + getAssetFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Delete(':id') + @Authenticated({ permission: Permission.AssetFileDelete }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete an asset file', + description: 'Delete a file and remove it from the database.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + deleteAssetFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(auth, id); + } + + @Get(':id/download') + @FileResponse() + @Authenticated({ permission: Permission.AssetFileDownload }) + @Endpoint({ + summary: 'Download an asset file', + description: 'Serve the contents of a specific asset file.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + async downloadAssetFile( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Res() res: Response, + @Next() next: NextFunction, + ) { + await sendFile(res, next, () => this.service.download(auth, id), this.logger); + } +} diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index e840654152a68a..a50ab050e5bad6 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -2,6 +2,7 @@ import { ActivityController } from 'src/controllers/activity.controller'; import { AlbumController } from 'src/controllers/album.controller'; import { ApiKeyController } from 'src/controllers/api-key.controller'; import { AppController } from 'src/controllers/app.controller'; +import { AssetFilesController } from 'src/controllers/asset-file.controller'; import { AssetMediaController } from 'src/controllers/asset-media.controller'; import { AssetController } from 'src/controllers/asset.controller'; import { AuthAdminController } from 'src/controllers/auth-admin.controller'; @@ -50,6 +51,7 @@ export const controllers = [ AlbumController, AppController, AssetController, + AssetFilesController, AssetMediaController, AuthController, AuthAdminController, diff --git a/server/src/dtos/asset-file.dto.ts b/server/src/dtos/asset-file.dto.ts new file mode 100644 index 00000000000000..54a772cd887c6b --- /dev/null +++ b/server/src/dtos/asset-file.dto.ts @@ -0,0 +1,45 @@ +import { Selectable } from 'kysely'; +import { createZodDto } from 'nestjs-zod'; +import { AssetFileTypeSchema } from 'src/enum'; +import { AssetFileTable } from 'src/schema/tables/asset-file.table'; +import { isoDatetimeToDate, stringToBool } from 'src/validation'; +import z from 'zod'; + +const AssetFileSearchSchema = z + .object({ + assetId: z.uuidv4().describe('Asset ID to filter files by'), + type: AssetFileTypeSchema.optional().describe('Filter by type of file'), + isEdited: stringToBool.optional().describe('The file was generated from an edit'), + isProgressive: stringToBool.optional().describe('The file is a progressively encoded JPEG'), + isTransparent: stringToBool.optional().describe('The file is transparent'), + }) + .meta({ id: 'AssetFileSearchDto' }); + +const AssetFileResponseSchema = z + .object({ + id: z.uuidv4().describe('Asset file ID'), + createdAt: isoDatetimeToDate.describe('Creation date'), + updatedAt: isoDatetimeToDate.describe('Update date'), + type: AssetFileTypeSchema.describe('Type of file'), + path: z.string().describe('File path'), + isEdited: z.boolean().describe('The file was generated from an edit'), + isProgressive: z.boolean().describe('The file is a progressively encoded JPEG'), + isTransparent: z.boolean().describe('The file is transparent'), + }) + .meta({ id: 'AssetFileResponseDto' }); + +export class AssetFileSearchDto extends createZodDto(AssetFileSearchSchema) {} +export class AssetFileResponseDto extends createZodDto(AssetFileResponseSchema) {} + +export const mapAssetFile = (file: Selectable): AssetFileResponseDto => { + return { + id: file.id, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + type: file.type, + path: file.path, + isEdited: file.isEdited, + isProgressive: file.isProgressive, + isTransparent: file.isTransparent, + }; +}; diff --git a/server/src/enum.ts b/server/src/enum.ts index 2b61999a4ffd3a..4be515a9e1cb34 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -62,6 +62,8 @@ export enum AssetFileType { EncodedVideo = 'encoded_video', } +export const AssetFileTypeSchema = z.enum(AssetFileType).describe('Type of file').meta({ id: 'AssetFileType' }); + export enum AlbumUserRole { Editor = 'editor', Owner = 'owner', @@ -131,6 +133,10 @@ export enum Permission { AssetCopy = 'asset.copy', AssetDerive = 'asset.derive', + AssetFileRead = 'assetFile.read', + AssetFileDelete = 'assetFile.delete', + AssetFileDownload = 'assetFile.download', + AssetEditGet = 'asset.edit.get', AssetEditCreate = 'asset.edit.create', AssetEditDelete = 'asset.edit.delete', @@ -1203,6 +1209,7 @@ export enum ApiTag { Authentication = 'Authentication', AuthenticationAdmin = 'Authentication (admin)', Assets = 'Assets', + AssetFiles = 'Asset files', ConfigUser = 'Config (user)', ConfigAdmin = 'Config (admin)', ConfigPublic = 'Config (public)', diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 270bc915e4cdcc..d21941f12693e1 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -149,6 +149,17 @@ where "albumAssets"."livePhotoVideoId" ] && array[$2]::uuid[] +-- AccessRepository.assetFile.checkOwnerAccess +select + "asset_file"."id" +from + "asset_file" + inner join "asset" on "asset"."id" = "asset_file"."assetId" +where + "asset"."visibility" != $1 + and "asset"."ownerId" = $2 + and "asset_file"."id" in ($3) + -- AccessRepository.authDevice.checkOwnerAccess select "session"."id" diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 62f2fcf6e99b38..9acb8c3fcb48c5 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -278,6 +278,28 @@ class AssetAccess { } } +class AssetFileAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, fileIds: Set, hasElevatedPermission: boolean | undefined) { + if (fileIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('asset_file') + .select('asset_file.id') + .innerJoin('asset', 'asset.id', 'asset_file.assetId') + .$if(!hasElevatedPermission, (eb) => eb.where('asset.visibility', '!=', AssetVisibility.Locked)) + .where('asset.ownerId', '=', userId) + .where('asset_file.id', 'in', [...fileIds]) + .execute() + .then((files) => new Set(files.map(({ id }) => id))); + } +} + class AuthDeviceAccess { constructor(private db: Kysely) {} @@ -596,6 +618,7 @@ export class AccessRepository { activity: ActivityAccess; album: AlbumAccess; asset: AssetAccess; + assetFile: AssetFileAccess; authDevice: AuthDeviceAccess; duplicate: DuplicateAccess; memory: MemoryAccess; @@ -614,6 +637,7 @@ export class AccessRepository { this.activity = new ActivityAccess(db); this.album = new AlbumAccess(db); this.asset = new AssetAccess(db); + this.assetFile = new AssetFileAccess(db); this.authDevice = new AuthDeviceAccess(db); this.duplicate = new DuplicateAccess(db); this.memory = new MemoryAccess(db); diff --git a/server/src/repositories/asset-file.repository.ts b/server/src/repositories/asset-file.repository.ts new file mode 100644 index 00000000000000..ac0f51e60abe33 --- /dev/null +++ b/server/src/repositories/asset-file.repository.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { Kysely } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { AssetFileSearchDto } from 'src/dtos/asset-file.dto'; +import { DB } from 'src/schema'; + +@Injectable() +export class AssetFileRepository { + constructor(@InjectKysely() private db: Kysely) {} + + get(id: string) { + return this.db.selectFrom('asset_file').where('id', '=', id).selectAll().executeTakeFirst(); + } + + search(dto: AssetFileSearchDto) { + return this.db + .selectFrom('asset_file') + .where('assetId', '=', dto.assetId) + .$if(dto.type !== undefined, (qb) => qb.where('type', '=', dto.type!)) + .$if(dto.isEdited !== undefined, (qb) => qb.where('isEdited', '=', dto.isEdited!)) + .$if(dto.isProgressive !== undefined, (qb) => qb.where('isProgressive', '=', dto.isProgressive!)) + .$if(dto.isTransparent !== undefined, (qb) => qb.where('isTransparent', '=', dto.isTransparent!)) + .selectAll() + .execute(); + } + + async delete(id: string): Promise { + const { numDeletedRows } = await this.db.deleteFrom('asset_file').where('id', '=', id).executeTakeFirst(); + return Number(numDeletedRows) === 1; + } +} diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 15ec7c353a9809..606f3b8b575873 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -5,6 +5,7 @@ import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository'; @@ -62,6 +63,7 @@ export const repositories = [ AppRepository, AssetRepository, AssetEditRepository, + AssetFileRepository, AssetJobRepository, ConfigRepository, CronRepository, diff --git a/server/src/services/asset-file.service.ts b/server/src/services/asset-file.service.ts new file mode 100644 index 00000000000000..155d2d407c8537 --- /dev/null +++ b/server/src/services/asset-file.service.ts @@ -0,0 +1,48 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AssetFileResponseDto, AssetFileSearchDto, mapAssetFile } from 'src/dtos/asset-file.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetFileType, CacheControl, JobName, Permission } from 'src/enum'; +import { BaseService } from 'src/services/base.service'; +import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file'; +import { mimeTypes } from 'src/utils/mime-types'; +import { findOrFail } from 'src/utils/misc'; + +@Injectable() +export class AssetFileService extends BaseService { + async search(auth: AuthDto, dto: AssetFileSearchDto): Promise { + await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.assetId] }); + const files = await this.assetFileRepository.search(dto); + return files.map((file) => mapAssetFile(file)); + } + + async get(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.AssetFileRead, ids: [id] }); + const file = await findOrFail(() => this.assetFileRepository.get(id), 'Asset file'); + return mapAssetFile(file); + } + + async download(auth: AuthDto, id: string) { + await this.requireAccess({ auth, permission: Permission.AssetFileDownload, ids: [id] }); + const file = await findOrFail(() => this.assetFileRepository.get(id), 'Asset file'); + + return new ImmichFileResponse({ + path: file.path, + fileName: getFileNameWithoutExtension(file.path) + getFilenameExtension(file.path), + contentType: mimeTypes.lookup(file.path), + cacheControl: CacheControl.PrivateWithCache, + }); + } + + async delete(auth: AuthDto, id: string) { + await this.requireAccess({ auth, permission: Permission.AssetFileDelete, ids: [id] }); + + const file = await findOrFail(() => this.assetFileRepository.get(id), 'Asset file'); + // TODO consider implications of allowing sidecar files to be deleted + if (file.type === AssetFileType.Sidecar) { + throw new BadRequestException('Sidecar files cannot be deleted'); + } + + await this.assetFileRepository.delete(id); + await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [file.path] } }); + } +} diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 6c3d5e3df3b1ad..3c5dab1ed2ff2a 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -12,6 +12,7 @@ import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository'; @@ -74,6 +75,7 @@ export const BASE_SERVICE_DEPENDENCIES = [ AppRepository, AssetRepository, AssetEditRepository, + AssetFileRepository, AssetJobRepository, ClusterGroupRepository, ConfigRepository, @@ -135,6 +137,7 @@ export class BaseService { protected appRepository: AppRepository, protected assetRepository: AssetRepository, protected assetEditRepository: AssetEditRepository, + protected assetFileRepository: AssetFileRepository, protected assetJobRepository: AssetJobRepository, protected clusterGroupRepository: ClusterGroupRepository, protected configRepository: ConfigRepository, @@ -205,6 +208,7 @@ export class BaseService { ctx.appRepository, ctx.assetRepository, ctx.assetEditRepository, + ctx.assetFileRepository, ctx.assetJobRepository, ctx.clusterGroupRepository, ctx.configRepository, diff --git a/server/src/services/database-backup.service.ts b/server/src/services/database-backup.service.ts index 6e1da7a1b709ce..d99d0433e55f0e 100644 --- a/server/src/services/database-backup.service.ts +++ b/server/src/services/database-backup.service.ts @@ -584,7 +584,7 @@ function createSqlOwnerTransformStream(databaseUsername: string) { const DATA_MARKER_START = new TextEncoder().encode('FROM stdin'); const LINE_END = new TextEncoder().encode(';'); - const owner = new TextEncoder().encode(databaseUsername); + const owner = new TextEncoder().encode(`"${databaseUsername}"`); let ownerSequenceIndex = 0; diff --git a/server/src/services/index.ts b/server/src/services/index.ts index d8bb618b01832c..2a45d0308b4481 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -2,6 +2,7 @@ import { ActivityService } from 'src/services/activity.service'; import { AlbumService } from 'src/services/album.service'; import { ApiKeyService } from 'src/services/api-key.service'; import { ApiService } from 'src/services/api.service'; +import { AssetFileService } from 'src/services/asset-file.service'; import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetService } from 'src/services/asset.service'; import { AuthAdminService } from 'src/services/auth-admin.service'; @@ -56,6 +57,7 @@ export const services = [ ActivityService, AlbumService, ApiService, + AssetFileService, AssetMediaService, AssetService, AuthService, diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 97c1e8e0c5e420..dc3c635ba72bff 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -126,6 +126,10 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return setUnion(isOwner, isPartner); } + case Permission.AssetFileDownload: { + return access.assetFile.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + } + case Permission.AssetView: { const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); @@ -164,6 +168,11 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); } + case Permission.AssetFileRead: + case Permission.AssetFileDelete: { + return await access.assetFile.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + } + case Permission.AlbumRead: { const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); const isShared = await access.album.checkSharedAlbumAccess( diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index a754d7bc285e6f..f6a2497e7ee201 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -24,6 +24,7 @@ import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository'; @@ -467,6 +468,7 @@ const newRealRepository = (key: T, db: Kysely case ApiKeyRepository: case AssetRepository: case AssetEditRepository: + case AssetFileRepository: case AssetJobRepository: case ClusterGroupRepository: case DuplicateRepository: diff --git a/server/test/medium/specs/services/memory.service.spec.ts b/server/test/medium/specs/services/memory.service.spec.ts index 5a9217ea60b28a..f4a6e341bfce66 100644 --- a/server/test/medium/specs/services/memory.service.spec.ts +++ b/server/test/medium/specs/services/memory.service.spec.ts @@ -1,5 +1,6 @@ import { Kysely } from 'kysely'; import { DateTime } from 'luxon'; +import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { AssetFileType, MemoryType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; @@ -34,30 +35,51 @@ const setup = (db?: Kysely) => { }); }; -/** A memory owned by one user, plus another user's auth to attempt access with */ -const newMemoryOfAnotherUser = async (ctx: ReturnType['ctx']) => { +const create = async (ctx: ReturnType['ctx']) => { const { user } = await ctx.newUser(); - const { user: otherUser } = await ctx.newUser(); const { memory } = await ctx.newMemory({ ownerId: user.id }); const { asset } = await ctx.newAsset({ ownerId: user.id }); - return { memory, asset, auth: factory.auth({ user }), otherAuth: factory.auth({ user: otherUser }) }; + return { memory, asset, user }; }; describe(MemoryService.name, () => { describe('get', () => { + it('should return the memory', async () => { + const { sut, ctx } = setup(); + const { memory, user } = await create(ctx); + const auth = factory.auth({ user }); + + await expect(sut.get(auth, memory.id)).resolves.toEqual(expect.objectContaining({ id: memory.id })); + }); + it('should not return a memory of another user', async () => { const { sut, ctx } = setup(); - const { memory, otherAuth } = await newMemoryOfAnotherUser(ctx); + const { memory } = await create(ctx); + const { user: otherUser } = await ctx.newUser(); + const otherAuth = factory.auth({ user: otherUser }); await expect(sut.get(otherAuth, memory.id)).rejects.toThrow('Not found or no memory.read access'); }); }); describe('update', () => { + it('should update the memory', async () => { + const { sut, ctx } = setup(); + const { memory, user } = await create(ctx); + const auth = factory.auth({ user }); + + await expect(sut.get(auth, memory.id)).resolves.toEqual(expect.objectContaining({ isSaved: false })); + await expect(sut.update(auth, memory.id, { isSaved: true })).resolves.toEqual( + expect.objectContaining({ id: memory.id, isSaved: true }), + ); + }); + it('should not update a memory of another user', async () => { const { sut, ctx } = setup(); - const { memory, otherAuth } = await newMemoryOfAnotherUser(ctx); + const { memory } = await create(ctx); + const { user: otherUser } = await ctx.newUser(); + const otherAuth = factory.auth({ user: otherUser }); await expect(sut.update(otherAuth, memory.id, { isSaved: true })).rejects.toThrow( 'Not found or no memory.update access', @@ -66,9 +88,21 @@ describe(MemoryService.name, () => { }); describe('remove', () => { + it('should remove the memory', async () => { + const { sut, ctx } = setup(); + const { memory, user } = await create(ctx); + const auth = factory.auth({ user }); + + await expect(sut.remove(auth, memory.id)).resolves.toBeUndefined(); + await expect(sut.get(auth, memory.id)).rejects.toThrow('Not found or no memory.read access'); + }); + it('should not remove a memory of another user', async () => { const { sut, ctx } = setup(); - const { memory, auth, otherAuth } = await newMemoryOfAnotherUser(ctx); + const { memory, user } = await create(ctx); + const auth = factory.auth({ user }); + const { user: otherUser } = await ctx.newUser(); + const otherAuth = factory.auth({ user: otherUser }); await expect(sut.remove(otherAuth, memory.id)).rejects.toThrow('Not found or no memory.delete access'); await expect(sut.get(auth, memory.id)).resolves.toEqual(expect.objectContaining({ id: memory.id })); @@ -76,9 +110,33 @@ describe(MemoryService.name, () => { }); describe('addAssets', () => { + it('should add assets to the memory', async () => { + const { sut, ctx } = setup(); + const { memory, asset, user } = await create(ctx); + const auth = factory.auth({ user }); + + await expect(sut.addAssets(auth, memory.id, { ids: [asset.id] })).resolves.toEqual([ + { id: asset.id, success: true }, + ]); + }); + + it('should require access to the asset', async () => { + const { sut, ctx } = setup(); + const { memory, user } = await create(ctx); + const auth = factory.auth({ user }); + const { user: other } = await ctx.newUser(); + const { asset: otherAsset } = await ctx.newAsset({ ownerId: other.id }); + + await expect(sut.addAssets(auth, memory.id, { ids: [otherAsset.id] })).resolves.toEqual([ + { id: otherAsset.id, success: false, error: BulkIdErrorReason.NO_PERMISSION }, + ]); + }); + it('should not add assets to a memory of another user', async () => { const { sut, ctx } = setup(); - const { memory, asset, otherAuth } = await newMemoryOfAnotherUser(ctx); + const { memory, asset } = await create(ctx); + const { user: otherUser } = await ctx.newUser(); + const otherAuth = factory.auth({ user: otherUser }); await expect(sut.addAssets(otherAuth, memory.id, { ids: [asset.id] })).rejects.toThrow( 'Not found or no memory.read access', @@ -87,9 +145,32 @@ describe(MemoryService.name, () => { }); describe('removeAssets', () => { + it('should remove assets from the memory', async () => { + const { sut, ctx } = setup(); + const { memory, asset, user } = await create(ctx); + const auth = factory.auth({ user }); + await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id }); + + await expect(sut.removeAssets(auth, memory.id, { ids: [asset.id] })).resolves.toEqual([ + { id: asset.id, success: true }, + ]); + }); + + it('should only remove assets that are in the memory', async () => { + const { sut, ctx } = setup(); + const { memory, asset, user } = await create(ctx); + const auth = factory.auth({ user }); + + await expect(sut.removeAssets(auth, memory.id, { ids: [asset.id] })).resolves.toEqual([ + { id: asset.id, success: false, error: BulkIdErrorReason.NOT_FOUND }, + ]); + }); + it('should not remove assets from a memory of another user', async () => { const { sut, ctx } = setup(); - const { memory, asset, otherAuth } = await newMemoryOfAnotherUser(ctx); + const { memory, asset } = await create(ctx); + const { user: otherUser } = await ctx.newUser(); + const otherAuth = factory.auth({ user: otherUser }); await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id }); await expect(sut.removeAssets(otherAuth, memory.id, { ids: [asset.id] })).rejects.toThrow( diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index a928b4796de810..90b9f64e0b8407 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -33,6 +33,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { checkSharedLinkAccess: vitest.fn().mockResolvedValue(new Set()), }, + assetFile: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, + album: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), checkSharedAlbumAccess: vitest.fn().mockResolvedValue(new Set()), diff --git a/server/test/utils.ts b/server/test/utils.ts index e7392949ad4b6d..e273d72a36f850 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -24,6 +24,7 @@ import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository'; @@ -239,6 +240,7 @@ export type ServiceOverrides = { app: AppRepository; asset: AssetRepository; assetEdit: AssetEditRepository; + assetFile: AssetFileRepository; assetJob: AssetJobRepository; clusterGroup: ClusterGroupRepository; config: ConfigRepository; @@ -323,6 +325,7 @@ export const getMocks = () => { albumUser: automock(AlbumUserRepository), asset: newAssetRepositoryMock(), assetEdit: automock(AssetEditRepository), + assetFile: automock(AssetFileRepository), assetJob: automock(AssetJobRepository), clusterGroup: automock(ClusterGroupRepository), app: automock(AppRepository, { strict: false }), @@ -397,6 +400,7 @@ export const newTestService = ( overrides.app || (mocks.app as As), overrides.asset || (mocks.asset as As), overrides.assetEdit || (mocks.assetEdit as As), + overrides.assetFile || (mocks.assetFile as As), overrides.assetJob || (mocks.assetJob as As), overrides.clusterGroup || (mocks.clusterGroup as As), overrides.config || (mocks.config as As as ConfigRepository),