mirror of
https://github.com/immich-app/immich.git
synced 2026-01-29 08:14:47 -08:00
Compare commits
17 Commits
shared-dee
...
fix/locale
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1304abac92 | ||
|
|
7cedb5ea04 | ||
|
|
e57739b641 | ||
|
|
6587d45f1e | ||
|
|
da590995ab | ||
|
|
e04d316203 | ||
|
|
6b2737bae3 | ||
|
|
42b354c302 | ||
|
|
cf6c7f9960 | ||
|
|
9506398153 | ||
|
|
b5c3d87290 | ||
|
|
97220102e4 | ||
|
|
6430c88b84 | ||
|
|
df7efc4945 | ||
|
|
646bb372ab | ||
|
|
836d22570f | ||
|
|
3b0be896e6 |
5
.github/workflows/build-mobile.yml
vendored
5
.github/workflows/build-mobile.yml
vendored
@@ -178,9 +178,12 @@ jobs:
|
||||
contents: read
|
||||
# Run on main branch or workflow_dispatch, or on PRs/other branches (build only, no upload)
|
||||
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-15
|
||||
|
||||
steps:
|
||||
- name: Select Xcode 26
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^24.10.8",
|
||||
"@types/node": "^24.10.9",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^24.10.8",
|
||||
"@types/node": "^24.10.9",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/supertest": "^6.0.2",
|
||||
|
||||
11
i18n/en.json
11
i18n/en.json
@@ -272,7 +272,7 @@
|
||||
"oauth_auto_register": "Auto register",
|
||||
"oauth_auto_register_description": "Automatically register new users after signing in with OAuth",
|
||||
"oauth_button_text": "Button text",
|
||||
"oauth_client_secret_description": "Required if PKCE (Proof Key for Code Exchange) is not supported by the OAuth provider",
|
||||
"oauth_client_secret_description": "Required for confidential client, or if PKCE (Proof Key for Code Exchange) is not supported for public client.",
|
||||
"oauth_enable_description": "Login with OAuth",
|
||||
"oauth_mobile_redirect_uri": "Mobile redirect URI",
|
||||
"oauth_mobile_redirect_uri_override": "Mobile redirect URI override",
|
||||
@@ -861,8 +861,8 @@
|
||||
"current_pin_code": "Current PIN code",
|
||||
"current_server_address": "Current server address",
|
||||
"custom_date": "Custom date",
|
||||
"custom_locale": "Custom Locale",
|
||||
"custom_locale_description": "Format dates and numbers based on the language and the region",
|
||||
"custom_locale": "Custom locale",
|
||||
"custom_locale_description": "Format dates, times, and numbers based on the selected language and region",
|
||||
"custom_url": "Custom URL",
|
||||
"cutoff_date_description": "Keep photos from the last…",
|
||||
"cutoff_day": "{count, plural, one {day} other {days}}",
|
||||
@@ -885,8 +885,8 @@
|
||||
"deduplication_criteria_2": "Count of EXIF data",
|
||||
"deduplication_info": "Deduplication Info",
|
||||
"deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:",
|
||||
"default_locale": "Default Locale",
|
||||
"default_locale_description": "Format dates and numbers based on your browser locale",
|
||||
"default_locale": "Use browser locale",
|
||||
"default_locale_description": "Format dates, times, and numbers based on your browser locale",
|
||||
"delete": "Delete",
|
||||
"delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally",
|
||||
"delete_action_prompt": "{count} deleted",
|
||||
@@ -2358,7 +2358,6 @@
|
||||
"view_qr_code": "View QR code",
|
||||
"view_similar_photos": "View similar photos",
|
||||
"view_stack": "View Stack",
|
||||
"view_in_app": "View in the Immich app",
|
||||
"view_user": "View User",
|
||||
"viewer_remove_from_stack": "Remove from Stack",
|
||||
"viewer_stack_use_as_main_asset": "Use as Main Asset",
|
||||
|
||||
@@ -123,9 +123,6 @@
|
||||
<data
|
||||
android:host="my.immich.app"
|
||||
android:pathPrefix="/photos/" />
|
||||
<data
|
||||
android:host="my.immich.app"
|
||||
android:pathPrefix="/share/" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ class ImmichAPI(cfg: ServerConfig) {
|
||||
}
|
||||
|
||||
suspend fun fetchImage(asset: Asset): Bitmap = withContext(Dispatchers.IO) {
|
||||
val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview"))
|
||||
val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview", "edited" to "true"))
|
||||
val connection = url.openConnection()
|
||||
val data = connection.getInputStream().readBytes()
|
||||
BitmapFactory.decodeByteArray(data, 0, data.size)
|
||||
|
||||
1
mobile/drift_schemas/main/drift_schema_v18.json
generated
Normal file
1
mobile/drift_schemas/main/drift_schema_v18.json
generated
Normal file
File diff suppressed because one or more lines are too long
@@ -23,6 +23,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
||||
static let session = {
|
||||
let cacheDir = FileManager.default.temporaryDirectory.appendingPathComponent("thumbnails", isDirectory: true)
|
||||
let config = URLSessionConfiguration.default
|
||||
config.requestCachePolicy = .returnCacheDataElseLoad
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
||||
config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"]
|
||||
try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
||||
|
||||
@@ -225,7 +225,7 @@ class ImmichAPI {
|
||||
}
|
||||
|
||||
func fetchImage(asset: Asset) async throws(FetchError) -> UIImage {
|
||||
let thumbnailParams = [URLQueryItem(name: "size", value: "preview")]
|
||||
let thumbnailParams = [URLQueryItem(name: "size", value: "preview"), URLQueryItem(name: "edited", value: "true")]
|
||||
let assetEndpoint = "/assets/" + asset.id + "/thumbnail"
|
||||
|
||||
guard
|
||||
|
||||
@@ -41,7 +41,7 @@ class HashService {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
// Migrate hashes from cloud ID to local ID so we don't have to re-hash them
|
||||
await _migrateHashes();
|
||||
await _localAssetRepository.reconcileHashesFromCloudId();
|
||||
|
||||
// Sorted by backupSelection followed by isCloud
|
||||
final localAlbums = await _localAlbumRepository.getBackupAlbums();
|
||||
@@ -78,15 +78,6 @@ class HashService {
|
||||
_log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
|
||||
}
|
||||
|
||||
Future<void> _migrateHashes() async {
|
||||
final hashMappings = await _localAssetRepository.getHashMappingFromCloudId();
|
||||
if (hashMappings.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _localAssetRepository.updateHashes(hashMappings);
|
||||
}
|
||||
|
||||
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
|
||||
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
||||
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
||||
|
||||
@@ -50,75 +50,84 @@ Future<void> syncCloudIds(ProviderContainer ref) async {
|
||||
return;
|
||||
}
|
||||
|
||||
final mappingsToUpdate = await _fetchCloudIdMappings(db, currentUser.id);
|
||||
// Deduplicate mappings as a single remote asset ID can match multiple local assets
|
||||
final seenRemoteAssetIds = <String>{};
|
||||
final uniqueMapping = mappingsToUpdate.where((mapping) {
|
||||
if (!seenRemoteAssetIds.add(mapping.remoteAssetId)) {
|
||||
logger.fine('Duplicate remote asset ID found: ${mapping.remoteAssetId}. Skipping duplicate entry.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
final assetApi = ref.read(apiServiceProvider).assetsApi;
|
||||
|
||||
if (canBulkUpdateMetadata) {
|
||||
await _bulkUpdateCloudIds(assetApi, uniqueMapping);
|
||||
return;
|
||||
}
|
||||
await _sequentialUpdateCloudIds(assetApi, uniqueMapping);
|
||||
// Process cloud IDs in paginated batches
|
||||
await _processCloudIdMappingsInBatches(db, currentUser.id, assetApi, canBulkUpdateMetadata, logger);
|
||||
}
|
||||
|
||||
Future<void> _sequentialUpdateCloudIds(AssetsApi assetsApi, List<_CloudIdMapping> mappings) async {
|
||||
for (final mapping in mappings) {
|
||||
final item = AssetMetadataUpsertItemDto(
|
||||
key: kMobileMetadataKey,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: mapping.localAsset.cloudId,
|
||||
createdAt: mapping.localAsset.createdAt.toIso8601String(),
|
||||
adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(),
|
||||
latitude: mapping.localAsset.latitude?.toString(),
|
||||
longitude: mapping.localAsset.longitude?.toString(),
|
||||
),
|
||||
);
|
||||
try {
|
||||
await assetsApi.updateAssetMetadata(mapping.remoteAssetId, AssetMetadataUpsertDto(items: [item]));
|
||||
} catch (error, stack) {
|
||||
Logger('migrateCloudIds').warning('Failed to update metadata for asset ${mapping.remoteAssetId}', error, stack);
|
||||
Future<void> _processCloudIdMappingsInBatches(
|
||||
Drift drift,
|
||||
String userId,
|
||||
AssetsApi assetsApi,
|
||||
bool canBulkUpdate,
|
||||
Logger logger,
|
||||
) async {
|
||||
const pageSize = 20000;
|
||||
String? lastLocalId;
|
||||
final seenRemoteAssetIds = <String>{};
|
||||
|
||||
while (true) {
|
||||
final mappings = await _fetchCloudIdMappings(drift, userId, pageSize, lastLocalId);
|
||||
if (mappings.isEmpty) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _bulkUpdateCloudIds(AssetsApi assetsApi, List<_CloudIdMapping> mappings) async {
|
||||
const batchSize = 10000;
|
||||
for (int i = 0; i < mappings.length; i += batchSize) {
|
||||
final endIndex = (i + batchSize > mappings.length) ? mappings.length : i + batchSize;
|
||||
final batch = mappings.sublist(i, endIndex);
|
||||
final items = <AssetMetadataBulkUpsertItemDto>[];
|
||||
for (final mapping in batch) {
|
||||
items.add(
|
||||
AssetMetadataBulkUpsertItemDto(
|
||||
assetId: mapping.remoteAssetId,
|
||||
key: kMobileMetadataKey,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: mapping.localAsset.cloudId,
|
||||
createdAt: mapping.localAsset.createdAt.toIso8601String(),
|
||||
adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(),
|
||||
latitude: mapping.localAsset.latitude?.toString(),
|
||||
longitude: mapping.localAsset.longitude?.toString(),
|
||||
for (final mapping in mappings) {
|
||||
if (seenRemoteAssetIds.add(mapping.remoteAssetId)) {
|
||||
items.add(
|
||||
AssetMetadataBulkUpsertItemDto(
|
||||
assetId: mapping.remoteAssetId,
|
||||
key: kMobileMetadataKey,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: mapping.localAsset.cloudId,
|
||||
createdAt: mapping.localAsset.createdAt.toIso8601String(),
|
||||
adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(),
|
||||
latitude: mapping.localAsset.latitude?.toString(),
|
||||
longitude: mapping.localAsset.longitude?.toString(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
} else {
|
||||
logger.fine('Duplicate remote asset ID found: ${mapping.remoteAssetId}. Skipping duplicate entry.');
|
||||
}
|
||||
}
|
||||
|
||||
if (items.isNotEmpty) {
|
||||
if (canBulkUpdate) {
|
||||
await _bulkUpdateCloudIds(assetsApi, items);
|
||||
} else {
|
||||
await _sequentialUpdateCloudIds(assetsApi, items);
|
||||
}
|
||||
}
|
||||
|
||||
lastLocalId = mappings.last.localAsset.id;
|
||||
if (mappings.length < pageSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sequentialUpdateCloudIds(AssetsApi assetsApi, List<AssetMetadataBulkUpsertItemDto> items) async {
|
||||
for (final item in items) {
|
||||
final upsertItem = AssetMetadataUpsertItemDto(key: item.key, value: item.value);
|
||||
try {
|
||||
await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items));
|
||||
await assetsApi.updateAssetMetadata(item.assetId, AssetMetadataUpsertDto(items: [upsertItem]));
|
||||
} catch (error, stack) {
|
||||
Logger('migrateCloudIds').warning('Failed to bulk update metadata', error, stack);
|
||||
Logger('migrateCloudIds').warning('Failed to update metadata for asset ${item.assetId}', error, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _bulkUpdateCloudIds(AssetsApi assetsApi, List<AssetMetadataBulkUpsertItemDto> items) async {
|
||||
try {
|
||||
await assetsApi.updateBulkAssetMetadata(AssetMetadataBulkUpsertDto(items: items));
|
||||
} catch (error, stack) {
|
||||
Logger('migrateCloudIds').warning('Failed to bulk update metadata', error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _populateCloudIds(Drift drift) async {
|
||||
final query = drift.localAssetEntity.selectOnly()
|
||||
..addColumns([drift.localAssetEntity.id])
|
||||
@@ -141,31 +150,38 @@ Future<void> _populateCloudIds(Drift drift) async {
|
||||
|
||||
typedef _CloudIdMapping = ({String remoteAssetId, LocalAsset localAsset});
|
||||
|
||||
Future<List<_CloudIdMapping>> _fetchCloudIdMappings(Drift drift, String userId) async {
|
||||
Future<List<_CloudIdMapping>> _fetchCloudIdMappings(Drift drift, String userId, int limit, String? lastLocalId) async {
|
||||
final query =
|
||||
drift.remoteAssetEntity.select().join([
|
||||
leftOuterJoin(
|
||||
drift.localAssetEntity,
|
||||
drift.localAssetEntity.checksum.equalsExp(drift.remoteAssetEntity.checksum),
|
||||
),
|
||||
leftOuterJoin(
|
||||
drift.remoteAssetCloudIdEntity,
|
||||
drift.remoteAssetEntity.id.equalsExp(drift.remoteAssetCloudIdEntity.assetId),
|
||||
useColumns: false,
|
||||
),
|
||||
])..where(
|
||||
// Only select assets that have a local cloud ID but either no remote cloud ID or a mismatched eTag
|
||||
drift.localAssetEntity.id.isNotNull() &
|
||||
drift.localAssetEntity.iCloudId.isNotNull() &
|
||||
drift.remoteAssetEntity.ownerId.equals(userId) &
|
||||
// Skip locked assets as we cannot update them without unlocking first
|
||||
drift.remoteAssetEntity.visibility.isNotValue(AssetVisibility.locked.index) &
|
||||
(drift.remoteAssetCloudIdEntity.cloudId.isNull() |
|
||||
drift.remoteAssetCloudIdEntity.adjustmentTime.isNotExp(drift.localAssetEntity.adjustmentTime) |
|
||||
drift.remoteAssetCloudIdEntity.latitude.isNotExp(drift.localAssetEntity.latitude) |
|
||||
drift.remoteAssetCloudIdEntity.longitude.isNotExp(drift.localAssetEntity.longitude) |
|
||||
drift.remoteAssetCloudIdEntity.createdAt.isNotExp(drift.localAssetEntity.createdAt)),
|
||||
);
|
||||
drift.localAssetEntity.select().join([
|
||||
innerJoin(
|
||||
drift.remoteAssetEntity,
|
||||
drift.localAssetEntity.checksum.equalsExp(drift.remoteAssetEntity.checksum),
|
||||
),
|
||||
leftOuterJoin(
|
||||
drift.remoteAssetCloudIdEntity,
|
||||
drift.remoteAssetEntity.id.equalsExp(drift.remoteAssetCloudIdEntity.assetId),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(
|
||||
// Only select assets that have a local cloud ID but either no remote cloud ID or a mismatched eTag
|
||||
drift.localAssetEntity.iCloudId.isNotNull() &
|
||||
drift.remoteAssetEntity.ownerId.equals(userId) &
|
||||
// Skip locked assets as we cannot update them without unlocking first
|
||||
drift.remoteAssetEntity.visibility.isNotValue(AssetVisibility.locked.index) &
|
||||
(drift.remoteAssetCloudIdEntity.cloudId.isNull() |
|
||||
drift.remoteAssetCloudIdEntity.adjustmentTime.isNotExp(drift.localAssetEntity.adjustmentTime) |
|
||||
drift.remoteAssetCloudIdEntity.latitude.isNotExp(drift.localAssetEntity.latitude) |
|
||||
drift.remoteAssetCloudIdEntity.longitude.isNotExp(drift.localAssetEntity.longitude) |
|
||||
drift.remoteAssetCloudIdEntity.createdAt.isNotExp(drift.localAssetEntity.createdAt)),
|
||||
)
|
||||
..orderBy([OrderingTerm.asc(drift.localAssetEntity.id)])
|
||||
..limit(limit);
|
||||
|
||||
if (lastLocalId != null) {
|
||||
query.where(drift.localAssetEntity.id.isBiggerThanValue(lastLocalId));
|
||||
}
|
||||
|
||||
return query.map((row) {
|
||||
return (
|
||||
remoteAssetId: row.read(drift.remoteAssetEntity.id)!,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)')
|
||||
class RemoteAssetCloudIdEntity extends Table with DriftDefaultsMixin {
|
||||
TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
|
||||
@@ -403,6 +403,10 @@ typedef $$RemoteAssetCloudIdEntityTableProcessedTableManager =
|
||||
i1.RemoteAssetCloudIdEntityData,
|
||||
i0.PrefetchHooks Function({bool assetId})
|
||||
>;
|
||||
i0.Index get idxRemoteAssetCloudId => i0.Index(
|
||||
'idx_remote_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
||||
);
|
||||
|
||||
class $RemoteAssetCloudIdEntityTable extends i2.RemoteAssetCloudIdEntity
|
||||
with
|
||||
|
||||
@@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 17;
|
||||
int get schemaVersion => 18;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -204,6 +204,9 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
from16To17: (m, v17) async {
|
||||
await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited);
|
||||
},
|
||||
from17To18: (m, v18) async {
|
||||
await m.createIndex(v18.idxRemoteAssetCloudId);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
i11.idxLatLng,
|
||||
i14.idxRemoteAssetCloudId,
|
||||
i20.idxTrashedLocalAssetChecksum,
|
||||
i20.idxTrashedLocalAssetAlbum,
|
||||
];
|
||||
|
||||
@@ -7408,6 +7408,455 @@ i1.GeneratedColumn<bool> _column_101(String aliasedName) =>
|
||||
),
|
||||
defaultValue: const CustomExpression('0'),
|
||||
);
|
||||
|
||||
final class Schema18 extends i0.VersionedSchema {
|
||||
Schema18({required super.database}) : super(version: 18);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAssetChecksum,
|
||||
idxLocalAssetCloudId,
|
||||
idxRemoteAssetOwnerChecksum,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
remoteAssetCloudIdEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
idxLatLng,
|
||||
idxRemoteAssetCloudId,
|
||||
idxTrashedLocalAssetChecksum,
|
||||
idxTrashedLocalAssetAlbum,
|
||||
];
|
||||
late final Shape20 userEntity = Shape20(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_3,
|
||||
_column_84,
|
||||
_column_85,
|
||||
_column_91,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape28 remoteAssetEntity = Shape28(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_13,
|
||||
_column_14,
|
||||
_column_15,
|
||||
_column_16,
|
||||
_column_17,
|
||||
_column_18,
|
||||
_column_19,
|
||||
_column_20,
|
||||
_column_21,
|
||||
_column_86,
|
||||
_column_101,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape3 stackEntity = Shape3(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'stack_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape26 localAssetEntity = Shape26(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_22,
|
||||
_column_14,
|
||||
_column_23,
|
||||
_column_98,
|
||||
_column_96,
|
||||
_column_46,
|
||||
_column_47,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape9 remoteAlbumEntity = Shape9(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_56,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_15,
|
||||
_column_57,
|
||||
_column_58,
|
||||
_column_59,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape19 localAlbumEntity = Shape19(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_5,
|
||||
_column_31,
|
||||
_column_32,
|
||||
_column_90,
|
||||
_column_33,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape22 localAlbumAssetEntity = Shape22(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_34, _column_35, _column_33],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||
'idx_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxLocalAssetCloudId = i1.Index(
|
||||
'idx_local_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
|
||||
'idx_remote_asset_owner_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_library_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||
'idx_remote_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||
);
|
||||
late final Shape21 authUserEntity = Shape21(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'auth_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_3,
|
||||
_column_2,
|
||||
_column_84,
|
||||
_column_85,
|
||||
_column_92,
|
||||
_column_93,
|
||||
_column_7,
|
||||
_column_94,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape4 userMetadataEntity = Shape4(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_metadata_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||
columns: [_column_25, _column_26, _column_27],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape5 partnerEntity = Shape5(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'partner_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||
columns: [_column_28, _column_29, _column_30],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape8 remoteExifEntity = Shape8(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_exif_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_36,
|
||||
_column_37,
|
||||
_column_38,
|
||||
_column_39,
|
||||
_column_40,
|
||||
_column_41,
|
||||
_column_11,
|
||||
_column_10,
|
||||
_column_42,
|
||||
_column_43,
|
||||
_column_44,
|
||||
_column_45,
|
||||
_column_46,
|
||||
_column_47,
|
||||
_column_48,
|
||||
_column_49,
|
||||
_column_50,
|
||||
_column_51,
|
||||
_column_52,
|
||||
_column_53,
|
||||
_column_54,
|
||||
_column_55,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_36, _column_60],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||
columns: [_column_60, _column_25, _column_61],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape27 remoteAssetCloudIdEntity = Shape27(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_cloud_id_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_36,
|
||||
_column_99,
|
||||
_column_100,
|
||||
_column_96,
|
||||
_column_46,
|
||||
_column_47,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape11 memoryEntity = Shape11(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_18,
|
||||
_column_15,
|
||||
_column_8,
|
||||
_column_62,
|
||||
_column_63,
|
||||
_column_64,
|
||||
_column_65,
|
||||
_column_66,
|
||||
_column_67,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape12 memoryAssetEntity = Shape12(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||
columns: [_column_36, _column_68],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape14 personEntity = Shape14(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'person_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_15,
|
||||
_column_1,
|
||||
_column_69,
|
||||
_column_71,
|
||||
_column_72,
|
||||
_column_73,
|
||||
_column_74,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape15 assetFaceEntity = Shape15(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_face_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_36,
|
||||
_column_76,
|
||||
_column_77,
|
||||
_column_78,
|
||||
_column_79,
|
||||
_column_80,
|
||||
_column_81,
|
||||
_column_82,
|
||||
_column_83,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape18 storeEntity = Shape18(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'store_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_87, _column_88, _column_89],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape25 trashedLocalAssetEntity = Shape25(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trashed_local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_95,
|
||||
_column_22,
|
||||
_column_14,
|
||||
_column_23,
|
||||
_column_97,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLatLng = i1.Index(
|
||||
'idx_lat_lng',
|
||||
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetCloudId = i1.Index(
|
||||
'idx_remote_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
|
||||
'idx_trashed_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
|
||||
'idx_trashed_local_asset_album',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
|
||||
);
|
||||
}
|
||||
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -7425,6 +7874,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
|
||||
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
|
||||
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
|
||||
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -7508,6 +7958,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from16To17(migrator, schema);
|
||||
return 17;
|
||||
case 17:
|
||||
final schema = Schema18(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from17To18(migrator, schema);
|
||||
return 18;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -7531,6 +7986,7 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
|
||||
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
|
||||
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
|
||||
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -7549,5 +8005,6 @@ i1.OnUpgrade stepByStep({
|
||||
from14To15: from14To15,
|
||||
from15To16: from15To16,
|
||||
from16To17: from16To17,
|
||||
from17To18: from17To18,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -204,34 +204,23 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
return query.map((row) => row.toDto()).get();
|
||||
}
|
||||
|
||||
Future<Map<String, String>> getHashMappingFromCloudId() async {
|
||||
final query =
|
||||
_db.localAssetEntity.selectOnly().join([
|
||||
leftOuterJoin(
|
||||
_db.remoteAssetCloudIdEntity,
|
||||
_db.localAssetEntity.iCloudId.equalsExp(_db.remoteAssetCloudIdEntity.cloudId),
|
||||
useColumns: false,
|
||||
),
|
||||
leftOuterJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.remoteAssetCloudIdEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..addColumns([_db.localAssetEntity.id, _db.remoteAssetEntity.checksum])
|
||||
..where(
|
||||
_db.remoteAssetCloudIdEntity.cloudId.isNotNull() &
|
||||
_db.localAssetEntity.checksum.isNull() &
|
||||
((_db.remoteAssetCloudIdEntity.adjustmentTime.isExp(_db.localAssetEntity.adjustmentTime)) &
|
||||
(_db.remoteAssetCloudIdEntity.latitude.isExp(_db.localAssetEntity.latitude)) &
|
||||
(_db.remoteAssetCloudIdEntity.longitude.isExp(_db.localAssetEntity.longitude)) &
|
||||
(_db.remoteAssetCloudIdEntity.createdAt.isExp(_db.localAssetEntity.createdAt))),
|
||||
);
|
||||
final mapping = await query
|
||||
.map(
|
||||
(row) => (assetId: row.read(_db.localAssetEntity.id)!, checksum: row.read(_db.remoteAssetEntity.checksum)!),
|
||||
)
|
||||
.get();
|
||||
return {for (final entry in mapping) entry.assetId: entry.checksum};
|
||||
Future<void> reconcileHashesFromCloudId() async {
|
||||
await _db.customUpdate(
|
||||
'''
|
||||
UPDATE local_asset_entity
|
||||
SET checksum = remote_asset_entity.checksum
|
||||
FROM remote_asset_cloud_id_entity
|
||||
INNER JOIN remote_asset_entity
|
||||
ON remote_asset_cloud_id_entity.asset_id = remote_asset_entity.id
|
||||
WHERE local_asset_entity.i_cloud_id = remote_asset_cloud_id_entity.cloud_id
|
||||
AND local_asset_entity.checksum IS NULL
|
||||
AND remote_asset_cloud_id_entity.adjustment_time IS local_asset_entity.adjustment_time
|
||||
AND remote_asset_cloud_id_entity.latitude IS local_asset_entity.latitude
|
||||
AND remote_asset_cloud_id_entity.longitude IS local_asset_entity.longitude
|
||||
AND remote_asset_cloud_id_entity.created_at IS local_asset_entity.created_at
|
||||
''',
|
||||
updates: {_db.localAssetEntity},
|
||||
updateKind: UpdateKind.update,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,8 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
_resumeBackup(backupProvider);
|
||||
}),
|
||||
_resumeBackup(backupProvider),
|
||||
backgroundManager.syncCloudIds(),
|
||||
// TODO: Bring back when the soft freeze issue is addressed
|
||||
// backgroundManager.syncCloudIds(),
|
||||
]);
|
||||
} else {
|
||||
await backgroundManager.hashAssets();
|
||||
|
||||
@@ -298,11 +298,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
'rating'.t(context: context).toUpperCase(),
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
'rating'.t(context: context),
|
||||
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
RatingBar(
|
||||
initialRating: exifInfo?.rating?.toDouble() ?? 0,
|
||||
|
||||
@@ -160,7 +160,8 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
_resumeBackup();
|
||||
}),
|
||||
_resumeBackup(),
|
||||
_safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
|
||||
// TODO: Bring back when the soft freeze issue is addressed
|
||||
// _safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
|
||||
]);
|
||||
} else {
|
||||
await _safeRun(backgroundManager.hashAssets(), "hashAssets");
|
||||
|
||||
@@ -20,7 +20,6 @@ import 'package:immich_mobile/services/album.service.dart';
|
||||
import 'package:immich_mobile/services/asset.service.dart';
|
||||
import 'package:immich_mobile/services/memory.service.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
final deepLinkServiceProvider = Provider(
|
||||
(ref) => DeepLinkService(
|
||||
@@ -34,7 +33,7 @@ final deepLinkServiceProvider = Provider(
|
||||
ref.watch(beta_asset_provider.assetServiceProvider),
|
||||
ref.watch(remoteAlbumServiceProvider),
|
||||
ref.watch(driftMemoryServiceProvider),
|
||||
ref.watch(currentUserProvider.select((user) => user!)),
|
||||
ref.watch(currentUserProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -52,7 +51,7 @@ class DeepLinkService {
|
||||
final RemoteAlbumService _betaRemoteAlbumService;
|
||||
final DriftMemoryService _betaMemoryServiceProvider;
|
||||
|
||||
final UserDto _currentUser;
|
||||
final UserDto? _currentUser;
|
||||
|
||||
const DeepLinkService(
|
||||
this._memoryService,
|
||||
@@ -103,13 +102,10 @@ class DeepLinkService {
|
||||
|
||||
Future<DeepLink> handleMyImmichApp(PlatformDeepLink link, WidgetRef ref, bool isColdStart) async {
|
||||
final path = link.uri.path;
|
||||
final queryParams = link.uri.queryParameters;
|
||||
|
||||
const uuidRegex = r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
|
||||
final assetRegex = RegExp('/photos/($uuidRegex)');
|
||||
final albumRegex = RegExp('/albums/($uuidRegex)');
|
||||
// Share links can use UUID keys or custom slugs
|
||||
final shareRegex = RegExp(r'/share/([^/?]+)');
|
||||
|
||||
PageRouteInfo<dynamic>? deepLinkRoute;
|
||||
if (assetRegex.hasMatch(path)) {
|
||||
@@ -120,19 +116,6 @@ class DeepLinkService {
|
||||
deepLinkRoute = await _buildAlbumDeepLink(albumId);
|
||||
} else if (path == "/memory") {
|
||||
deepLinkRoute = await _buildMemoryDeepLink(null);
|
||||
} else if (shareRegex.hasMatch(path)) {
|
||||
// Handle shared links by opening them in the browser
|
||||
// The mobile app doesn't have a native viewer for external shared links yet
|
||||
final serverUrl = queryParams['server'];
|
||||
final shareKey = shareRegex.firstMatch(path)?.group(1);
|
||||
if (serverUrl != null && shareKey != null) {
|
||||
final decodedServerUrl = Uri.decodeComponent(serverUrl);
|
||||
final shareUrl = Uri.parse('$decodedServerUrl/share/$shareKey');
|
||||
await launchUrl(shareUrl, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
// Return appropriate deep link based on app state
|
||||
if (isColdStart) return DeepLink.defaultPath;
|
||||
return DeepLink.none;
|
||||
}
|
||||
|
||||
// Deep link resolution failed, safely handle it based on the app state
|
||||
@@ -148,9 +131,18 @@ class DeepLinkService {
|
||||
if (Store.isBetaTimelineEnabled) {
|
||||
List<DriftMemory> memories = [];
|
||||
|
||||
memories = memoryId == null
|
||||
? await _betaMemoryServiceProvider.getMemoryLane(_currentUser.id)
|
||||
: [await _betaMemoryServiceProvider.get(memoryId)].whereType<DriftMemory>().toList();
|
||||
if (memoryId == null) {
|
||||
if (_currentUser == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
memories = await _betaMemoryServiceProvider.getMemoryLane(_currentUser.id);
|
||||
} else {
|
||||
final memory = await _betaMemoryServiceProvider.get(memoryId);
|
||||
if (memory != null) {
|
||||
memories = [memory];
|
||||
}
|
||||
}
|
||||
|
||||
if (memories.isEmpty) {
|
||||
return null;
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
|
||||
@@ -27,6 +28,8 @@ class SyncStatusAndActions extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final serverVersion = ref.watch(serverInfoProvider.select((value) => value.serverVersion));
|
||||
|
||||
Future<void> exportDatabase() async {
|
||||
try {
|
||||
// WAL Checkpoint to ensure all changes are written to the database
|
||||
@@ -135,6 +138,14 @@ class SyncStatusAndActions extends HookConsumerWidget {
|
||||
ref.read(backgroundSyncProvider).syncRemote();
|
||||
},
|
||||
),
|
||||
if (CurrentPlatform.isIOS && serverVersion.isAtLeast(major: 2, minor: 5))
|
||||
SettingListTile(
|
||||
title: "Sync Cloud Ids".t(context: context),
|
||||
leading: const Icon(Icons.cloud_circle_rounded),
|
||||
subtitle: "tap_to_run_job".t(context: context),
|
||||
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).cloudIdSyncStatus),
|
||||
onTap: ref.read(backgroundSyncProvider).syncCloudIds,
|
||||
),
|
||||
SettingListTile(
|
||||
title: "hash_asset".t(context: context),
|
||||
leading: const Icon(Icons.tag),
|
||||
|
||||
@@ -33,7 +33,7 @@ void main() {
|
||||
registerFallbackValue(LocalAssetStub.image1);
|
||||
registerFallbackValue(<String, String>{});
|
||||
|
||||
when(() => mockAssetRepo.getHashMappingFromCloudId()).thenAnswer((_) async => {});
|
||||
when(() => mockAssetRepo.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
|
||||
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||
});
|
||||
|
||||
@@ -191,5 +191,4 @@ void main() {
|
||||
verify(() => mockNativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
4
mobile/test/drift/main/generated/schema.dart
generated
4
mobile/test/drift/main/generated/schema.dart
generated
@@ -20,6 +20,7 @@ import 'schema_v14.dart' as v14;
|
||||
import 'schema_v15.dart' as v15;
|
||||
import 'schema_v16.dart' as v16;
|
||||
import 'schema_v17.dart' as v17;
|
||||
import 'schema_v18.dart' as v18;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -59,6 +60,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v16.DatabaseAtV16(db);
|
||||
case 17:
|
||||
return v17.DatabaseAtV17(db);
|
||||
case 18:
|
||||
return v18.DatabaseAtV18(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
@@ -82,5 +85,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
];
|
||||
}
|
||||
|
||||
8342
mobile/test/drift/main/generated/schema_v18.dart
generated
Normal file
8342
mobile/test/drift/main/generated/schema_v18.dart
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/drift.dart' hide isNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
@@ -8,11 +8,13 @@ import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.d
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
|
||||
void main() {
|
||||
final now = DateTime(2024, 1, 15);
|
||||
late Drift db;
|
||||
late DriftLocalAssetRepository repository;
|
||||
|
||||
@@ -25,68 +27,98 @@ void main() {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
Future<void> insertLocalAsset({
|
||||
required String id,
|
||||
String? checksum,
|
||||
DateTime? createdAt,
|
||||
AssetType type = AssetType.image,
|
||||
bool isFavorite = false,
|
||||
String? iCloudId,
|
||||
DateTime? adjustmentTime,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
}) async {
|
||||
final created = createdAt ?? now;
|
||||
await db
|
||||
.into(db.localAssetEntity)
|
||||
.insert(
|
||||
LocalAssetEntityCompanion.insert(
|
||||
id: id,
|
||||
name: 'asset_$id.jpg',
|
||||
checksum: Value(checksum),
|
||||
type: type,
|
||||
createdAt: Value(created),
|
||||
updatedAt: Value(created),
|
||||
isFavorite: Value(isFavorite),
|
||||
iCloudId: Value(iCloudId),
|
||||
adjustmentTime: Value(adjustmentTime),
|
||||
latitude: Value(latitude),
|
||||
longitude: Value(longitude),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> insertRemoteAsset({
|
||||
required String id,
|
||||
required String checksum,
|
||||
required String ownerId,
|
||||
DateTime? deletedAt,
|
||||
}) async {
|
||||
await db
|
||||
.into(db.remoteAssetEntity)
|
||||
.insert(
|
||||
RemoteAssetEntityCompanion.insert(
|
||||
id: id,
|
||||
name: 'remote_$id.jpg',
|
||||
checksum: checksum,
|
||||
type: AssetType.image,
|
||||
createdAt: Value(now),
|
||||
updatedAt: Value(now),
|
||||
ownerId: ownerId,
|
||||
visibility: AssetVisibility.timeline,
|
||||
deletedAt: Value(deletedAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> insertRemoteAssetCloudId({
|
||||
required String assetId,
|
||||
required String? cloudId,
|
||||
DateTime? createdAt,
|
||||
DateTime? adjustmentTime,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
}) async {
|
||||
await db
|
||||
.into(db.remoteAssetCloudIdEntity)
|
||||
.insert(
|
||||
RemoteAssetCloudIdEntityCompanion.insert(
|
||||
assetId: assetId,
|
||||
cloudId: Value(cloudId),
|
||||
createdAt: Value(createdAt),
|
||||
adjustmentTime: Value(adjustmentTime),
|
||||
latitude: Value(latitude),
|
||||
longitude: Value(longitude),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> insertUser(String id, String email) async {
|
||||
await db.into(db.userEntity).insert(UserEntityCompanion.insert(id: id, email: email, name: email));
|
||||
}
|
||||
|
||||
group('getRemovalCandidates', () {
|
||||
final userId = 'user-123';
|
||||
final otherUserId = 'user-456';
|
||||
final now = DateTime(2024, 1, 15);
|
||||
final cutoffDate = DateTime(2024, 1, 10);
|
||||
final beforeCutoff = DateTime(2024, 1, 5);
|
||||
final afterCutoff = DateTime(2024, 1, 12);
|
||||
|
||||
Future<void> insertUser(String id, String email) async {
|
||||
await db.into(db.userEntity).insert(UserEntityCompanion.insert(id: id, email: email, name: email));
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
await insertUser(userId, 'user@test.com');
|
||||
await insertUser(otherUserId, 'other@test.com');
|
||||
});
|
||||
|
||||
Future<void> insertLocalAsset({
|
||||
required String id,
|
||||
required String checksum,
|
||||
required DateTime createdAt,
|
||||
required AssetType type,
|
||||
required bool isFavorite,
|
||||
}) async {
|
||||
await db
|
||||
.into(db.localAssetEntity)
|
||||
.insert(
|
||||
LocalAssetEntityCompanion.insert(
|
||||
id: id,
|
||||
name: 'asset_$id.jpg',
|
||||
checksum: Value(checksum),
|
||||
type: type,
|
||||
createdAt: Value(createdAt),
|
||||
updatedAt: Value(createdAt),
|
||||
isFavorite: Value(isFavorite),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> insertRemoteAsset({
|
||||
required String id,
|
||||
required String checksum,
|
||||
required String ownerId,
|
||||
DateTime? deletedAt,
|
||||
}) async {
|
||||
await db
|
||||
.into(db.remoteAssetEntity)
|
||||
.insert(
|
||||
RemoteAssetEntityCompanion.insert(
|
||||
id: id,
|
||||
name: 'remote_$id.jpg',
|
||||
checksum: checksum,
|
||||
type: AssetType.image,
|
||||
createdAt: Value(now),
|
||||
updatedAt: Value(now),
|
||||
ownerId: ownerId,
|
||||
visibility: AssetVisibility.timeline,
|
||||
deletedAt: Value(deletedAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> insertLocalAlbum({required String id, required String name, required bool isIosSharedAlbum}) async {
|
||||
await db
|
||||
.into(db.localAlbumEntity)
|
||||
@@ -211,11 +243,7 @@ void main() {
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
|
||||
|
||||
final result = await repository.getRemovalCandidates(
|
||||
userId,
|
||||
cutoffDate,
|
||||
keepMediaType: AssetKeepType.photosOnly,
|
||||
);
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.photosOnly);
|
||||
|
||||
expect(result.assets.length, 1);
|
||||
expect(result.assets[0].id, 'local-video');
|
||||
@@ -243,11 +271,7 @@ void main() {
|
||||
);
|
||||
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
|
||||
|
||||
final result = await repository.getRemovalCandidates(
|
||||
userId,
|
||||
cutoffDate,
|
||||
keepMediaType: AssetKeepType.videosOnly,
|
||||
);
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.videosOnly);
|
||||
|
||||
expect(result.assets.length, 1);
|
||||
expect(result.assets[0].id, 'local-photo');
|
||||
@@ -507,11 +531,7 @@ void main() {
|
||||
await insertRemoteAsset(id: 'remote-3', checksum: 'checksum-3', ownerId: userId);
|
||||
await insertLocalAlbumAsset(albumId: 'album-3', assetId: 'local-3');
|
||||
|
||||
final result = await repository.getRemovalCandidates(
|
||||
userId,
|
||||
cutoffDate,
|
||||
keepAlbumIds: {'album-1', 'album-2'},
|
||||
);
|
||||
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {'album-1', 'album-2'});
|
||||
|
||||
expect(result.assets.length, 1);
|
||||
expect(result.assets[0].id, 'local-3');
|
||||
@@ -644,4 +664,313 @@ void main() {
|
||||
expect(result.assets[0].id, 'local-video');
|
||||
});
|
||||
});
|
||||
|
||||
group('reconcileHashesFromCloudId', () {
|
||||
final userId = 'user-123';
|
||||
final createdAt = DateTime(2024, 1, 10);
|
||||
final adjustmentTime = DateTime(2024, 1, 11);
|
||||
const latitude = 37.7749;
|
||||
const longitude = -122.4194;
|
||||
|
||||
setUp(() async {
|
||||
await insertUser(userId, 'user@test.com');
|
||||
});
|
||||
|
||||
test('updates local asset checksum when all metadata matches', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, 'hash-abc123');
|
||||
});
|
||||
|
||||
test('does not update when local asset already has checksum', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: 'existing-checksum',
|
||||
iCloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, 'existing-checksum');
|
||||
});
|
||||
|
||||
test('does not update when adjustment_time does not match', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: DateTime(2024, 1, 12),
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, isNull);
|
||||
});
|
||||
|
||||
test('does not update when latitude does not match', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: 40.7128,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, isNull);
|
||||
});
|
||||
|
||||
test('does not update when longitude does not match', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: -74.0060,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, isNull);
|
||||
});
|
||||
|
||||
test('does not update when createdAt does not match', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-123',
|
||||
createdAt: DateTime(2024, 1, 5),
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, isNull);
|
||||
});
|
||||
|
||||
test('does not update when iCloudId is null', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: null,
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, isNull);
|
||||
});
|
||||
|
||||
test('does not update when cloudId does not match iCloudId', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-456',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, isNull);
|
||||
});
|
||||
|
||||
test('handles partial null metadata fields matching correctly', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: null,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: null,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, 'hash-abc123');
|
||||
});
|
||||
|
||||
test('does not update when one has null and other has value', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: null,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await insertRemoteAsset(id: 'remote-1', checksum: 'hash-abc123', ownerId: userId);
|
||||
|
||||
await insertRemoteAssetCloudId(
|
||||
assetId: 'remote-1',
|
||||
cloudId: 'cloud-123',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, isNull);
|
||||
});
|
||||
|
||||
test('handles no matching assets gracefully', () async {
|
||||
await insertLocalAsset(
|
||||
id: 'local-1',
|
||||
checksum: null,
|
||||
iCloudId: 'cloud-999',
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
|
||||
await repository.reconcileHashesFromCloudId();
|
||||
|
||||
final updated = await repository.getById('local-1');
|
||||
expect(updated?.checksum, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.8",
|
||||
"@types/node": "^24.10.9",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -63,7 +63,7 @@ importers:
|
||||
specifier: ^4.13.1
|
||||
version: 4.13.4
|
||||
'@types/node':
|
||||
specifier: ^24.10.8
|
||||
specifier: ^24.10.9
|
||||
version: 24.10.9
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^3.0.0
|
||||
@@ -220,7 +220,7 @@ importers:
|
||||
specifier: ^3.4.2
|
||||
version: 3.7.1
|
||||
'@types/node':
|
||||
specifier: ^24.10.8
|
||||
specifier: ^24.10.9
|
||||
version: 24.10.9
|
||||
'@types/pg':
|
||||
specifier: ^8.15.1
|
||||
@@ -320,7 +320,7 @@ importers:
|
||||
version: 1.1.0
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.10.8
|
||||
specifier: ^24.10.9
|
||||
version: 24.10.9
|
||||
typescript:
|
||||
specifier: ^5.3.3
|
||||
@@ -639,7 +639,7 @@ importers:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
'@types/node':
|
||||
specifier: ^24.10.8
|
||||
specifier: ^24.10.9
|
||||
version: 24.10.9
|
||||
'@types/nodemailer':
|
||||
specifier: ^7.0.0
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.8",
|
||||
"@types/node": "^24.10.9",
|
||||
"@types/nodemailer": "^7.0.0",
|
||||
"@types/picomatch": "^4.0.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface MoveRequest {
|
||||
|
||||
export type ThumbnailPathEntity = { id: string; ownerId: string };
|
||||
|
||||
export type ImagePathOptions = { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean };
|
||||
|
||||
let instance: StorageCore | null;
|
||||
|
||||
let mediaLocation: string | undefined;
|
||||
@@ -110,14 +112,7 @@ export class StorageCore {
|
||||
return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`);
|
||||
}
|
||||
|
||||
static getImagePath(
|
||||
asset: ThumbnailPathEntity,
|
||||
{
|
||||
fileType,
|
||||
format,
|
||||
isEdited,
|
||||
}: { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean },
|
||||
) {
|
||||
static getImagePath(asset: ThumbnailPathEntity, { fileType, format, isEdited }: ImagePathOptions) {
|
||||
return StorageCore.getNestedPath(
|
||||
StorageFolder.Thumbnails,
|
||||
asset.ownerId,
|
||||
|
||||
@@ -346,6 +346,13 @@ export const columns = {
|
||||
'asset.height',
|
||||
],
|
||||
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
|
||||
assetFilesForThumbnail: [
|
||||
'asset_file.id',
|
||||
'asset_file.path',
|
||||
'asset_file.type',
|
||||
'asset_file.isEdited',
|
||||
'asset_file.isProgressive',
|
||||
],
|
||||
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
|
||||
authApiKey: ['api_key.id', 'api_key.permissions'],
|
||||
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],
|
||||
|
||||
@@ -165,11 +165,13 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" in ($1, $2, $3)
|
||||
) as agg
|
||||
) as "files",
|
||||
(
|
||||
@@ -191,7 +193,7 @@ from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
"asset"."id" = $4
|
||||
|
||||
-- AssetJobRepository.getForMetadataExtraction
|
||||
select
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { Asset, columns } from 'src/database';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
@@ -104,7 +105,15 @@ export class AssetJobRepository {
|
||||
'asset.thumbhash',
|
||||
'asset.type',
|
||||
])
|
||||
.select(withFiles)
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('asset_file')
|
||||
.select(columns.assetFilesForThumbnail)
|
||||
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||
.where('asset_file.type', 'in', [AssetFileType.Thumbnail, AssetFileType.Preview, AssetFileType.FullSize]),
|
||||
).as('files'),
|
||||
)
|
||||
.select(withEdits)
|
||||
.$call(withExifInner)
|
||||
.where('asset.id', '=', id)
|
||||
|
||||
@@ -904,11 +904,12 @@ export class AssetRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async upsertFile(file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>): Promise<void> {
|
||||
const value = { ...file, assetId: asUuid(file.assetId) };
|
||||
async upsertFile(
|
||||
file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.insertInto('asset_file')
|
||||
.values(value)
|
||||
.values(file)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
|
||||
path: eb.ref('excluded.path'),
|
||||
@@ -918,19 +919,19 @@ export class AssetRepository {
|
||||
}
|
||||
|
||||
async upsertFiles(
|
||||
files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>[],
|
||||
files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>[],
|
||||
): Promise<void> {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = files.map((row) => ({ ...row, assetId: asUuid(row.assetId) }));
|
||||
await this.db
|
||||
.insertInto('asset_file')
|
||||
.values(values)
|
||||
.values(files)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
|
||||
path: eb.ref('excluded.path'),
|
||||
isProgressive: eb.ref('excluded.isProgressive'),
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset_file" ADD "isProgressive" boolean NOT NULL DEFAULT false;`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset_file" DROP COLUMN "isProgressive";`.execute(db);
|
||||
}
|
||||
@@ -40,4 +40,7 @@ export class AssetFileTable {
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isEdited!: Generated<boolean>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isProgressive!: Generated<boolean>;
|
||||
}
|
||||
|
||||
@@ -572,6 +572,35 @@ describe(AssetMediaService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not return the unedited version if requested using a shared link', async () => {
|
||||
const editedAsset = {
|
||||
...assetStub.withCropEdit,
|
||||
files: [
|
||||
...assetStub.withCropEdit.files,
|
||||
{
|
||||
id: 'edited-file',
|
||||
type: AssetFileType.FullSize,
|
||||
path: '/uploads/user-id/fullsize/edited.jpg',
|
||||
isEdited: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getForOriginal.mockResolvedValue({
|
||||
...editedAsset,
|
||||
editedPath: '/uploads/user-id/fullsize/edited.jpg',
|
||||
});
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.adminSharedLink, 'asset-id', { edited: false })).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/uploads/user-id/fullsize/edited.jpg',
|
||||
fileName: 'asset-id.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should download original file when edited=false', async () => {
|
||||
const editedAsset = {
|
||||
...assetStub.withCropEdit,
|
||||
@@ -711,6 +740,28 @@ describe(AssetMediaService.name, () => {
|
||||
);
|
||||
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false);
|
||||
});
|
||||
|
||||
it('should not return the unedited version if requested using a shared link', async () => {
|
||||
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.asset.getForThumbnail.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
path: '/uploads/user-id/thumbs/edited-thumbnail.jpg',
|
||||
});
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.adminSharedLink, assetStub.image.id, {
|
||||
size: AssetMediaSize.THUMBNAIL,
|
||||
edited: true,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/uploads/user-id/thumbs/edited-thumbnail.jpg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
contentType: 'image/jpeg',
|
||||
fileName: 'asset-id_thumbnail.jpg',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playbackVideo', () => {
|
||||
|
||||
@@ -196,6 +196,10 @@ export class AssetMediaService extends BaseService {
|
||||
async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise<ImmichFileResponse> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] });
|
||||
|
||||
if (auth.sharedLink) {
|
||||
dto.edited = true;
|
||||
}
|
||||
|
||||
const { originalPath, originalFileName, editedPath } = await this.assetRepository.getForOriginal(
|
||||
id,
|
||||
dto.edited ?? false,
|
||||
@@ -222,6 +226,10 @@ export class AssetMediaService extends BaseService {
|
||||
throw new BadRequestException('May not request original file');
|
||||
}
|
||||
|
||||
if (auth.sharedLink) {
|
||||
dto.edited = true;
|
||||
}
|
||||
|
||||
const size = (dto.size ?? AssetMediaSize.THUMBNAIL) as unknown as AssetFileType;
|
||||
const { originalPath, originalFileName, path } = await this.assetRepository.getForThumbnail(
|
||||
id,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
|
||||
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||
import { AssetMetadataKey, AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
|
||||
import { AssetStats } from 'src/repositories/asset.repository';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
@@ -813,4 +814,25 @@ describe(AssetService.name, () => {
|
||||
expect(mocks.asset.upsertBulkMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('editAsset', () => {
|
||||
it('should enforce crop first', async () => {
|
||||
await expect(
|
||||
sut.editAsset(authStub.admin, 'asset-1', {
|
||||
edits: [
|
||||
{
|
||||
action: AssetEditAction.Rotate,
|
||||
parameters: { angle: 90 },
|
||||
},
|
||||
{
|
||||
action: AssetEditAction.Crop,
|
||||
parameters: { x: 0, y: 0, width: 100, height: 100 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.assetEdit.replaceAll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
mapStats,
|
||||
} from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEditAction, AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto';
|
||||
import { AssetEditAction, AssetEditActionCrop, AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto';
|
||||
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
@@ -574,16 +574,21 @@ export class AssetService extends BaseService {
|
||||
throw new BadRequestException('Editing SVG images is not supported');
|
||||
}
|
||||
|
||||
// check that crop parameters will not go out of bounds
|
||||
const { width: assetWidth, height: assetHeight } = getDimensions(asset.exifInfo!);
|
||||
|
||||
if (!assetWidth || !assetHeight) {
|
||||
throw new BadRequestException('Asset dimensions are not available for editing');
|
||||
const cropIndex = dto.edits.findIndex((e) => e.action === AssetEditAction.Crop);
|
||||
if (cropIndex > 0) {
|
||||
throw new BadRequestException('Crop action must be the first edit action');
|
||||
}
|
||||
|
||||
const crop = dto.edits.find((e) => e.action === AssetEditAction.Crop)?.parameters;
|
||||
const crop = cropIndex === -1 ? null : (dto.edits[cropIndex] as AssetEditActionCrop);
|
||||
if (crop) {
|
||||
const { x, y, width, height } = crop;
|
||||
// check that crop parameters will not go out of bounds
|
||||
const { width: assetWidth, height: assetHeight } = getDimensions(asset.exifInfo!);
|
||||
|
||||
if (!assetWidth || !assetHeight) {
|
||||
throw new BadRequestException('Asset dimensions are not available for editing');
|
||||
}
|
||||
|
||||
const { x, y, width, height } = crop.parameters;
|
||||
if (x + width > assetWidth || y + height > assetHeight) {
|
||||
throw new BadRequestException('Crop parameters are out of bounds');
|
||||
}
|
||||
|
||||
@@ -388,12 +388,14 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
|
||||
@@ -426,12 +428,14 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -463,12 +467,14 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -673,6 +679,16 @@ describe(MediaService.name, () => {
|
||||
}),
|
||||
expect.stringContaining('thumbnail.webp'),
|
||||
);
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Preview,
|
||||
isProgressive: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Thumbnail,
|
||||
isProgressive: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should generate progressive JPEG for thumbnail when enabled', async () => {
|
||||
@@ -699,6 +715,37 @@ describe(MediaService.name, () => {
|
||||
}),
|
||||
expect.stringContaining('thumbnail.jpeg'),
|
||||
);
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Preview,
|
||||
isProgressive: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Thumbnail,
|
||||
isProgressive: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should never set isProgressive for videos', async () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR);
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
image: { preview: { progressive: true }, thumbnail: { progressive: true } },
|
||||
});
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.video.id });
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Preview,
|
||||
isProgressive: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Thumbnail,
|
||||
isProgressive: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should delete previous thumbnail if different path', async () => {
|
||||
@@ -3353,14 +3400,38 @@ describe(MediaService.name, () => {
|
||||
files: [],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false },
|
||||
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false },
|
||||
await sut['syncFiles'](asset.files, [
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
path: '/new/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/new/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
|
||||
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false },
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
path: '/new/preview.jpg',
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
path: '/new/thumbnail.jpg',
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
@@ -3376,6 +3447,7 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
@@ -3383,18 +3455,43 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false },
|
||||
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false },
|
||||
await sut['syncFiles'](asset.files, [
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
path: '/new/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/new/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
|
||||
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false },
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
path: '/new/preview.jpg',
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
path: '/new/thumbnail.jpg',
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
@@ -3413,6 +3510,7 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
@@ -3420,24 +3518,30 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Preview, isEdited: false },
|
||||
{ type: AssetFileType.Thumbnail, isEdited: false },
|
||||
]);
|
||||
await sut['syncFiles'](asset.files, []);
|
||||
|
||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
|
||||
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg', isEdited: false },
|
||||
{
|
||||
id: 'file-1',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
@@ -3456,6 +3560,7 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
path: '/same/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
@@ -3463,13 +3568,26 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/same/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Preview, newPath: '/same/preview.jpg', isEdited: false },
|
||||
{ type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg', isEdited: false },
|
||||
await sut['syncFiles'](asset.files, [
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
path: '/same/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/same/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||
@@ -3487,6 +3605,7 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
@@ -3494,19 +3613,43 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false }, // replace
|
||||
{ type: AssetFileType.Thumbnail, isEdited: false }, // delete
|
||||
{ type: AssetFileType.FullSize, newPath: '/new/fullsize.jpg', isEdited: false }, // new
|
||||
await sut['syncFiles'](asset.files, [
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
path: '/new/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
}, // replace
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.FullSize,
|
||||
path: '/new/fullsize.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
}, // new
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
|
||||
{ assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize, isEdited: false },
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
path: '/new/preview.jpg',
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
path: '/new/fullsize.jpg',
|
||||
type: AssetFileType.FullSize,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
|
||||
{
|
||||
@@ -3515,6 +3658,7 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
@@ -3529,7 +3673,7 @@ describe(MediaService.name, () => {
|
||||
files: [],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset, []);
|
||||
await sut['syncFiles'](asset.files, []);
|
||||
|
||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
||||
@@ -3546,15 +3690,79 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset, [
|
||||
{ type: AssetFileType.Thumbnail, isEdited: false }, // file doesn't exist, newPath not provided
|
||||
]);
|
||||
await sut['syncFiles'](asset.files, []);
|
||||
|
||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
|
||||
{
|
||||
id: 'file-1',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
data: { files: ['/old/preview.jpg'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('should update database when isProgressive changes', async () => {
|
||||
const asset = {
|
||||
id: 'asset-id',
|
||||
files: [
|
||||
{
|
||||
id: 'file-1',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
assetId: 'asset-id',
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await sut['syncFiles'](asset.files, [
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: true,
|
||||
},
|
||||
{
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Thumbnail,
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
path: '/old/preview.jpg',
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
isProgressive: true,
|
||||
},
|
||||
]);
|
||||
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
||||
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
||||
import { AssetFile, Exif } from 'src/database';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
|
||||
@@ -45,11 +45,13 @@ import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { clamp, isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
|
||||
import { getOutputDimensions } from 'src/utils/transform';
|
||||
|
||||
interface UpsertFileOptions {
|
||||
assetId: string;
|
||||
type: AssetFileType;
|
||||
path: string;
|
||||
isEdited: boolean;
|
||||
isProgressive: boolean;
|
||||
}
|
||||
|
||||
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
|
||||
@@ -171,18 +173,22 @@ export class MediaService extends BaseService {
|
||||
@OnJob({ name: JobName.AssetEditThumbnailGeneration, queue: QueueName.Editor })
|
||||
async handleAssetEditThumbnailGeneration({ id }: JobOf<JobName.AssetEditThumbnailGeneration>): Promise<JobStatus> {
|
||||
const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
|
||||
const config = await this.getConfig({ withCache: true });
|
||||
|
||||
if (!asset) {
|
||||
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`);
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
const generated = await this.generateEditedThumbnails(asset);
|
||||
const generated = await this.generateEditedThumbnails(asset, config);
|
||||
await this.syncFiles(
|
||||
asset.files.filter((asset) => asset.isEdited),
|
||||
generated?.files ?? [],
|
||||
);
|
||||
|
||||
let thumbhash: Buffer | undefined = generated?.thumbhash;
|
||||
if (!thumbhash) {
|
||||
const { image } = await this.getConfig({ withCache: true });
|
||||
const extractedImage = await this.extractOriginalImage(asset, image);
|
||||
const extractedImage = await this.extractOriginalImage(asset, config.image);
|
||||
const { info, data, colorspace } = extractedImage;
|
||||
|
||||
thumbhash = await this.mediaRepository.generateThumbhash(data, {
|
||||
@@ -206,6 +212,7 @@ export class MediaService extends BaseService {
|
||||
@OnJob({ name: JobName.AssetGenerateThumbnails, queue: QueueName.ThumbnailGeneration })
|
||||
async handleGenerateThumbnails({ id }: JobOf<JobName.AssetGenerateThumbnails>): Promise<JobStatus> {
|
||||
const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
|
||||
const config = await this.getConfig({ withCache: true });
|
||||
|
||||
if (!asset) {
|
||||
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`);
|
||||
@@ -217,32 +224,25 @@ export class MediaService extends BaseService {
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
let generated: {
|
||||
previewPath: string;
|
||||
thumbnailPath: string;
|
||||
fullsizePath?: string;
|
||||
thumbhash: Buffer;
|
||||
fullsizeDimensions?: ImageDimensions;
|
||||
};
|
||||
let generated: Awaited<ReturnType<MediaService['generateImageThumbnails']>>;
|
||||
if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) {
|
||||
this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`);
|
||||
generated = await this.generateVideoThumbnails(asset);
|
||||
generated = await this.generateVideoThumbnails(asset, config);
|
||||
} else if (asset.type === AssetType.Image) {
|
||||
this.logger.verbose(`Thumbnail generation for image ${id} ${asset.originalPath}`);
|
||||
generated = await this.generateImageThumbnails(asset);
|
||||
generated = await this.generateImageThumbnails(asset, config);
|
||||
} else {
|
||||
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
await this.syncFiles(asset, [
|
||||
{ type: AssetFileType.Preview, newPath: generated.previewPath, isEdited: false },
|
||||
{ type: AssetFileType.Thumbnail, newPath: generated.thumbnailPath, isEdited: false },
|
||||
{ type: AssetFileType.FullSize, newPath: generated.fullsizePath, isEdited: false },
|
||||
]);
|
||||
const editedGenerated = await this.generateEditedThumbnails(asset, config);
|
||||
if (editedGenerated) {
|
||||
generated.files.push(...editedGenerated.files);
|
||||
}
|
||||
|
||||
const editiedGenerated = await this.generateEditedThumbnails(asset);
|
||||
const thumbhash = editiedGenerated?.thumbhash || generated.thumbhash;
|
||||
await this.syncFiles(asset.files, generated.files);
|
||||
const thumbhash = editedGenerated?.thumbhash || generated.thumbhash;
|
||||
|
||||
if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) {
|
||||
await this.assetRepository.update({ id: asset.id, thumbhash });
|
||||
@@ -274,11 +274,7 @@ export class MediaService extends BaseService {
|
||||
return { info, data, colorspace };
|
||||
}
|
||||
|
||||
private async extractOriginalImage(
|
||||
asset: NonNullable<ThumbnailAsset>,
|
||||
image: SystemConfig['image'],
|
||||
useEdits = false,
|
||||
) {
|
||||
private async extractOriginalImage(asset: ThumbnailAsset, image: SystemConfig['image'], useEdits = false) {
|
||||
const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName);
|
||||
const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null;
|
||||
const generateFullsize =
|
||||
@@ -305,19 +301,21 @@ export class MediaService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
private async generateImageThumbnails(asset: ThumbnailAsset, useEdits: boolean = false) {
|
||||
const { image } = await this.getConfig({ withCache: true });
|
||||
const previewPath = StorageCore.getImagePath(asset, {
|
||||
private async generateImageThumbnails(asset: ThumbnailAsset, { image }: SystemConfig, useEdits: boolean = false) {
|
||||
const previewFile = this.getImageFile(asset, {
|
||||
fileType: AssetFileType.Preview,
|
||||
isEdited: useEdits,
|
||||
format: image.preview.format,
|
||||
});
|
||||
const thumbnailPath = StorageCore.getImagePath(asset, {
|
||||
fileType: AssetFileType.Thumbnail,
|
||||
isEdited: useEdits,
|
||||
format: image.thumbnail.format,
|
||||
isProgressive: !!image.preview.progressive && image.preview.format !== ImageFormat.Webp,
|
||||
});
|
||||
this.storageCore.ensureFolders(previewPath);
|
||||
previewFile.isProgressive = !!image.preview.progressive && image.preview.format !== ImageFormat.Webp;
|
||||
const thumbnailFile = this.getImageFile(asset, {
|
||||
fileType: AssetFileType.Thumbnail,
|
||||
format: image.thumbnail.format,
|
||||
isEdited: useEdits,
|
||||
isProgressive: !!image.thumbnail.progressive && image.thumbnail.format !== ImageFormat.Webp,
|
||||
});
|
||||
this.storageCore.ensureFolders(previewFile.path);
|
||||
|
||||
// Handle embedded preview extraction for RAW files
|
||||
const extractedImage = await this.extractOriginalImage(asset, image, useEdits);
|
||||
@@ -327,26 +325,18 @@ export class MediaService extends BaseService {
|
||||
const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] };
|
||||
const promises = [
|
||||
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
|
||||
this.mediaRepository.generateThumbnail(
|
||||
data,
|
||||
{ ...image.thumbnail, ...thumbnailOptions, edits: useEdits ? asset.edits : [] },
|
||||
thumbnailPath,
|
||||
),
|
||||
this.mediaRepository.generateThumbnail(
|
||||
data,
|
||||
{ ...image.preview, ...thumbnailOptions, edits: useEdits ? asset.edits : [] },
|
||||
previewPath,
|
||||
),
|
||||
this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailFile.path),
|
||||
this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewFile.path),
|
||||
];
|
||||
|
||||
let fullsizePath: string | undefined;
|
||||
|
||||
let fullsizeFile: UpsertFileOptions | undefined;
|
||||
if (convertFullsize) {
|
||||
// convert a new fullsize image from the same source as the thumbnail
|
||||
fullsizePath = StorageCore.getImagePath(asset, {
|
||||
fullsizeFile = this.getImageFile(asset, {
|
||||
fileType: AssetFileType.FullSize,
|
||||
isEdited: useEdits,
|
||||
format: image.fullsize.format,
|
||||
isEdited: useEdits,
|
||||
isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp,
|
||||
});
|
||||
const fullsizeOptions = {
|
||||
format: image.fullsize.format,
|
||||
@@ -354,23 +344,25 @@ export class MediaService extends BaseService {
|
||||
progressive: image.fullsize.progressive,
|
||||
...thumbnailOptions,
|
||||
};
|
||||
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
|
||||
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizeFile.path));
|
||||
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) {
|
||||
fullsizePath = StorageCore.getImagePath(asset, {
|
||||
fullsizeFile = this.getImageFile(asset, {
|
||||
fileType: AssetFileType.FullSize,
|
||||
format: extracted.format,
|
||||
isEdited: false,
|
||||
isEdited: useEdits,
|
||||
isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp,
|
||||
});
|
||||
this.storageCore.ensureFolders(fullsizePath);
|
||||
fullsizeFile.isProgressive = !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp;
|
||||
this.storageCore.ensureFolders(fullsizeFile.path);
|
||||
|
||||
// Write the buffer to disk with essential EXIF data
|
||||
await this.storageRepository.createOrOverwriteFile(fullsizePath, extracted.buffer);
|
||||
await this.storageRepository.createOrOverwriteFile(fullsizeFile.path, extracted.buffer);
|
||||
await this.mediaRepository.writeExif(
|
||||
{
|
||||
orientation: asset.exifInfo.orientation,
|
||||
colorspace: asset.exifInfo.colorspace,
|
||||
},
|
||||
fullsizePath,
|
||||
fullsizeFile.path,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -378,9 +370,9 @@ export class MediaService extends BaseService {
|
||||
|
||||
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
|
||||
const promises = [
|
||||
this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewPath),
|
||||
fullsizePath
|
||||
? this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, fullsizePath)
|
||||
this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewFile.path),
|
||||
fullsizeFile
|
||||
? this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, fullsizeFile.path)
|
||||
: Promise.resolve(),
|
||||
];
|
||||
await Promise.all(promises);
|
||||
@@ -389,7 +381,11 @@ export class MediaService extends BaseService {
|
||||
const decodedDimensions = { width: info.width, height: info.height };
|
||||
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
|
||||
|
||||
return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer, fullsizeDimensions };
|
||||
return {
|
||||
files: fullsizeFile ? [previewFile, thumbnailFile, fullsizeFile] : [previewFile, thumbnailFile],
|
||||
thumbhash: outputs[0] as Buffer,
|
||||
fullsizeDimensions,
|
||||
};
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
|
||||
@@ -493,19 +489,23 @@ export class MediaService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) {
|
||||
const { image, ffmpeg } = await this.getConfig({ withCache: true });
|
||||
const previewPath = StorageCore.getImagePath(asset, {
|
||||
private async generateVideoThumbnails(
|
||||
asset: ThumbnailPathEntity & { originalPath: string },
|
||||
{ ffmpeg, image }: SystemConfig,
|
||||
) {
|
||||
const previewFile = this.getImageFile(asset, {
|
||||
fileType: AssetFileType.Preview,
|
||||
format: image.preview.format,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
});
|
||||
const thumbnailPath = StorageCore.getImagePath(asset, {
|
||||
const thumbnailFile = this.getImageFile(asset, {
|
||||
fileType: AssetFileType.Thumbnail,
|
||||
format: image.thumbnail.format,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
});
|
||||
this.storageCore.ensureFolders(previewPath);
|
||||
this.storageCore.ensureFolders(previewFile.path);
|
||||
|
||||
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
||||
const mainVideoStream = this.getMainStream(videoStreams);
|
||||
@@ -524,17 +524,16 @@ export class MediaService extends BaseService {
|
||||
format,
|
||||
);
|
||||
|
||||
await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions);
|
||||
await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions);
|
||||
await this.mediaRepository.transcode(asset.originalPath, previewFile.path, previewOptions);
|
||||
await this.mediaRepository.transcode(asset.originalPath, thumbnailFile.path, thumbnailOptions);
|
||||
|
||||
const thumbhash = await this.mediaRepository.generateThumbhash(previewPath, {
|
||||
const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path, {
|
||||
colorspace: image.colorspace,
|
||||
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
|
||||
});
|
||||
|
||||
return {
|
||||
previewPath,
|
||||
thumbnailPath,
|
||||
files: [previewFile, thumbnailFile],
|
||||
thumbhash,
|
||||
fullsizeDimensions: { width: mainVideoStream.width, height: mainVideoStream.height },
|
||||
};
|
||||
@@ -791,34 +790,28 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async syncFiles(
|
||||
asset: { id: string; files: AssetFile[] },
|
||||
files: { type: AssetFileType; newPath?: string; isEdited: boolean }[],
|
||||
) {
|
||||
private async syncFiles(oldFiles: (AssetFile & { isProgressive: boolean })[], newFiles: UpsertFileOptions[]) {
|
||||
const toUpsert: UpsertFileOptions[] = [];
|
||||
const pathsToDelete: string[] = [];
|
||||
const toDelete: AssetFile[] = [];
|
||||
const toDelete = new Set(oldFiles);
|
||||
|
||||
for (const { type, newPath, isEdited } of files) {
|
||||
const existingFile = asset.files.find((file) => file.type === type && file.isEdited === isEdited);
|
||||
|
||||
// upsert new file path
|
||||
if (newPath && existingFile?.path !== newPath) {
|
||||
toUpsert.push({ assetId: asset.id, path: newPath, type, isEdited });
|
||||
|
||||
// delete old file from disk
|
||||
if (existingFile) {
|
||||
this.logger.debug(`Deleting old ${type} image for asset ${asset.id} in favor of a replacement`);
|
||||
pathsToDelete.push(existingFile.path);
|
||||
}
|
||||
for (const newFile of newFiles) {
|
||||
const existingFile = oldFiles.find((file) => file.type === newFile.type && file.isEdited === newFile.isEdited);
|
||||
if (existingFile) {
|
||||
toDelete.delete(existingFile);
|
||||
}
|
||||
|
||||
// delete old file from disk and database
|
||||
if (!newPath && existingFile) {
|
||||
this.logger.debug(`Deleting old ${type} image for asset ${asset.id}`);
|
||||
// upsert new file path
|
||||
if (existingFile?.path !== newFile.path || existingFile.isProgressive !== newFile.isProgressive) {
|
||||
toUpsert.push(newFile);
|
||||
|
||||
pathsToDelete.push(existingFile.path);
|
||||
toDelete.push(existingFile);
|
||||
// delete old file from disk
|
||||
if (existingFile && existingFile.path !== newFile.path) {
|
||||
this.logger.debug(
|
||||
`Deleting old ${newFile.type} image for asset ${newFile.assetId} in favor of a replacement`,
|
||||
);
|
||||
pathsToDelete.push(existingFile.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -826,8 +819,12 @@ export class MediaService extends BaseService {
|
||||
await this.assetRepository.upsertFiles(toUpsert);
|
||||
}
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
await this.assetRepository.deleteFiles(toDelete);
|
||||
if (toDelete.size > 0) {
|
||||
const toDeleteArray = [...toDelete];
|
||||
for (const file of toDeleteArray) {
|
||||
pathsToDelete.push(file.path);
|
||||
}
|
||||
await this.assetRepository.deleteFiles(toDeleteArray);
|
||||
}
|
||||
|
||||
if (pathsToDelete.length > 0) {
|
||||
@@ -835,18 +832,12 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async generateEditedThumbnails(asset: ThumbnailAsset) {
|
||||
if (asset.type !== AssetType.Image) {
|
||||
private async generateEditedThumbnails(asset: ThumbnailAsset, config: SystemConfig) {
|
||||
if (asset.type !== AssetType.Image || (asset.files.length === 0 && asset.edits.length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, true) : undefined;
|
||||
|
||||
await this.syncFiles(asset, [
|
||||
{ type: AssetFileType.Preview, newPath: generated?.previewPath, isEdited: true },
|
||||
{ type: AssetFileType.Thumbnail, newPath: generated?.thumbnailPath, isEdited: true },
|
||||
{ type: AssetFileType.FullSize, newPath: generated?.fullsizePath, isEdited: true },
|
||||
]);
|
||||
const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, config, true) : undefined;
|
||||
|
||||
const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop);
|
||||
const cropBox = crop
|
||||
@@ -870,4 +861,15 @@ export class MediaService extends BaseService {
|
||||
|
||||
return generated;
|
||||
}
|
||||
|
||||
private getImageFile(asset: ThumbnailPathEntity, options: ImagePathOptions & { isProgressive: boolean }) {
|
||||
const path = StorageCore.getImagePath(asset, options);
|
||||
return {
|
||||
assetId: asset.id,
|
||||
type: options.fileType,
|
||||
path,
|
||||
isEdited: options.isEdited,
|
||||
isProgressive: options.isProgressive,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
19
server/test/fixtures/asset.stub.ts
vendored
19
server/test/fixtures/asset.stub.ts
vendored
@@ -48,9 +48,9 @@ const editedFullsizeFile = factory.assetFile({
|
||||
isEdited: true,
|
||||
});
|
||||
|
||||
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];
|
||||
const files = [fullsizeFile, previewFile, thumbnailFile];
|
||||
|
||||
const editedFiles: AssetFile[] = [
|
||||
const editedFiles = [
|
||||
fullsizeFile,
|
||||
previewFile,
|
||||
thumbnailFile,
|
||||
@@ -624,14 +624,19 @@ export const assetStub = {
|
||||
fileSizeInByte: 100_000,
|
||||
timeZone: `America/New_York`,
|
||||
},
|
||||
files: [] as AssetFile[],
|
||||
files: [],
|
||||
libraryId: null,
|
||||
visibility: AssetVisibility.Hidden,
|
||||
width: null,
|
||||
height: null,
|
||||
edits: [] as AssetEditActionItem[],
|
||||
isEdited: false,
|
||||
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif; edits: AssetEditActionItem[] }),
|
||||
} as unknown as MapAsset & {
|
||||
faces: AssetFace[];
|
||||
files: (AssetFile & { isProgressive: boolean })[];
|
||||
exifInfo: Exif;
|
||||
edits: AssetEditActionItem[];
|
||||
}),
|
||||
|
||||
livePhotoStillAsset: Object.freeze({
|
||||
id: 'live-photo-still-asset',
|
||||
@@ -653,7 +658,11 @@ export const assetStub = {
|
||||
height: null,
|
||||
edits: [] as AssetEditActionItem[],
|
||||
isEdited: false,
|
||||
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }),
|
||||
} as unknown as MapAsset & {
|
||||
faces: AssetFace[];
|
||||
files: (AssetFile & { isProgressive: boolean })[];
|
||||
edits: AssetEditActionItem[];
|
||||
}),
|
||||
|
||||
livePhotoWithOriginalFileName: Object.freeze({
|
||||
id: 'live-photo-still-asset',
|
||||
|
||||
@@ -400,11 +400,12 @@ const assetOcrFactory = (
|
||||
...ocr,
|
||||
});
|
||||
|
||||
const assetFileFactory = (file: Partial<AssetFile> = {}): AssetFile => ({
|
||||
const assetFileFactory = (file: Partial<AssetFile> = {}) => ({
|
||||
id: newUuid(),
|
||||
type: AssetFileType.Preview,
|
||||
path: '/uploads/user-id/thumbs/path.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
...file,
|
||||
});
|
||||
|
||||
|
||||
@@ -588,7 +588,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stack && withStacked}
|
||||
{#if stack && withStacked && !assetViewerManager.isShowEditor}
|
||||
{@const stackedAssets = stack.assets}
|
||||
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
|
||||
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import AlbumViewer from '$lib/components/album-page/album-viewer.svelte';
|
||||
import IndividualSharedViewer from '$lib/components/share-page/individual-shared-viewer.svelte';
|
||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||
import OpenInAppBanner from '$lib/components/shared-components/open-in-app-banner.svelte';
|
||||
import ThemeButton from '$lib/components/shared-components/theme-button.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
@@ -67,14 +65,6 @@
|
||||
<svelte:head>
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
{#if key}
|
||||
<meta
|
||||
name="apple-itunes-app"
|
||||
content="app-id=1613945652, app-argument=https://my.immich.app/share/{key}?server={encodeURIComponent(
|
||||
globalThis.location?.origin ?? ''
|
||||
)}"
|
||||
/>
|
||||
{/if}
|
||||
</svelte:head>
|
||||
{#if passwordRequired}
|
||||
<main
|
||||
@@ -116,7 +106,3 @@
|
||||
<IndividualSharedViewer {sharedLink} {isOwned} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if key && browser}
|
||||
<OpenInAppBanner shareKey={key} serverUrl={globalThis.location?.origin ?? ''} />
|
||||
{/if}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { mdiClose } from '@mdi/js';
|
||||
import { Button, Icon, Logo } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
shareKey: string;
|
||||
serverUrl: string;
|
||||
};
|
||||
|
||||
const { shareKey, serverUrl }: Props = $props();
|
||||
|
||||
const STORAGE_KEY = 'immich-open-in-app-dismissed';
|
||||
const isMobile = $derived(
|
||||
browser && /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent),
|
||||
);
|
||||
|
||||
let isDismissed = $state(browser && localStorage.getItem(STORAGE_KEY) === 'true');
|
||||
let showBanner = $derived(isMobile && !isDismissed);
|
||||
|
||||
const deepLinkUrl = $derived(
|
||||
`https://my.immich.app/share/${shareKey}?server=${encodeURIComponent(serverUrl)}`,
|
||||
);
|
||||
|
||||
function dismiss() {
|
||||
isDismissed = true;
|
||||
if (browser) {
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showBanner}
|
||||
<div
|
||||
class="fixed bottom-0 left-0 right-0 z-50 flex items-center justify-between gap-3 bg-immich-bg dark:bg-immich-dark-gray p-3 shadow-lg border-t border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="shrink-0 w-10 h-10">
|
||||
<Logo variant="icon" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-immich-primary dark:text-immich-dark-primary text-sm truncate">
|
||||
Immich
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{$t('view_in_app')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button href={deepLinkUrl} size="small" shape="round">
|
||||
{$t('open')}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={dismiss}
|
||||
class="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label={$t('close')}
|
||||
>
|
||||
<Icon icon={mdiClose} size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -9,7 +9,6 @@
|
||||
mdiCrosshairsGps,
|
||||
mdiImageSizeSelectLarge,
|
||||
mdiLinkEdit,
|
||||
mdiStateMachine,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -17,7 +16,7 @@
|
||||
{ href: Route.duplicatesUtility(), icon: mdiContentDuplicate, label: $t('review_duplicates') },
|
||||
{ href: Route.largeFileUtility(), icon: mdiImageSizeSelectLarge, label: $t('review_large_files') },
|
||||
{ href: Route.geolocationUtility(), icon: mdiCrosshairsGps, label: $t('manage_geolocation') },
|
||||
{ href: Route.workflows(), icon: mdiStateMachine, label: $t('workflows') },
|
||||
// { href: Route.workflows(), icon: mdiStateMachine, label: $t('workflows') },
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { getAssetInfo, getAssetOcr, type AssetOcrResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
const defaultSerializer = <K>(params: K) => JSON.stringify(params);
|
||||
@@ -35,6 +36,13 @@ class AssetCacheManager {
|
||||
#assetCache = new AsyncCache<AssetResponseDto>();
|
||||
#ocrCache = new AsyncCache<AssetOcrResponseDto[]>();
|
||||
|
||||
constructor() {
|
||||
eventManager.on('AssetEditsApplied', () => {
|
||||
this.#assetCache.clear();
|
||||
this.#ocrCache.clear();
|
||||
});
|
||||
}
|
||||
|
||||
async getAsset(assetIdentifier: { key?: string; slug?: string; id: string }, updateCache = true) {
|
||||
return this.#assetCache.getOrFetch(assetIdentifier, getAssetInfo, defaultSerializer, updateCache);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import TransformTool from '$lib/components/asset-viewer/editor/transform-tool/transform-tool.svelte';
|
||||
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { waitForWebsocketEvent } from '$lib/stores/websocket';
|
||||
import { editAsset, removeAssetEdits, type AssetEditsDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import { ConfirmModal, modalManager, toastManager } from '@immich/ui';
|
||||
@@ -110,25 +111,29 @@ export class EditManager {
|
||||
this.isApplyingEdits = true;
|
||||
|
||||
const edits = this.tools.flatMap((tool) => tool.manager.edits);
|
||||
if (!this.currentAsset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const assetId = this.currentAsset.id;
|
||||
|
||||
try {
|
||||
// Setup the websocket listener before sending the edit request
|
||||
const editCompleted = waitForWebsocketEvent(
|
||||
'AssetEditReadyV1',
|
||||
(event) => event.asset.id === this.currentAsset!.id,
|
||||
10_000,
|
||||
);
|
||||
const editCompleted = waitForWebsocketEvent('AssetEditReadyV1', (event) => event.asset.id === assetId, 10_000);
|
||||
|
||||
await (edits.length === 0
|
||||
? removeAssetEdits({ id: this.currentAsset!.id })
|
||||
? removeAssetEdits({ id: assetId })
|
||||
: editAsset({
|
||||
id: this.currentAsset!.id,
|
||||
id: assetId,
|
||||
assetEditActionListDto: {
|
||||
edits,
|
||||
},
|
||||
}));
|
||||
|
||||
await editCompleted;
|
||||
|
||||
eventManager.emit('AssetEditsApplied', assetId);
|
||||
|
||||
toastManager.success('Edits applied successfully');
|
||||
this.hasAppliedEdits = true;
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export type Events = {
|
||||
AssetReplace: [{ oldAssetId: string; newAssetId: string }];
|
||||
AssetsArchive: [string[]];
|
||||
AssetsDelete: [string[]];
|
||||
AssetEditsApplied: [string];
|
||||
|
||||
AlbumAddAssets: [];
|
||||
AlbumUpdate: [AlbumResponseDto];
|
||||
|
||||
@@ -6,8 +6,10 @@ class UploadManager {
|
||||
mediaTypes = $state<ServerMediaTypesResponseDto>({ image: [], sidecar: [], video: [] });
|
||||
|
||||
constructor() {
|
||||
eventManager.on('AppInit', () => this.#loadExtensions());
|
||||
eventManager.on('AuthLogout', () => this.reset());
|
||||
eventManager.onMany({
|
||||
AppInit: () => this.#loadExtensions(),
|
||||
AuthLogout: () => this.reset(),
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
|
||||
@@ -23,8 +23,10 @@ class MemoryStoreSvelte {
|
||||
#loading: Promise<void> | undefined;
|
||||
|
||||
constructor() {
|
||||
eventManager.on('AuthLogout', () => this.clearCache());
|
||||
eventManager.on('AuthUserLoaded', () => this.initialize());
|
||||
eventManager.onMany({
|
||||
AuthLogout: () => this.clearCache(),
|
||||
AuthUserLoaded: () => this.initialize(),
|
||||
});
|
||||
}
|
||||
|
||||
ready() {
|
||||
|
||||
@@ -8,8 +8,10 @@ class NotificationStore {
|
||||
notifications = $state<NotificationDto[]>([]);
|
||||
|
||||
constructor() {
|
||||
eventManager.on('AuthLogin', () => this.refresh());
|
||||
eventManager.on('AuthLogout', () => this.clear());
|
||||
eventManager.onMany({
|
||||
AuthLogin: () => this.refresh(),
|
||||
AuthLogout: () => this.clear(),
|
||||
});
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
|
||||
@@ -30,6 +30,17 @@ export class BaseEventManager<Events extends EventMap> {
|
||||
};
|
||||
}
|
||||
|
||||
onMany(subscriptions: { [T in keyof Events]?: EventCallback<Events, T> }) {
|
||||
const cleanups = Object.entries(subscriptions).map(([event, callback]) =>
|
||||
this.on(event as keyof Events, callback as EventCallback<Events, keyof Events>),
|
||||
);
|
||||
return () => {
|
||||
for (const cleanup of cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
emit<T extends keyof Events>(event: T, ...params: Events[T]) {
|
||||
const listeners = this.getListeners(event);
|
||||
for (const listener of listeners) {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getPlugins, getWorkflows } from '@immich/sdk';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url);
|
||||
|
||||
const isReady = false;
|
||||
if (!isReady) {
|
||||
redirect(307, '/utilities');
|
||||
}
|
||||
|
||||
const [workflows, plugins] = await Promise.all([getWorkflows(), getPlugins()]);
|
||||
const $t = await getFormatter();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user