From e454c3566b162951d158a5a4383e079a63d43c61 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:54:20 +0100 Subject: [PATCH 001/150] refactor: star rating (#26357) * refactor: star rating * transform rating 0 to null in controller dto * migrate rating 0 to null * deprecate rating -1 * rating type annotation * update Rating type --- i18n/en.json | 3 +- mobile/openapi/lib/api/search_api.dart | 4 +- .../lib/model/asset_bulk_update_dto.dart | 12 +- .../lib/model/metadata_search_dto.dart | 12 +- .../openapi/lib/model/random_search_dto.dart | 12 +- .../openapi/lib/model/smart_search_dto.dart | 12 +- .../lib/model/statistics_search_dto.dart | 12 +- .../openapi/lib/model/update_asset_dto.dart | 12 +- open-api/immich-openapi-specs.json | 145 ++++++++++++++++-- open-api/typescript-sdk/src/fetch-client.ts | 26 ++-- .../src/controllers/asset.controller.spec.ts | 18 ++- server/src/dtos/asset.dto.ts | 16 +- server/src/dtos/search.dto.ts | 17 +- server/src/repositories/media.repository.ts | 2 +- .../1771535611395-ConvertRating0ToNull.ts | 9 ++ server/src/services/asset.service.ts | 2 +- server/src/services/metadata.service.spec.ts | 36 +++++ server/src/services/metadata.service.ts | 4 +- .../asset-viewer/actions/rating-action.svelte | 3 +- .../detail-panel-star-rating.svelte | 8 +- .../search-bar/search-ratings-section.svelte | 6 +- web/src/lib/elements/StarRating.svelte | 46 +++--- web/src/lib/modals/SearchFilterModal.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 2 + 24 files changed, 294 insertions(+), 127 deletions(-) create mode 100644 server/src/schema/migrations/1771535611395-ConvertRating0ToNull.ts diff --git a/i18n/en.json b/i18n/en.json index b99dac5609..808fbeb695 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1810,9 +1810,8 @@ "rate_asset": "Rate Asset", "rating": "Star rating", "rating_clear": "Clear rating", - "rating_count": "{count, plural, one {# star} other {# stars}}", + "rating_count": "{count, plural, =0 {Unrated} one {# star} other {# stars}}", "rating_description": "Display the EXIF rating in the info panel", - "rating_set": "Rating set to {rating, plural, one {# star} other {# stars}}", "reaction_options": "Reaction options", "read_changelog": "Read Changelog", "readonly_mode_disabled": "Read-only mode disabled", diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 1b8ed3d9e4..085958de66 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -410,7 +410,7 @@ class SearchApi { /// Filter by person IDs /// /// * [num] rating: - /// Filter by rating + /// Filter by rating [1-5], or null for unrated /// /// * [num] size: /// Number of results to return @@ -633,7 +633,7 @@ class SearchApi { /// Filter by person IDs /// /// * [num] rating: - /// Filter by rating + /// Filter by rating [1-5], or null for unrated /// /// * [num] size: /// Number of results to return diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index a373743852..99bac7abfa 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -86,16 +86,10 @@ class AssetBulkUpdateDto { /// num? longitude; - /// Rating + /// Rating in range [1-5], or null for unrated /// /// Minimum value: -1 /// Maximum value: 5 - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// num? rating; /// Time zone (IANA timezone) @@ -223,7 +217,9 @@ class AssetBulkUpdateDto { isFavorite: mapValueOfType(json, r'isFavorite'), latitude: num.parse('${json[r'latitude']}'), longitude: num.parse('${json[r'longitude']}'), - rating: num.parse('${json[r'rating']}'), + rating: json[r'rating'] == null + ? null + : num.parse('${json[r'rating']}'), timeZone: mapValueOfType(json, r'timeZone'), visibility: AssetVisibility.fromJson(json[r'visibility']), ); diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index 4a7ca403ab..81f8d41527 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -256,16 +256,10 @@ class MetadataSearchDto { /// String? previewPath; - /// Filter by rating + /// Filter by rating [1-5], or null for unrated /// /// Minimum value: -1 /// Maximum value: 5 - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// num? rating; /// Number of results to return @@ -754,7 +748,9 @@ class MetadataSearchDto { ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], previewPath: mapValueOfType(json, r'previewPath'), - rating: num.parse('${json[r'rating']}'), + rating: json[r'rating'] == null + ? null + : num.parse('${json[r'rating']}'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), tagIds: json[r'tagIds'] is Iterable diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 7e0fb0c5c2..4166fc9f3c 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -159,16 +159,10 @@ class RandomSearchDto { /// Filter by person IDs List personIds; - /// Filter by rating + /// Filter by rating [1-5], or null for unrated /// /// Minimum value: -1 /// Maximum value: 5 - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// num? rating; /// Number of results to return @@ -565,7 +559,9 @@ class RandomSearchDto { personIds: json[r'personIds'] is Iterable ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], - rating: num.parse('${json[r'rating']}'), + rating: json[r'rating'] == null + ? null + : num.parse('${json[r'rating']}'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), tagIds: json[r'tagIds'] is Iterable diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index 7d43cea872..5f8214467f 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -199,16 +199,10 @@ class SmartSearchDto { /// String? queryAssetId; - /// Filter by rating + /// Filter by rating [1-5], or null for unrated /// /// Minimum value: -1 /// Maximum value: 5 - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// num? rating; /// Number of results to return @@ -605,7 +599,9 @@ class SmartSearchDto { : const [], query: mapValueOfType(json, r'query'), queryAssetId: mapValueOfType(json, r'queryAssetId'), - rating: num.parse('${json[r'rating']}'), + rating: json[r'rating'] == null + ? null + : num.parse('${json[r'rating']}'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), tagIds: json[r'tagIds'] is Iterable diff --git a/mobile/openapi/lib/model/statistics_search_dto.dart b/mobile/openapi/lib/model/statistics_search_dto.dart index fce2feb421..d5bbf448a3 100644 --- a/mobile/openapi/lib/model/statistics_search_dto.dart +++ b/mobile/openapi/lib/model/statistics_search_dto.dart @@ -164,16 +164,10 @@ class StatisticsSearchDto { /// Filter by person IDs List personIds; - /// Filter by rating + /// Filter by rating [1-5], or null for unrated /// /// Minimum value: -1 /// Maximum value: 5 - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// num? rating; /// Filter by state/province name @@ -495,7 +489,9 @@ class StatisticsSearchDto { personIds: json[r'personIds'] is Iterable ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], - rating: num.parse('${json[r'rating']}'), + rating: json[r'rating'] == null + ? null + : num.parse('${json[r'rating']}'), state: mapValueOfType(json, r'state'), tagIds: json[r'tagIds'] is Iterable ? (json[r'tagIds'] as Iterable).cast().toList(growable: false) diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index 42e8ec387f..8526995934 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -71,16 +71,10 @@ class UpdateAssetDto { /// num? longitude; - /// Rating + /// Rating in range [1-5], or null for unrated /// /// Minimum value: -1 /// Maximum value: 5 - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// num? rating; /// Asset visibility @@ -178,7 +172,9 @@ class UpdateAssetDto { latitude: num.parse('${json[r'latitude']}'), livePhotoVideoId: mapValueOfType(json, r'livePhotoVideoId'), longitude: num.parse('${json[r'longitude']}'), - rating: num.parse('${json[r'rating']}'), + rating: json[r'rating'] == null + ? null + : num.parse('${json[r'rating']}'), visibility: AssetVisibility.fromJson(json[r'visibility']), ); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 85ea126a6d..fecd8933a8 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9407,10 +9407,27 @@ "name": "rating", "required": false, "in": "query", - "description": "Filter by rating", + "description": "Filter by rating [1-5], or null for unrated", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v2.6.0", + "state": "Updated", + "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + } + ], + "x-immich-state": "Stable", "schema": { "minimum": -1, "maximum": 5, + "nullable": true, "type": "number" } }, @@ -15872,10 +15889,27 @@ "type": "number" }, "rating": { - "description": "Rating", + "description": "Rating in range [1-5], or null for unrated", "maximum": 5, "minimum": -1, - "type": "number" + "nullable": true, + "type": "number", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v2.6.0", + "state": "Updated", + "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + } + ], + "x-immich-state": "Stable" }, "timeZone": { "description": "Time zone (IANA timezone)", @@ -18988,10 +19022,27 @@ "type": "string" }, "rating": { - "description": "Filter by rating", + "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, "minimum": -1, - "type": "number" + "nullable": true, + "type": "number", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v2.6.0", + "state": "Updated", + "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + } + ], + "x-immich-state": "Stable" }, "size": { "description": "Number of results to return", @@ -20714,10 +20765,27 @@ "type": "array" }, "rating": { - "description": "Filter by rating", + "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, "minimum": -1, - "type": "number" + "nullable": true, + "type": "number", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v2.6.0", + "state": "Updated", + "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + } + ], + "x-immich-state": "Stable" }, "size": { "description": "Number of results to return", @@ -22088,10 +22156,27 @@ "type": "string" }, "rating": { - "description": "Filter by rating", + "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, "minimum": -1, - "type": "number" + "nullable": true, + "type": "number", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v2.6.0", + "state": "Updated", + "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + } + ], + "x-immich-state": "Stable" }, "size": { "description": "Number of results to return", @@ -22322,10 +22407,27 @@ "type": "array" }, "rating": { - "description": "Filter by rating", + "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, "minimum": -1, - "type": "number" + "nullable": true, + "type": "number", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v2.6.0", + "state": "Updated", + "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + } + ], + "x-immich-state": "Stable" }, "state": { "description": "Filter by state/province name", @@ -25209,10 +25311,27 @@ "type": "number" }, "rating": { - "description": "Rating", + "description": "Rating in range [1-5], or null for unrated", "maximum": 5, "minimum": -1, - "type": "number" + "nullable": true, + "type": "number", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v2.6.0", + "state": "Updated", + "description": "Using -1 as a rating is deprecated and will be removed in the next major version." + } + ], + "x-immich-state": "Stable" }, "visibility": { "allOf": [ diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9dae33541e..fd07ce01a7 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -834,8 +834,8 @@ export type AssetBulkUpdateDto = { latitude?: number; /** Longitude coordinate */ longitude?: number; - /** Rating */ - rating?: number; + /** Rating in range [1-5], or null for unrated */ + rating?: number | null; /** Time zone (IANA timezone) */ timeZone?: string; /** Asset visibility */ @@ -944,8 +944,8 @@ export type UpdateAssetDto = { livePhotoVideoId?: string | null; /** Longitude coordinate */ longitude?: number; - /** Rating */ - rating?: number; + /** Rating in range [1-5], or null for unrated */ + rating?: number | null; /** Asset visibility */ visibility?: AssetVisibility; }; @@ -1711,8 +1711,8 @@ export type MetadataSearchDto = { personIds?: string[]; /** Filter by preview file path */ previewPath?: string; - /** Filter by rating */ - rating?: number; + /** Filter by rating [1-5], or null for unrated */ + rating?: number | null; /** Number of results to return */ size?: number; /** Filter by state/province name */ @@ -1827,8 +1827,8 @@ export type RandomSearchDto = { ocr?: string; /** Filter by person IDs */ personIds?: string[]; - /** Filter by rating */ - rating?: number; + /** Filter by rating [1-5], or null for unrated */ + rating?: number | null; /** Number of results to return */ size?: number; /** Filter by state/province name */ @@ -1903,8 +1903,8 @@ export type SmartSearchDto = { query?: string; /** Asset ID to use as search reference */ queryAssetId?: string; - /** Filter by rating */ - rating?: number; + /** Filter by rating [1-5], or null for unrated */ + rating?: number | null; /** Number of results to return */ size?: number; /** Filter by state/province name */ @@ -1969,8 +1969,8 @@ export type StatisticsSearchDto = { ocr?: string; /** Filter by person IDs */ personIds?: string[]; - /** Filter by rating */ - rating?: number; + /** Filter by rating [1-5], or null for unrated */ + rating?: number | null; /** Filter by state/province name */ state?: string | null; /** Filter by tag IDs */ @@ -5454,7 +5454,7 @@ export function searchLargeAssets({ albumIds, city, country, createdAfter, creat model?: string | null; ocr?: string; personIds?: string[]; - rating?: number; + rating?: number | null; size?: number; state?: string | null; tagIds?: string[] | null; diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index 2893a27539..69bf1f6443 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -207,12 +207,28 @@ describe(AssetController.name, () => { }); it('should reject invalid rating', async () => { - for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: null }]) { + for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: -2 }]) { const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test); expect(status).toBe(400); expect(body).toEqual(factory.responses.badRequest()); } }); + + it('should convert rating 0 to null', async () => { + const assetId = factory.uuid(); + const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send({ rating: 0 }); + expect(service.update).toHaveBeenCalledWith(undefined, assetId, { rating: null }); + expect(status).toBe(200); + }); + + it('should leave correct ratings as-is', async () => { + const assetId = factory.uuid(); + for (const test of [{ rating: -1 }, { rating: 1 }, { rating: 5 }]) { + const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send(test); + expect(service.update).toHaveBeenCalledWith(undefined, assetId, test); + expect(status).toBe(200); + } + }); }); describe('GET /assets/statistics', () => { diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 00ea46f789..b7bd7a18e8 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { IsArray, IsDateString, @@ -16,6 +16,7 @@ import { ValidateIf, ValidateNested, } from 'class-validator'; +import { HistoryBuilder, Property } from 'src/decorators'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AssetType, AssetVisibility } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; @@ -56,12 +57,19 @@ export class UpdateAssetBase { @IsNotEmpty() longitude?: number; - @ApiProperty({ description: 'Rating' }) - @Optional() + @Property({ + description: 'Rating in range [1-5], or null for unrated', + history: new HistoryBuilder() + .added('v1') + .stable('v2') + .updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.'), + }) + @Optional({ nullable: true }) @IsInt() @Max(5) @Min(-1) - rating?: number; + @Transform(({ value }) => (value === 0 ? null : value)) + rating?: number | null; @ApiProperty({ description: 'Asset description' }) @Optional() diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 47a1889e47..f72ecdf8b6 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; import { Place } from 'src/database'; -import { HistoryBuilder } from 'src/decorators'; +import { HistoryBuilder, Property } from 'src/decorators'; import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetOrder, AssetType, AssetVisibility } from 'src/enum'; @@ -103,12 +103,21 @@ class BaseSearchDto { @ValidateUUID({ each: true, optional: true, description: 'Filter by album IDs' }) albumIds?: string[]; - @ApiPropertyOptional({ type: 'number', description: 'Filter by rating', minimum: -1, maximum: 5 }) - @Optional() + @Property({ + type: 'number', + description: 'Filter by rating [1-5], or null for unrated', + minimum: -1, + maximum: 5, + history: new HistoryBuilder() + .added('v1') + .stable('v2') + .updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.'), + }) + @Optional({ nullable: true }) @IsInt() @Max(5) @Min(-1) - rating?: number; + rating?: number | null; @ApiPropertyOptional({ description: 'Filter by OCR text content' }) @IsString() diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 7b0b30583d..58e006171a 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -107,7 +107,7 @@ export class MediaRepository { ExposureTime: tags.exposureTime, ProfileDescription: tags.profileDescription, ColorSpace: tags.colorspace, - Rating: tags.rating, + Rating: tags.rating === null ? 0 : tags.rating, // specially convert Orientation to numeric Orientation# for exiftool 'Orientation#': tags.orientation ? Number(tags.orientation) : undefined, }; diff --git a/server/src/schema/migrations/1771535611395-ConvertRating0ToNull.ts b/server/src/schema/migrations/1771535611395-ConvertRating0ToNull.ts new file mode 100644 index 0000000000..8faebb250e --- /dev/null +++ b/server/src/schema/migrations/1771535611395-ConvertRating0ToNull.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`UPDATE "asset_exif" SET "rating" = NULL WHERE "rating" = 0;`.execute(db); +} + +export async function down(): Promise { + // not supported +} diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index f41004dd1c..387b700f01 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -516,7 +516,7 @@ export class AssetService extends BaseService { dateTimeOriginal?: string; latitude?: number; longitude?: number; - rating?: number; + rating?: number | null; }) { const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; const writes = _.omitBy( diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index feaba36b1d..92ec13bea5 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1423,6 +1423,20 @@ describe(MetadataService.name, () => { ); }); + it('should handle 0 as unrated -> null', async () => { + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mockReadTags({ Rating: 0 }); + + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + rating: null, + }), + { lockedPropertiesBehavior: 'skip' }, + ); + }); + it('should handle valid negative rating value', async () => { const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); @@ -1780,6 +1794,28 @@ describe(MetadataService.name, () => { 'timeZone', ]); }); + + it('should write rating', async () => { + const asset = factory.jobAssets.sidecarWrite(); + asset.exifInfo.rating = 4; + + mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']); + mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset); + await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success); + expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Rating: 4 }); + expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, ['rating']); + }); + + it('should write null rating as 0', async () => { + const asset = factory.jobAssets.sidecarWrite(); + asset.exifInfo.rating = null; + + mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']); + mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset); + await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success); + expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Rating: 0 }); + expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, ['rating']); + }); }); describe('firstDateTime', () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index c5d7278d56..f22d4682fa 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -301,7 +301,7 @@ export class MetadataService extends BaseService { // comments description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), profileDescription: exifTags.ProfileDescription || null, - rating: validateRange(exifTags.Rating, -1, 5), + rating: exifTags.Rating === 0 ? null : validateRange(exifTags.Rating, -1, 5), // grouping livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, @@ -451,7 +451,7 @@ export class MetadataService extends BaseService { dateTimeOriginal: asset.exifInfo.dateTimeOriginal as string | null, latitude: asset.exifInfo.latitude, longitude: asset.exifInfo.longitude, - rating: asset.exifInfo.rating, + rating: asset.exifInfo.rating ?? 0, tags: asset.exifInfo.tags, timeZone: asset.exifInfo.timeZone, }, diff --git a/web/src/lib/components/asset-viewer/actions/rating-action.svelte b/web/src/lib/components/asset-viewer/actions/rating-action.svelte index 3791fccf23..c5b0197121 100644 --- a/web/src/lib/components/asset-viewer/actions/rating-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/rating-action.svelte @@ -17,10 +17,9 @@ const rateAsset = async (rating: number | null) => { try { - const updateAssetDto = rating === null ? {} : { rating }; await updateAsset({ id: asset.id, - updateAssetDto, + updateAssetDto: { rating }, }); asset = { diff --git a/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte index 15bf1f21d6..81e7d4e1fb 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte @@ -1,5 +1,5 @@ {#if !authManager.isSharedLink && $preferences?.ratings.enabled} -
+
handlePromiseError(handleChangeRating(rating))} />
{/if} diff --git a/web/src/lib/components/shared-components/search-bar/search-ratings-section.svelte b/web/src/lib/components/shared-components/search-bar/search-ratings-section.svelte index 4f01848a6c..7b9fa7414f 100644 --- a/web/src/lib/components/shared-components/search-bar/search-ratings-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-ratings-section.svelte @@ -4,13 +4,13 @@ import Combobox from '../combobox.svelte'; interface Props { - rating?: number; + rating?: number | null; } let { rating = $bindable() }: Props = $props(); const options = [ - { value: '0', label: $t('rating_count', { values: { count: 0 } }) }, + { value: 'null', label: $t('rating_count', { values: { count: 0 } }) }, { value: '1', label: $t('rating_count', { values: { count: 1 } }) }, { value: '2', label: $t('rating_count', { values: { count: 2 } }) }, { value: '3', label: $t('rating_count', { values: { count: 3 } }) }, @@ -26,7 +26,7 @@ placeholder={$t('search_rating')} hideLabel {options} - selectedOption={rating === undefined ? undefined : options[rating]} + selectedOption={rating === undefined ? undefined : options[rating === null ? 0 : rating]} onSelect={(r) => (rating = r === undefined ? undefined : Number.parseInt(r.value))} /> diff --git a/web/src/lib/elements/StarRating.svelte b/web/src/lib/elements/StarRating.svelte index f345dc86b7..803e408ec1 100644 --- a/web/src/lib/elements/StarRating.svelte +++ b/web/src/lib/elements/StarRating.svelte @@ -3,27 +3,28 @@ import { shortcuts } from '$lib/actions/shortcut'; import { generateId } from '$lib/utils/generate-id'; import { Icon } from '@immich/ui'; + import { mdiStar, mdiStarOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; + export type Rating = 1 | 2 | 3 | 4 | 5 | null; + interface Props { count?: number; - rating: number; + rating: Rating; readOnly?: boolean; - onRating: (rating: number) => void | undefined; + onRating: (rating: Rating) => void | undefined; } let { count = 5, rating, readOnly = false, onRating }: Props = $props(); let ratingSelection = $derived(rating); - let hoverRating = $state(0); - let focusRating = $state(0); + let hoverRating: Rating = $state(null); + let focusRating: Rating = $state(null); let timeoutId: ReturnType | undefined; - const starIcon = - 'M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z'; const id = generateId(); - const handleSelect = (newRating: number) => { + const handleSelect = (newRating: Rating) => { if (readOnly) { return; } @@ -35,7 +36,7 @@ onRating(newRating); }; - const setHoverRating = (value: number) => { + const setHoverRating = (value: Rating) => { if (readOnly) { return; } @@ -43,11 +44,11 @@ }; const reset = () => { - setHoverRating(0); - focusRating = 0; + setHoverRating(null); + focusRating = null; }; - const handleSelectDebounced = (value: number) => { + const handleSelectDebounced = (value: Rating) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => { handleSelect(value); @@ -58,7 +59,7 @@
setHoverRating(0)} + onmouseleave={() => setHoverRating(null)} use:focusOutside={{ onFocusOut: reset }} use:shortcuts={[ { shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: (event) => event.stopPropagation() }, @@ -69,7 +70,7 @@
{#each { length: count } as _, index (index)} {@const value = index + 1} - {@const filled = hoverRating >= value || (hoverRating === 0 && ratingSelection >= value)} + {@const filled = hoverRating === null ? (ratingSelection || 0) >= value : hoverRating >= value} {@const starId = `${id}-${value}`} @@ -77,19 +78,12 @@ for={starId} class:cursor-pointer={!readOnly} class:ring-2={focusRating === value} - onmouseover={() => setHoverRating(value)} + onmouseover={() => setHoverRating(value as Rating)} tabindex={-1} data-testid="star" > {$t('rating_count', { values: { count: value } })} - + { - focusRating = value; + focusRating = value as Rating; }} - onchange={() => handleSelectDebounced(value)} + onchange={() => handleSelectDebounced(value as Rating)} class="sr-only" /> {/each}
-{#if ratingSelection > 0 && !readOnly} +{#if ratingSelection !== null && !readOnly} - {ratio.label} + {ratio.label} {/each} diff --git a/web/src/lib/components/asset-viewer/slideshow-bar.svelte b/web/src/lib/components/asset-viewer/slideshow-bar.svelte index 0063ca404e..21f99952c3 100644 --- a/web/src/lib/components/asset-viewer/slideshow-bar.svelte +++ b/web/src/lib/components/asset-viewer/slideshow-bar.svelte @@ -2,6 +2,7 @@ import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut'; import ProgressBar from '$lib/components/shared-components/progress-bar/progress-bar.svelte'; import { ProgressBarStatus } from '$lib/constants'; + import { languageManager } from '$lib/managers/language-manager.svelte'; import SlideshowSettingsModal from '$lib/modals/SlideshowSettingsModal.svelte'; import { SlideshowNavigation, slideshowStore } from '$lib/stores/slideshow.store'; import { AssetTypeEnum } from '@immich/sdk'; @@ -199,7 +200,7 @@ variant="ghost" shape="round" color="secondary" - icon={mdiChevronLeft} + icon={languageManager.rtl ? mdiChevronRight : mdiChevronLeft} onclick={onPrevious} aria-label={$t('previous')} /> @@ -207,7 +208,7 @@ variant="ghost" shape="round" color="secondary" - icon={mdiChevronRight} + icon={languageManager.rtl ? mdiChevronLeft : mdiChevronRight} onclick={onNext} aria-label={$t('next')} /> diff --git a/web/src/lib/components/timeline/AssetLayout.svelte b/web/src/lib/components/timeline/AssetLayout.svelte index 1d3300ca71..8b06d9b72b 100644 --- a/web/src/lib/components/timeline/AssetLayout.svelte +++ b/web/src/lib/components/timeline/AssetLayout.svelte @@ -47,7 +47,7 @@ data-asset-id={asset.id} class="absolute" style:top={position.top + 'px'} - style:left={position.left + 'px'} + style:inset-inline-start={position.left + 'px'} style:width={position.width + 'px'} style:height={position.height + 'px'} out:scale|global={{ start: 0.1, duration: scaleDuration }} diff --git a/web/src/lib/components/timeline/Month.svelte b/web/src/lib/components/timeline/Month.svelte index f7ffb58c43..8a93dae633 100644 --- a/web/src/lib/components/timeline/Month.svelte +++ b/web/src/lib/components/timeline/Month.svelte @@ -54,7 +54,6 @@ {#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)} - {@const absoluteWidth = dayGroup.left} {@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
(hoveredDayGroup = dayGroup.groupTitle)} onmouseleave={() => (hoveredDayGroup = null)} > diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index f72ea60dca..d6ce722c96 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -617,7 +617,7 @@
this.viewerAssets.some((viewAsset) => viewAsset.intersecting)); #top: number = $state(0); - #left: number = $state(0); + #start: number = $state(0); #row = $state(0); #col = $state(0); #deferredLayout = false; @@ -41,12 +41,12 @@ export class DayGroup { this.#top = value; } - get left() { - return this.#left; + get start() { + return this.#start; } - set left(value: number) { - this.#left = value; + set start(value: number) { + this.#start = value; } get row() { diff --git a/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts index 71dc168971..232c67a6ba 100644 --- a/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts @@ -39,7 +39,7 @@ export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthG if (fitsInCurrentRow) { dayGroup.row = dayGroupRow; dayGroup.col = dayGroupCol++; - dayGroup.left = cumulativeWidth; + dayGroup.start = cumulativeWidth; dayGroup.top = cumulativeHeight; cumulativeWidth += dayGroup.width + timelineManager.gap; @@ -53,7 +53,7 @@ export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthG // Position at start of new row dayGroup.row = dayGroupRow; dayGroup.col = dayGroupCol; - dayGroup.left = 0; + dayGroup.start = 0; dayGroup.top = cumulativeHeight; dayGroupCol++; From 15fc6b18f3d2bc71259488d72af40b3e5290ad0b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:22:58 +0000 Subject: [PATCH 046/150] chore(deps): update dependency @types/node to ^24.10.14 (#26654) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package.json | 2 +- e2e/package.json | 2 +- open-api/typescript-sdk/package.json | 2 +- pnpm-lock.yaml | 8 ++++---- server/package.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cli/package.json b/cli/package.json index 849957ae36..61059476a4 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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.13", + "@types/node": "^24.10.14", "@vitest/coverage-v8": "^3.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", diff --git a/e2e/package.json b/e2e/package.json index ac1ae081b3..82166b069f 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -32,7 +32,7 @@ "@playwright/test": "^1.44.1", "@socket.io/component-emitter": "^3.1.2", "@types/luxon": "^3.4.2", - "@types/node": "^24.10.13", + "@types/node": "^24.10.14", "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", "@types/supertest": "^6.0.2", diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 8f057df6cc..cdf2ef19dd 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^24.10.13", + "@types/node": "^24.10.14", "typescript": "^5.3.3" }, "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dfb4375bd..d2438f6bfb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,7 +63,7 @@ importers: specifier: ^4.13.1 version: 4.13.4 '@types/node': - specifier: ^24.10.13 + specifier: ^24.10.14 version: 24.11.0 '@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.13 + specifier: ^24.10.14 version: 24.11.0 '@types/pg': specifier: ^8.15.1 @@ -320,7 +320,7 @@ importers: version: 1.2.0 devDependencies: '@types/node': - specifier: ^24.10.13 + specifier: ^24.10.14 version: 24.11.0 typescript: specifier: ^5.3.3 @@ -642,7 +642,7 @@ importers: specifier: ^2.0.0 version: 2.0.0 '@types/node': - specifier: ^24.10.13 + specifier: ^24.10.14 version: 24.11.0 '@types/nodemailer': specifier: ^7.0.0 diff --git a/server/package.json b/server/package.json index 5578f242ae..3d12e0f6e7 100644 --- a/server/package.json +++ b/server/package.json @@ -136,7 +136,7 @@ "@types/luxon": "^3.6.2", "@types/mock-fs": "^4.13.1", "@types/multer": "^2.0.0", - "@types/node": "^24.10.13", + "@types/node": "^24.10.14", "@types/nodemailer": "^7.0.0", "@types/picomatch": "^4.0.0", "@types/pngjs": "^6.0.5", From cc2dacb308ae42e86bcfbbb70036bffaafffe7de Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 11:23:13 +0100 Subject: [PATCH 047/150] chore(deps): update prom/prometheus docker digest to 4a61322 (#26653) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 4d9e7efbe9..e2e19cacdc 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -85,7 +85,7 @@ services: container_name: immich_prometheus ports: - 9090:9090 - image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702 + image: prom/prometheus@sha256:4a61322ac1103a0e3aea2a61ef1718422a48fa046441f299d71e660a3bc71ae9 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus From 9670c853c6b09062e465708b5b0a79017730186a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:02:04 +0100 Subject: [PATCH 048/150] chore(deps): update docker.io/valkey/valkey:9 docker digest to 2bce660 (#26652) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.dev.yml | 2 +- docker/docker-compose.prod.yml | 2 +- docker/docker-compose.rootless.yml | 2 +- docker/docker-compose.yml | 2 +- e2e/docker-compose.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 8c46d3c51f..c132c224aa 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -155,7 +155,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e + image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d healthcheck: test: redis-cli ping || exit 1 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index e2e19cacdc..3a5f781d5e 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -56,7 +56,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e + image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docker/docker-compose.rootless.yml b/docker/docker-compose.rootless.yml index f6eb38a429..7cbec36eb6 100644 --- a/docker/docker-compose.rootless.yml +++ b/docker/docker-compose.rootless.yml @@ -61,7 +61,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e + image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d user: '1000:1000' security_opt: - no-new-privileges:true diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 3d92655453..f016955b32 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -49,7 +49,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e + image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 8ae5762a1b..7f117ee37c 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -44,7 +44,7 @@ services: redis: container_name: immich-e2e-redis - image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e + image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d healthcheck: test: redis-cli ping || exit 1 From b2081eda1e200a998b4e47930b032ad245e5f866 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:06:22 +0100 Subject: [PATCH 049/150] fix(deps): update typescript-projects (#26657) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel Dietzler --- mise.toml | 2 +- package.json | 2 +- pnpm-lock.yaml | 1785 ++++++++++++++++++++++++------------------------ 3 files changed, 886 insertions(+), 903 deletions(-) diff --git a/mise.toml b/mise.toml index a87b1c3a29..e8a62bca40 100644 --- a/mise.toml +++ b/mise.toml @@ -16,7 +16,7 @@ config_roots = [ [tools] node = "24.13.1" flutter = "3.35.7" -pnpm = "10.30.0" +pnpm = "10.30.3" terragrunt = "0.98.0" opentofu = "1.11.4" java = "21.0.2" diff --git a/package.json b/package.json index b49e12c3e9..4449cfbdd2 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "2.5.6", "description": "Monorepo for Immich", "private": true, - "packageManager": "pnpm@10.30.0+sha512.2b5753de015d480eeb88f5b5b61e0051f05b4301808a82ec8b840c9d2adf7748eb352c83f5c1593ca703ff1017295bc3fdd3119abb9686efc96b9fcb18200937", + "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017", "engines": { "pnpm": ">=10.0.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2438f6bfb..f6f3c0f461 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,7 +67,7 @@ importers: version: 24.11.0 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) byte-size: specifier: ^9.0.0 version: 9.0.1 @@ -106,7 +106,7 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.0.0 version: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -115,10 +115,10 @@ importers: version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest-fetch-mock: specifier: ^0.4.0 - version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) yaml: specifier: ^2.3.1 version: 2.8.2 @@ -148,7 +148,7 @@ importers: version: 3.1.1(@types/react@19.2.14)(react@18.3.1) autoprefixer: specifier: ^10.4.17 - version: 10.4.24(postcss@8.5.6) + version: 10.4.27(postcss@8.5.8) docusaurus-lunr-search: specifier: ^3.3.2 version: 3.6.0(@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -157,7 +157,7 @@ importers: version: 2.3.9 postcss: specifier: ^8.4.25 - version: 8.5.6 + version: 8.5.8 prism-react-renderer: specifier: ^2.3.1 version: 2.4.1(react@18.3.1) @@ -257,7 +257,7 @@ importers: version: 3.7.2 pg: specifier: ^8.11.3 - version: 8.18.0 + version: 8.19.0 pngjs: specifier: ^7.0.0 version: 7.0.0 @@ -281,13 +281,13 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) utimes: specifier: ^5.2.1 version: 5.2.1(encoding@0.1.13) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) e2e-auth-server: devDependencies: @@ -348,28 +348,28 @@ importers: version: 0.3.2 '@nestjs/bullmq': specifier: ^11.0.1 - version: 11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(bullmq@5.69.3) + version: 11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(bullmq@5.70.1) '@nestjs/common': specifier: ^11.0.4 - version: 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.4 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^11.0.4 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) '@nestjs/platform-socket.io': specifier: ^11.0.4 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(rxjs@7.8.2) + version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(rxjs@7.8.2) '@nestjs/schedule': specifier: ^6.0.0 - version: 6.1.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + version: 6.1.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) '@nestjs/swagger': specifier: ^11.0.2 - version: 11.2.6(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) + version: 11.2.6(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/websockets': specifier: ^11.0.4 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 @@ -402,7 +402,7 @@ importers: version: 0.212.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': specifier: ^1.34.0 - version: 1.39.0 + version: 1.40.0 '@react-email/components': specifier: ^0.5.0 version: 0.5.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -429,7 +429,7 @@ importers: version: 2.2.2 bullmq: specifier: ^5.51.0 - version: 5.69.3 + version: 5.70.1 chokidar: specifier: ^4.0.3 version: 4.0.3 @@ -438,7 +438,7 @@ importers: version: 0.5.1 class-validator: specifier: ^0.14.0 - version: 0.14.3 + version: 0.14.4 compression: specifier: ^1.8.0 version: 1.8.1 @@ -504,16 +504,16 @@ importers: version: 2.1.0 nest-commander: specifier: ^3.16.0 - version: 3.20.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@types/inquirer@8.2.12)(@types/node@24.11.0)(typescript@5.9.3) + version: 3.20.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@types/inquirer@8.2.12)(@types/node@24.11.0)(typescript@5.9.3) nestjs-cls: specifier: ^5.0.0 - version: 5.4.3(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 5.4.3(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) nestjs-kysely: specifier: 3.1.2 - version: 3.1.2(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(kysely@0.28.2)(reflect-metadata@0.2.2) + version: 3.1.2(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(kysely@0.28.2)(reflect-metadata@0.2.2) nestjs-otel: specifier: ^7.0.0 - version: 7.0.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + version: 7.0.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) nodemailer: specifier: ^7.0.0 version: 7.0.13 @@ -522,7 +522,7 @@ importers: version: 6.8.2 pg: specifier: ^8.11.3 - version: 8.18.0 + version: 8.19.0 pg-connection-string: specifier: ^2.9.1 version: 2.11.0 @@ -589,16 +589,16 @@ importers: version: 10.0.1(eslint@10.0.2(jiti@2.6.1)) '@nestjs/cli': specifier: ^11.0.2 - version: 11.0.16(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@24.11.0) + version: 11.0.16(@swc/core@1.15.13(@swc/helpers@0.5.17))(@types/node@24.11.0) '@nestjs/schematics': specifier: ^11.0.0 version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.0.4 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-express@11.1.14) + version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-express@11.1.14) '@swc/core': specifier: ^1.4.14 - version: 1.15.11(@swc/helpers@0.5.17) + version: 1.15.13(@swc/helpers@0.5.17) '@types/archiver': specifier: ^7.0.0 version: 7.0.0 @@ -631,7 +631,7 @@ importers: version: 9.0.10 '@types/lodash': specifier: ^4.14.197 - version: 4.17.23 + version: 4.17.24 '@types/luxon': specifier: ^3.6.2 version: 3.7.1 @@ -646,7 +646,7 @@ importers: version: 24.11.0 '@types/nodemailer': specifier: ^7.0.0 - version: 7.0.10 + version: 7.0.11 '@types/picomatch': specifier: ^4.0.0 version: 4.0.2 @@ -673,7 +673,7 @@ importers: version: 13.15.10 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) eslint: specifier: ^10.0.0 version: 10.0.2(jiti@2.6.1) @@ -721,16 +721,16 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) unplugin-swc: specifier: ^1.4.5 - version: 1.5.9(@swc/core@1.15.11(@swc/helpers@0.5.17))(rollup@4.55.1) + version: 1.5.9(@swc/core@1.15.13(@swc/helpers@0.5.17))(rollup@4.55.1) vite-tsconfig-paths: specifier: ^6.0.0 version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) web: dependencies: @@ -745,7 +745,7 @@ importers: version: link:../open-api/typescript-sdk '@immich/ui': specifier: ^0.64.0 - version: 0.64.0(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5) + version: 0.64.0(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5) '@mapbox/mapbox-gl-rtl-text': specifier: 0.3.0 version: 0.3.0 @@ -796,7 +796,7 @@ importers: version: 4.7.8 happy-dom: specifier: ^20.0.0 - version: 20.6.3 + version: 20.7.0 intl-messageformat: specifier: ^11.0.0 version: 11.1.2 @@ -811,7 +811,7 @@ importers: version: 3.7.2 maplibre-gl: specifier: ^5.6.2 - version: 5.18.0 + version: 5.19.0 pmtiles: specifier: ^4.3.0 version: 4.4.0 @@ -866,25 +866,25 @@ importers: version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) + version: 3.0.10(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) '@sveltejs/enhanced-img': specifier: ^0.10.0 - version: 0.10.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/kit': specifier: ^2.27.1 - version: 2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte': specifier: 6.2.4 - version: 6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@tailwindcss/vite': specifier: ^4.1.7 - version: 4.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.2.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/jest-dom': specifier: ^6.4.2 version: 6.9.1 '@testing-library/svelte': specifier: ^5.2.8 - version: 5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.1) @@ -908,7 +908,7 @@ importers: version: 1.5.6 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) dotenv: specifier: ^17.0.0 version: 17.3.1 @@ -920,7 +920,7 @@ importers: version: 10.1.8(eslint@10.0.2(jiti@2.6.1)) eslint-plugin-compat: specifier: ^6.0.2 - version: 6.2.0(eslint@10.0.2(jiti@2.6.1)) + version: 6.2.1(eslint@10.0.2(jiti@2.6.1)) eslint-plugin-svelte: specifier: ^3.12.4 version: 3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.5) @@ -953,25 +953,25 @@ importers: version: 5.53.5 svelte-check: specifier: ^4.1.5 - version: 4.4.1(picomatch@4.0.3)(svelte@5.53.5)(typescript@5.9.3) + version: 4.4.3(picomatch@4.0.3)(svelte@5.53.5)(typescript@5.9.3) svelte-eslint-parser: specifier: ^1.3.3 - version: 1.4.1(svelte@5.53.5) + version: 1.5.1(svelte@5.53.5) tailwindcss: specifier: ^4.1.7 - version: 4.2.0 + version: 4.2.1 typescript: specifier: ^5.8.3 version: 5.9.3 typescript-eslint: specifier: ^8.45.0 - version: 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.1.2 - version: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -3343,15 +3343,15 @@ packages: '@maplibre/geojson-vt@5.0.4': resolution: {integrity: sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==} - '@maplibre/maplibre-gl-style-spec@24.4.1': - resolution: {integrity: sha512-UKhA4qv1h30XT768ccSv5NjNCX+dgfoq2qlLVmKejspPcSQTYD4SrVucgqegmYcKcmwf06wcNAa/kRd0NHWbUg==} + '@maplibre/maplibre-gl-style-spec@24.6.0': + resolution: {integrity: sha512-+lxMYE+DvInshwVrqSQ3CkW9YRwVlRXeDzfthVOa1c9pwK5d7YgCwhgFwlSmjJLvTXn4gL8EvPUGT620sk2Pzg==} hasBin: true '@maplibre/mlt@1.1.6': resolution: {integrity: sha512-rgtY3x65lrrfXycLf6/T22ZnjTg5WgIOsptOIoCaMZy4O4UAKTyZlYY0h6v8le721pTptF94U65yMDQkug+URw==} - '@maplibre/vt-pbf@4.2.1': - resolution: {integrity: sha512-IxZBGq/+9cqf2qdWlFuQ+ZfoMhWpxDUGQZ/poPHOJBvwMUT1GuxLo6HgYTou+xxtsOsjfbcjI8PZaPCtmt97rA==} + '@maplibre/vt-pbf@4.3.0': + resolution: {integrity: sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==} '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} @@ -3782,8 +3782,8 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/semantic-conventions@1.39.0': - resolution: {integrity: sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==} + '@opentelemetry/semantic-conventions@1.40.0': + resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} engines: {node: '>=14'} '@opentelemetry/sql-common@0.41.2': @@ -4313,8 +4313,8 @@ packages: peerDependencies: '@sveltejs/kit': ^2.0.0 - '@sveltejs/enhanced-img@0.10.2': - resolution: {integrity: sha512-HcIX7KFaLe+3ZD+GcMIlOGKODO8zb8p6I5tY8aoM9tz4GwueGyn9gILyTWZHqXYgg7PXto++ELB/q68wC9j4qw==} + '@sveltejs/enhanced-img@0.10.3': + resolution: {integrity: sha512-/6tYiqVmVgWcntSD/TChENE74yN8Gde9JEN8gyGKtm2ytlsUzGiS8loPPiO7Lh4V/rSsOFbvLdXPdiNVztMArA==} peerDependencies: '@sveltejs/vite-plugin-svelte': ^6.0.0 svelte: ^5.0.0 @@ -4429,72 +4429,72 @@ packages: resolution: {integrity: sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==} engines: {node: '>=14'} - '@swc/core-darwin-arm64@1.15.11': - resolution: {integrity: sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg==} + '@swc/core-darwin-arm64@1.15.13': + resolution: {integrity: sha512-ztXusRuC5NV2w+a6pDhX13CGioMLq8CjX5P4XgVJ21ocqz9t19288Do0y8LklplDtwcEhYGTNdMbkmUT7+lDTg==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.15.11': - resolution: {integrity: sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA==} + '@swc/core-darwin-x64@1.15.13': + resolution: {integrity: sha512-cVifxQUKhaE7qcO/y9Mq6PEhoyvN9tSLzCnnFZ4EIabFHBuLtDDO6a+vLveOy98hAs5Qu1+bb5Nv0oa1Pihe3Q==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.15.11': - resolution: {integrity: sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg==} + '@swc/core-linux-arm-gnueabihf@1.15.13': + resolution: {integrity: sha512-t+xxEzZ48enl/wGGy7SRYd7kImWQ/+wvVFD7g5JZo234g6/QnIgZ+YdfIyjHB+ZJI3F7a2IQHS7RNjxF29UkWw==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.15.11': - resolution: {integrity: sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA==} + '@swc/core-linux-arm64-gnu@1.15.13': + resolution: {integrity: sha512-VndeGvKmTXFn6AGwjy0Kg8i7HccOCE7Jt/vmZwRxGtOfNZM1RLYRQ7MfDLo6T0h1Bq6eYzps3L5Ma4zBmjOnOg==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [glibc] - '@swc/core-linux-arm64-musl@1.15.11': - resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==} + '@swc/core-linux-arm64-musl@1.15.13': + resolution: {integrity: sha512-SmZ9m+XqCB35NddHCctvHFLqPZDAs5j8IgD36GoutufDJmeq2VNfgk5rQoqNqKmAK3Y7iFdEmI76QoHIWiCLyw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [musl] - '@swc/core-linux-x64-gnu@1.15.11': - resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==} + '@swc/core-linux-x64-gnu@1.15.13': + resolution: {integrity: sha512-5rij+vB9a29aNkHq72EXI2ihDZPszJb4zlApJY4aCC/q6utgqFA6CkrfTfIb+O8hxtG3zP5KERETz8mfFK6A0A==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [glibc] - '@swc/core-linux-x64-musl@1.15.11': - resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==} + '@swc/core-linux-x64-musl@1.15.13': + resolution: {integrity: sha512-OlSlaOK9JplQ5qn07WiBLibkOw7iml2++ojEXhhR3rbWrNEKCD7sd8+6wSavsInyFdw4PhLA+Hy6YyDBIE23Yw==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [musl] - '@swc/core-win32-arm64-msvc@1.15.11': - resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==} + '@swc/core-win32-arm64-msvc@1.15.13': + resolution: {integrity: sha512-zwQii5YVdsfG8Ti9gIKgBKZg8qMkRZxl+OlYWUT5D93Jl4NuNBRausP20tfEkQdAPSRrMCSUZBM6FhW7izAZRg==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.15.11': - resolution: {integrity: sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw==} + '@swc/core-win32-ia32-msvc@1.15.13': + resolution: {integrity: sha512-hYXvyVVntqRlYoAIDwNzkS3tL2ijP3rxyWQMNKaxcCxxkCDto/w3meOK/OB6rbQSkNw0qTUcBfU9k+T0ptYdfQ==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.15.11': - resolution: {integrity: sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw==} + '@swc/core-win32-x64-msvc@1.15.13': + resolution: {integrity: sha512-XTzKs7c/vYCcjmcwawnQvlHHNS1naJEAzcBckMI5OJlnrcgW8UtcX9NHFYvNjGtXuKv0/9KvqL4fuahdvlNGKw==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.15.11': - resolution: {integrity: sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w==} + '@swc/core@1.15.13': + resolution: {integrity: sha512-0l1gl/72PErwUZuavcRpRAQN9uSst+Nk++niC5IX6lmMWpXoScYx3oq/narT64/sKv/eRiPTaAjBFGDEQiWJIw==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -4515,69 +4515,69 @@ packages: resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} - '@tailwindcss/node@4.2.0': - resolution: {integrity: sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q==} + '@tailwindcss/node@4.2.1': + resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} - '@tailwindcss/oxide-android-arm64@4.2.0': - resolution: {integrity: sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw==} + '@tailwindcss/oxide-android-arm64@4.2.1': + resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.0': - resolution: {integrity: sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA==} + '@tailwindcss/oxide-darwin-arm64@4.2.1': + resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.2.0': - resolution: {integrity: sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg==} + '@tailwindcss/oxide-darwin-x64@4.2.1': + resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.2.0': - resolution: {integrity: sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw==} + '@tailwindcss/oxide-freebsd-x64@4.2.1': + resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': - resolution: {integrity: sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': - resolution: {integrity: sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA==} + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.2.0': - resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==} + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.2.0': - resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==} + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.2.0': - resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==} + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.2.0': - resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==} + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -4588,24 +4588,24 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': - resolution: {integrity: sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA==} + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.2.0': - resolution: {integrity: sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ==} + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.0': - resolution: {integrity: sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ==} + '@tailwindcss/oxide@4.2.1': + resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} engines: {node: '>= 20'} - '@tailwindcss/vite@4.2.0': - resolution: {integrity: sha512-da9mFCaHpoOgtQiWtDGIikTrSpUFBtIZCG3jy/u2BGV+l/X1/pbxzmIUxNt6JWm19N3WtGi4KlJdSH/Si83WOA==} + '@tailwindcss/vite@4.2.1': + resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 @@ -4946,8 +4946,8 @@ packages: '@types/lodash-es@4.17.12': resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} - '@types/lodash@4.17.23': - resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} '@types/luxon@3.7.1': resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} @@ -4988,11 +4988,11 @@ packages: '@types/node@24.11.0': resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==} - '@types/node@25.3.0': - resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} + '@types/node@25.3.3': + resolution: {integrity: sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==} - '@types/nodemailer@7.0.10': - resolution: {integrity: sha512-tP+9WggTFN22Zxh0XFyst7239H0qwiRCogsk7v9aQS79sYAJY+WEbTHbNYcxUMaalHKmsNpxmoTe35hBEMMd6g==} + '@types/nodemailer@7.0.11': + resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==} '@types/oidc-provider@9.5.0': resolution: {integrity: sha512-eEzCRVTSqIHD9Bo/qRJ4XQWQ5Z/zBcG+Z2cGJluRsSuWx1RJihqRyPxhIEpMXTwPzHYRTQkVp7hwisQOwzzSAg==} @@ -5120,63 +5120,63 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.56.0': - resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} + '@typescript-eslint/eslint-plugin@8.56.1': + resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.56.0 + '@typescript-eslint/parser': ^8.56.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.56.0': - resolution: {integrity: sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==} + '@typescript-eslint/parser@8.56.1': + resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.56.0': - resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==} + '@typescript-eslint/project-service@8.56.1': + resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.56.0': - resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==} + '@typescript-eslint/scope-manager@8.56.1': + resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.56.0': - resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} + '@typescript-eslint/tsconfig-utils@8.56.1': + resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.56.0': - resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} + '@typescript-eslint/type-utils@8.56.1': + resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.56.0': - resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} + '@typescript-eslint/types@8.56.1': + resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.56.0': - resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} + '@typescript-eslint/typescript-estree@8.56.1': + resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.56.0': - resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} + '@typescript-eslint/utils@8.56.1': + resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.56.0': - resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} + '@typescript-eslint/visitor-keys@8.56.1': + resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -5527,8 +5527,8 @@ packages: autocomplete.js@0.37.1: resolution: {integrity: sha512-PgSe9fHYhZEsm/9jggbjtVsGXJkPLvd+9mC7gZJ662vVL5CRWEtm/mIrrzCx0MrNxHVwxD5d00UOn6NsmL2LUQ==} - autoprefixer@10.4.24: - resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==} + autoprefixer@10.4.27: + resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: @@ -5629,8 +5629,9 @@ packages: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} - baseline-browser-mapping@2.9.19: - resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + baseline-browser-mapping@2.10.0: + resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} + engines: {node: '>=6.0.0'} hasBin: true batch-cluster@17.3.1: @@ -5695,8 +5696,8 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - brace-expansion@5.0.3: - resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==} + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -5732,8 +5733,8 @@ packages: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} - bullmq@5.69.3: - resolution: {integrity: sha512-P9uLsR7fDvejH/1m6uur6j7U9mqY6nNt+XvhlhStOUe7jdwbZoP/c2oWNtE+8ljOlubw4pRUKymtRqkyvloc4A==} + bullmq@5.70.1: + resolution: {integrity: sha512-HjfGHfICkAClrFL0Y07qNbWcmiOCv1l+nusupXUjrvTPuDEyPEJ23MP0lUwUs/QEy1a3pWt/P/sCsSZ1RjRK+w==} bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} @@ -5818,8 +5819,8 @@ packages: caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} - caniuse-lite@1.0.30001774: - resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==} + caniuse-lite@1.0.30001776: + resolution: {integrity: sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==} canvas@2.11.2: resolution: {integrity: sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==} @@ -5921,8 +5922,8 @@ packages: class-transformer@0.5.1: resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} - class-validator@0.14.3: - resolution: {integrity: sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==} + class-validator@0.14.4: + resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==} clean-css@5.3.3: resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} @@ -6822,8 +6823,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.286: - resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + electron-to-chromium@1.5.302: + resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -6865,8 +6866,8 @@ packages: resolution: {integrity: sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==} engines: {node: '>=10.2.0'} - enhanced-resolve@5.19.0: - resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} + enhanced-resolve@5.20.0: + resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} engines: {node: '>=10.13.0'} entities@2.2.0: @@ -6980,8 +6981,8 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-plugin-compat@6.2.0: - resolution: {integrity: sha512-Ihz4zAeHKzyksDDUTObrYQxaqnV/pFlAiZoWkMuWM9XGf4O191ReQFYv516zcs9QVJ2vX+MMpqr1yEfTkXVETQ==} + eslint-plugin-compat@6.2.1: + resolution: {integrity: sha512-gLKqUH+lQcCL+HzsROUjBDvakc5Zaga51Y4ZAkPCXc41pzKBfyluqTr2j8zOx8QQQb7zyglu1LVoL5aSNWf2SQ==} engines: {node: '>=18.x'} peerDependencies: eslint: ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 @@ -7591,8 +7592,8 @@ packages: engines: {node: '>=0.4.7'} hasBin: true - happy-dom@20.6.3: - resolution: {integrity: sha512-QAMY7d228dHs8gb9NG4SJ3OxQo4r+NGN8pOXGZ3SGfQf/XYuuYubrtZ25QVY2WoUQdskhRXSXb4R4mcRk+hV1w==} + happy-dom@20.7.0: + resolution: {integrity: sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==} engines: {node: '>=20.0.0'} has-flag@4.0.0: @@ -7919,10 +7920,6 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - ioredis@5.9.2: - resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} - engines: {node: '>=12.22.0'} - ioredis@5.9.3: resolution: {integrity: sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==} engines: {node: '>=12.22.0'} @@ -8373,8 +8370,8 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - libphonenumber-js@1.12.31: - resolution: {integrity: sha512-Z3IhgVgrqO1S5xPYM3K5XwbkDasU67/Vys4heW+lfSBALcUZjeIIzI8zCLifY+OCzSq+fpDdywMDa7z+4srJPQ==} + libphonenumber-js@1.12.38: + resolution: {integrity: sha512-vwzxmasAy9hZigxtqTbFEwp8ZdZ975TiqVDwj5bKx5sR+zi5ucUQy9mbVTkKM9GzqdLdxux/hTw2nmN5J7POMA==} lightningcss-android-arm64@1.31.1: resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} @@ -8620,8 +8617,8 @@ packages: resolution: {integrity: sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==} engines: {node: ^20.17.0 || >=22.9.0} - maplibre-gl@5.18.0: - resolution: {integrity: sha512-UtWxPBpHuFvEkM+5FVfcFG9ZKEWZQI6+PZkvLErr8Zs5ux+O7/KQ3JjSUvAfOlMeMgd/77qlHpOw0yHL7JU5cw==} + maplibre-gl@5.19.0: + resolution: {integrity: sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==} engines: {node: '>=16.14.0', npm: '>=8.1.0'} mark.js@8.11.1: @@ -8953,8 +8950,8 @@ packages: minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} - minimatch@10.2.2: - resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} minimatch@3.1.2: @@ -9544,20 +9541,20 @@ packages: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} - pg-pool@3.11.0: - resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} + pg-pool@3.12.0: + resolution: {integrity: sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg==} peerDependencies: pg: '>=8.0' - pg-protocol@1.11.0: - resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + pg-protocol@1.12.0: + resolution: {integrity: sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg==} pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg@8.18.0: - resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==} + pg@8.19.0: + resolution: {integrity: sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==} engines: {node: '>= 16.0.0'} peerDependencies: pg-native: '>=3.0.1' @@ -10085,8 +10082,8 @@ packages: peerDependencies: postcss: ^8.4.31 - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -11096,17 +11093,17 @@ packages: peerDependencies: svelte: '>= 3.43.1 < 6' - svelte-check@4.4.1: - resolution: {integrity: sha512-y1bBT0CRCMMfdjyqX1e5zCygLgEEr4KJV1qP6GSUReHl90bmcQaAWjZygHPfQ8K63f1eR8IuivuZMwmCg3zT2Q==} + svelte-check@4.4.3: + resolution: {integrity: sha512-4HtdEv2hOoLCEsSXI+RDELk9okP/4sImWa7X02OjMFFOWeSdFF3NFy3vqpw0z+eH9C88J9vxZfUXz/Uv2A1ANw==} engines: {node: '>= 18.0.0'} hasBin: true peerDependencies: svelte: ^4.0.0 || ^5.0.0-next.0 typescript: '>=5.0.0' - svelte-eslint-parser@1.4.1: - resolution: {integrity: sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.24.0} + svelte-eslint-parser@1.5.1: + resolution: {integrity: sha512-UbY7DYoDg+x4AKLUcX5xWuEWylgmm8ZD2Z89YT/AK6Wm/ckeMTnOMwr6AVC99znXbRC26xzWEPhSgmB62E07Gg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.30.2} peerDependencies: svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 peerDependenciesMeta: @@ -11244,8 +11241,8 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - tailwindcss@4.2.0: - resolution: {integrity: sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==} + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} @@ -11527,8 +11524,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript-eslint@8.56.0: - resolution: {integrity: sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==} + typescript-eslint@8.56.1: + resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -13277,261 +13274,261 @@ snapshots: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-alpha-function@1.0.1(postcss@8.5.6)': + '@csstools/postcss-alpha-function@1.0.1(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.6)': + '@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.8)': dependencies: '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - '@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.6)': + '@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-color-function@4.0.12(postcss@8.5.6)': + '@csstools/postcss-color-function@4.0.12(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-color-mix-function@3.0.12(postcss@8.5.6)': + '@csstools/postcss-color-mix-function@3.0.12(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-color-mix-variadic-function-arguments@1.0.2(postcss@8.5.6)': + '@csstools/postcss-color-mix-variadic-function-arguments@1.0.2(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-content-alt-text@2.0.8(postcss@8.5.6)': + '@csstools/postcss-content-alt-text@2.0.8(postcss@8.5.8)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-contrast-color-function@2.0.12(postcss@8.5.6)': + '@csstools/postcss-contrast-color-function@2.0.12(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-exponential-functions@2.0.9(postcss@8.5.6)': + '@csstools/postcss-exponential-functions@2.0.9(postcss@8.5.8)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-font-format-keywords@4.0.0(postcss@8.5.6)': + '@csstools/postcss-font-format-keywords@4.0.0(postcss@8.5.8)': dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - '@csstools/postcss-gamut-mapping@2.0.11(postcss@8.5.6)': + '@csstools/postcss-gamut-mapping@2.0.11(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-gradients-interpolation-method@5.0.12(postcss@8.5.6)': + '@csstools/postcss-gradients-interpolation-method@5.0.12(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-hwb-function@4.0.12(postcss@8.5.6)': + '@csstools/postcss-hwb-function@4.0.12(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-ic-unit@4.0.4(postcss@8.5.6)': + '@csstools/postcss-ic-unit@4.0.4(postcss@8.5.8)': dependencies: - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - '@csstools/postcss-initial@2.0.1(postcss@8.5.6)': + '@csstools/postcss-initial@2.0.1(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.6)': + '@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.8)': dependencies: '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - '@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.6)': + '@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.8)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-logical-float-and-clear@3.0.0(postcss@8.5.6)': + '@csstools/postcss-logical-float-and-clear@3.0.0(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-logical-overflow@2.0.0(postcss@8.5.6)': + '@csstools/postcss-logical-overflow@2.0.0(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-logical-overscroll-behavior@2.0.0(postcss@8.5.6)': + '@csstools/postcss-logical-overscroll-behavior@2.0.0(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-logical-resize@3.0.0(postcss@8.5.6)': + '@csstools/postcss-logical-resize@3.0.0(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - '@csstools/postcss-logical-viewport-units@3.0.4(postcss@8.5.6)': + '@csstools/postcss-logical-viewport-units@3.0.4(postcss@8.5.8)': dependencies: '@csstools/css-tokenizer': 3.0.4 - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-media-minmax@2.0.9(postcss@8.5.6)': + '@csstools/postcss-media-minmax@2.0.9(postcss@8.5.8)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-media-queries-aspect-ratio-number-values@3.0.5(postcss@8.5.6)': + '@csstools/postcss-media-queries-aspect-ratio-number-values@3.0.5(postcss@8.5.8)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-nested-calc@4.0.0(postcss@8.5.6)': + '@csstools/postcss-nested-calc@4.0.0(postcss@8.5.8)': dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - '@csstools/postcss-normalize-display-values@4.0.0(postcss@8.5.6)': + '@csstools/postcss-normalize-display-values@4.0.0(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - '@csstools/postcss-oklab-function@4.0.12(postcss@8.5.6)': + '@csstools/postcss-oklab-function@4.0.12(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-position-area-property@1.0.0(postcss@8.5.6)': + '@csstools/postcss-position-area-property@1.0.0(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-progressive-custom-properties@4.2.1(postcss@8.5.6)': + '@csstools/postcss-progressive-custom-properties@4.2.1(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - '@csstools/postcss-random-function@2.0.1(postcss@8.5.6)': + '@csstools/postcss-random-function@2.0.1(postcss@8.5.8)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-relative-color-syntax@3.0.12(postcss@8.5.6)': + '@csstools/postcss-relative-color-syntax@3.0.12(postcss@8.5.8)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - '@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.6)': + '@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - '@csstools/postcss-sign-functions@1.1.4(postcss@8.5.6)': + '@csstools/postcss-sign-functions@1.1.4(postcss@8.5.8)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-stepped-value-functions@4.0.9(postcss@8.5.6)': + '@csstools/postcss-stepped-value-functions@4.0.9(postcss@8.5.8)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-system-ui-font-family@1.0.0(postcss@8.5.6)': + '@csstools/postcss-system-ui-font-family@1.0.0(postcss@8.5.8)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-text-decoration-shorthand@4.0.3(postcss@8.5.6)': + '@csstools/postcss-text-decoration-shorthand@4.0.3(postcss@8.5.8)': dependencies: '@csstools/color-helpers': 5.1.0 - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - '@csstools/postcss-trigonometric-functions@4.0.9(postcss@8.5.6)': + '@csstools/postcss-trigonometric-functions@4.0.9(postcss@8.5.8)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.6 + postcss: 8.5.8 - '@csstools/postcss-unset-value@4.0.0(postcss@8.5.6)': + '@csstools/postcss-unset-value@4.0.0(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 '@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.1)': dependencies: @@ -13541,9 +13538,9 @@ snapshots: dependencies: postcss-selector-parser: 7.1.1 - '@csstools/utilities@2.0.0(postcss@8.5.6)': + '@csstools/utilities@2.0.0(postcss@8.5.8)': dependencies: - postcss: 8.5.6 + postcss: 8.5.8 '@discoveryjs/json-ext@0.5.7': {} @@ -13612,14 +13609,14 @@ snapshots: copy-webpack-plugin: 11.0.0(webpack@5.104.1) css-loader: 6.11.0(webpack@5.104.1) css-minimizer-webpack-plugin: 5.0.1(clean-css@5.3.3)(webpack@5.104.1) - cssnano: 6.1.2(postcss@8.5.6) + cssnano: 6.1.2(postcss@8.5.8) file-loader: 6.2.0(webpack@5.104.1) html-minifier-terser: 7.2.0 mini-css-extract-plugin: 2.9.4(webpack@5.104.1) null-loader: 4.0.1(webpack@5.104.1) - postcss: 8.5.6 - postcss-loader: 7.3.4(postcss@8.5.6)(typescript@5.9.3)(webpack@5.104.1) - postcss-preset-env: 10.5.0(postcss@8.5.6) + postcss: 8.5.8 + postcss-loader: 7.3.4(postcss@8.5.8)(typescript@5.9.3)(webpack@5.104.1) + postcss-preset-env: 10.5.0(postcss@8.5.8) terser-webpack-plugin: 5.3.16(webpack@5.104.1) tslib: 2.8.1 url-loader: 4.1.1(file-loader@6.2.0(webpack@5.104.1))(webpack@5.104.1) @@ -13706,9 +13703,9 @@ snapshots: '@docusaurus/cssnano-preset@3.9.2': dependencies: - cssnano-preset-advanced: 6.1.2(postcss@8.5.6) - postcss: 8.5.6 - postcss-sort-media-queries: 5.2.0(postcss@8.5.6) + cssnano-preset-advanced: 6.1.2(postcss@8.5.8) + postcss: 8.5.8 + postcss-sort-media-queries: 5.2.0(postcss@8.5.8) tslib: 2.8.1 '@docusaurus/logger@3.9.2': @@ -14140,7 +14137,7 @@ snapshots: infima: 0.2.0-alpha.45 lodash: 4.17.23 nprogress: 0.2.0 - postcss: 8.5.6 + postcss: 8.5.8 prism-react-renderer: 2.4.1(react@18.3.1) prismjs: 1.30.0 react: 18.3.1 @@ -14595,7 +14592,7 @@ snapshots: dependencies: '@eslint/object-schema': 3.0.2 debug: 4.4.3 - minimatch: 10.2.2 + minimatch: 10.2.4 transitivePeerDependencies: - supports-color @@ -14705,10 +14702,10 @@ snapshots: dependencies: '@fortawesome/fontawesome-common-types': 7.1.0 - '@golevelup/nestjs-discovery@5.0.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': + '@golevelup/nestjs-discovery@5.0.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) lodash: 4.17.23 '@grpc/grpc-js@1.14.3': @@ -14868,19 +14865,19 @@ snapshots: node-emoji: 2.2.0 svelte: 5.53.5 - '@immich/ui@0.64.0(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)': + '@immich/ui@0.64.0(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)': dependencies: '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.5) '@internationalized/date': 3.10.0 '@mdi/js': 7.4.47 - bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5) + bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5) luxon: 3.7.2 simple-icons: 16.9.0 svelte: 5.53.5 svelte-highlight: 7.9.0 tailwind-merge: 3.4.0 - tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.0) - tailwindcss: 4.2.0 + tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1) + tailwindcss: 4.2.1 transitivePeerDependencies: - '@sveltejs/kit' @@ -15147,8 +15144,8 @@ snapshots: '@koddsson/eslint-plugin-tscompat@0.2.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@mdn/browser-compat-data': 6.1.5 - '@typescript-eslint/type-utils': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) browserslist: 4.28.1 transitivePeerDependencies: - eslint @@ -15237,7 +15234,7 @@ snapshots: '@maplibre/geojson-vt@5.0.4': {} - '@maplibre/maplibre-gl-style-spec@24.4.1': + '@maplibre/maplibre-gl-style-spec@24.6.0': dependencies: '@mapbox/jsonlint-lines-primitives': 2.0.2 '@mapbox/unitbezier': 0.0.1 @@ -15251,7 +15248,7 @@ snapshots: dependencies: '@mapbox/point-geometry': 1.1.0 - '@maplibre/vt-pbf@4.2.1': + '@maplibre/vt-pbf@4.3.0': dependencies: '@mapbox/point-geometry': 1.1.0 '@mapbox/vector-tile': 2.0.4 @@ -15335,21 +15332,21 @@ snapshots: '@namnode/store@0.1.0': {} - '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(bullmq@5.69.3)': + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(bullmq@5.70.1)': dependencies: - '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) - bullmq: 5.69.3 + '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + bullmq: 5.70.1 tslib: 2.8.1 - '@nestjs/cli@11.0.16(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@24.11.0)': + '@nestjs/cli@11.0.16(@swc/core@1.15.13(@swc/helpers@0.5.17))(@types/node@24.11.0)': dependencies: '@angular-devkit/core': 19.2.19(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) @@ -15360,24 +15357,24 @@ snapshots: chokidar: 4.0.3 cli-table3: 0.6.5 commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.11(@swc/helpers@0.5.17))) + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17))) glob: 13.0.0 node-emoji: 1.11.0 ora: 5.4.1 tsconfig-paths: 4.2.0 tsconfig-paths-webpack-plugin: 4.2.0 typescript: 5.9.3 - webpack: 5.104.1(@swc/core@1.15.11(@swc/helpers@0.5.17)) + webpack: 5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17)) webpack-node-externals: 3.0.0 optionalDependencies: - '@swc/core': 1.15.11(@swc/helpers@0.5.17) + '@swc/core': 1.15.13(@swc/helpers@0.5.17) transitivePeerDependencies: - '@types/node' - esbuild - uglify-js - webpack-cli - '@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.3.0 iterare: 1.2.1 @@ -15388,13 +15385,13 @@ snapshots: uid: 2.0.2 optionalDependencies: class-transformer: 0.5.1 - class-validator: 0.14.3 + class-validator: 0.14.4 transitivePeerDependencies: - supports-color - '@nestjs/core@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -15404,21 +15401,21 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) - '@nestjs/websockets': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + '@nestjs/websockets': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)': + '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 optionalDependencies: class-transformer: 0.5.1 - class-validator: 0.14.3 + class-validator: 0.14.4 - '@nestjs/platform-express@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': + '@nestjs/platform-express@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.6 express: 5.2.1 multer: 2.0.2 @@ -15427,10 +15424,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/platform-socket.io@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(rxjs@7.8.2)': + '@nestjs/platform-socket.io@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/websockets': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/websockets': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) rxjs: 7.8.2 socket.io: 4.8.3 tslib: 2.8.1 @@ -15439,10 +15436,10 @@ snapshots: - supports-color - utf-8-validate - '@nestjs/schedule@6.1.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': + '@nestjs/schedule@6.1.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) cron: 4.4.0 '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)': @@ -15456,12 +15453,12 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/swagger@11.2.6(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)': + '@nestjs/swagger@11.2.6(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.16.0 - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) js-yaml: 4.1.1 lodash: 4.17.23 path-to-regexp: 8.3.0 @@ -15469,27 +15466,27 @@ snapshots: swagger-ui-dist: 5.31.0 optionalDependencies: class-transformer: 0.5.1 - class-validator: 0.14.3 + class-validator: 0.14.4 - '@nestjs/testing@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-express@11.1.14)': + '@nestjs/testing@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-express@11.1.14)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + '@nestjs/platform-express': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) - '@nestjs/websockets@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/websockets@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) iterare: 1.2.1 object-hash: 3.0.0 reflect-metadata: 0.2.2 rxjs: 7.8.2 tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-socket.io': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(rxjs@7.8.2) + '@nestjs/platform-socket.io': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(rxjs@7.8.2) '@noble/hashes@1.8.0': {} @@ -15544,7 +15541,7 @@ snapshots: '@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/exporter-logs-otlp-grpc@0.212.0(@opentelemetry/api@1.9.0)': dependencies: @@ -15613,7 +15610,7 @@ snapshots: '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/exporter-trace-otlp-grpc@0.212.0(@opentelemetry/api@1.9.0)': dependencies: @@ -15650,7 +15647,7 @@ snapshots: '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/host-metrics@0.36.2(@opentelemetry/api@1.9.0)': dependencies: @@ -15662,7 +15659,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/semantic-conventions': 1.40.0 forwarded-parse: 2.1.2 transitivePeerDependencies: - supports-color @@ -15672,7 +15669,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) '@opentelemetry/redis-common': 0.38.2 - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color @@ -15680,7 +15677,7 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color @@ -15689,7 +15686,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) '@types/pg': 8.15.6 '@types/pg-pool': 2.0.7 @@ -15746,7 +15743,7 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/sdk-logs@0.212.0(@opentelemetry/api@1.9.0)': dependencies: @@ -15787,7 +15784,7 @@ snapshots: '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-node': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color @@ -15796,7 +15793,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/sdk-trace-node@2.5.1(@opentelemetry/api@1.9.0)': dependencies: @@ -15805,7 +15802,7 @@ snapshots: '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions@1.39.0': {} + '@opentelemetry/semantic-conventions@1.40.0': {} '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.0)': dependencies: @@ -16210,29 +16207,29 @@ snapshots: dependencies: acorn: 8.16.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - '@sveltejs/kit': 2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@sveltejs/enhanced-img@0.10.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/enhanced-img@0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) magic-string: 0.30.21 sharp: 0.34.5 svelte: 5.53.5 svelte-parse-markup: 0.1.5(svelte@5.53.5) - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-imagetools: 9.0.3(rollup@4.55.1) zimmerframe: 1.1.4 transitivePeerDependencies: - rollup - supports-color - '@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@types/cookie': 0.6.0 acorn: 8.16.0 cookie: 0.6.0 @@ -16244,29 +16241,29 @@ snapshots: set-cookie-parser: 3.0.1 sirv: 3.0.2 svelte: 5.53.5 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: '@opentelemetry/api': 1.9.0 typescript: 5.9.3 - '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) debug: 4.4.3 svelte: 5.53.5 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 svelte: 5.53.5 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitefu: 1.1.1(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitefu: 1.1.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: - supports-color @@ -16363,51 +16360,51 @@ snapshots: - supports-color - typescript - '@swc/core-darwin-arm64@1.15.11': + '@swc/core-darwin-arm64@1.15.13': optional: true - '@swc/core-darwin-x64@1.15.11': + '@swc/core-darwin-x64@1.15.13': optional: true - '@swc/core-linux-arm-gnueabihf@1.15.11': + '@swc/core-linux-arm-gnueabihf@1.15.13': optional: true - '@swc/core-linux-arm64-gnu@1.15.11': + '@swc/core-linux-arm64-gnu@1.15.13': optional: true - '@swc/core-linux-arm64-musl@1.15.11': + '@swc/core-linux-arm64-musl@1.15.13': optional: true - '@swc/core-linux-x64-gnu@1.15.11': + '@swc/core-linux-x64-gnu@1.15.13': optional: true - '@swc/core-linux-x64-musl@1.15.11': + '@swc/core-linux-x64-musl@1.15.13': optional: true - '@swc/core-win32-arm64-msvc@1.15.11': + '@swc/core-win32-arm64-msvc@1.15.13': optional: true - '@swc/core-win32-ia32-msvc@1.15.11': + '@swc/core-win32-ia32-msvc@1.15.13': optional: true - '@swc/core-win32-x64-msvc@1.15.11': + '@swc/core-win32-x64-msvc@1.15.13': optional: true - '@swc/core@1.15.11(@swc/helpers@0.5.17)': + '@swc/core@1.15.13(@swc/helpers@0.5.17)': dependencies: '@swc/counter': 0.1.3 '@swc/types': 0.1.25 optionalDependencies: - '@swc/core-darwin-arm64': 1.15.11 - '@swc/core-darwin-x64': 1.15.11 - '@swc/core-linux-arm-gnueabihf': 1.15.11 - '@swc/core-linux-arm64-gnu': 1.15.11 - '@swc/core-linux-arm64-musl': 1.15.11 - '@swc/core-linux-x64-gnu': 1.15.11 - '@swc/core-linux-x64-musl': 1.15.11 - '@swc/core-win32-arm64-msvc': 1.15.11 - '@swc/core-win32-ia32-msvc': 1.15.11 - '@swc/core-win32-x64-msvc': 1.15.11 + '@swc/core-darwin-arm64': 1.15.13 + '@swc/core-darwin-x64': 1.15.13 + '@swc/core-linux-arm-gnueabihf': 1.15.13 + '@swc/core-linux-arm64-gnu': 1.15.13 + '@swc/core-linux-arm64-musl': 1.15.13 + '@swc/core-linux-x64-gnu': 1.15.13 + '@swc/core-linux-x64-musl': 1.15.13 + '@swc/core-win32-arm64-msvc': 1.15.13 + '@swc/core-win32-ia32-msvc': 1.15.13 + '@swc/core-win32-x64-msvc': 1.15.13 '@swc/helpers': 0.5.17 '@swc/counter@0.1.3': {} @@ -16424,73 +16421,73 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tailwindcss/node@4.2.0': + '@tailwindcss/node@4.2.1': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.19.0 + enhanced-resolve: 5.20.0 jiti: 2.6.1 lightningcss: 1.31.1 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.2.0 + tailwindcss: 4.2.1 - '@tailwindcss/oxide-android-arm64@4.2.0': + '@tailwindcss/oxide-android-arm64@4.2.1': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.0': + '@tailwindcss/oxide-darwin-arm64@4.2.1': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.0': + '@tailwindcss/oxide-darwin-x64@4.2.1': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.0': + '@tailwindcss/oxide-freebsd-x64@4.2.1': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.0': + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.0': + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.0': + '@tailwindcss/oxide-linux-x64-musl@4.2.1': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.0': + '@tailwindcss/oxide-wasm32-wasi@4.2.1': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.0': + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': optional: true - '@tailwindcss/oxide@4.2.0': + '@tailwindcss/oxide@4.2.1': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.0 - '@tailwindcss/oxide-darwin-arm64': 4.2.0 - '@tailwindcss/oxide-darwin-x64': 4.2.0 - '@tailwindcss/oxide-freebsd-x64': 4.2.0 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.0 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.0 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.0 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.0 - '@tailwindcss/oxide-linux-x64-musl': 4.2.0 - '@tailwindcss/oxide-wasm32-wasi': 4.2.0 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.0 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.0 + '@tailwindcss/oxide-android-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-x64': 4.2.1 + '@tailwindcss/oxide-freebsd-x64': 4.2.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-x64-musl': 4.2.1 + '@tailwindcss/oxide-wasm32-wasi': 4.2.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 - '@tailwindcss/vite@4.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@tailwindcss/node': 4.2.0 - '@tailwindcss/oxide': 4.2.0 - tailwindcss: 4.2.0 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + tailwindcss: 4.2.1 + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@testing-library/dom@10.4.1': dependencies: @@ -16516,14 +16513,14 @@ snapshots: dependencies: svelte: 5.53.5 - '@testing-library/svelte@5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@testing-library/svelte@5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/svelte-core': 1.0.0(svelte@5.53.5) svelte: 5.53.5 optionalDependencies: - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -16909,9 +16906,9 @@ snapshots: '@types/lodash-es@4.17.12': dependencies: - '@types/lodash': 4.17.23 + '@types/lodash': 4.17.24 - '@types/lodash@4.17.23': {} + '@types/lodash@4.17.24': {} '@types/luxon@3.7.1': {} @@ -16953,12 +16950,12 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/node@25.3.0': + '@types/node@25.3.3': dependencies: undici-types: 7.18.2 optional: true - '@types/nodemailer@7.0.10': + '@types/nodemailer@7.0.11': dependencies: '@types/node': 24.11.0 @@ -16977,13 +16974,13 @@ snapshots: '@types/pg@8.15.6': dependencies: '@types/node': 24.11.0 - pg-protocol: 1.11.0 + pg-protocol: 1.12.0 pg-types: 2.2.0 '@types/pg@8.16.0': dependencies: '@types/node': 24.11.0 - pg-protocol: 1.11.0 + pg-protocol: 1.12.0 pg-types: 2.2.0 '@types/picomatch@4.0.2': {} @@ -17122,14 +17119,14 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/type-utils': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.0 + '@typescript-eslint/parser': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/type-utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 eslint: 10.0.2(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 @@ -17138,41 +17135,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.0 + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 debug: 4.4.3 eslint: 10.0.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.56.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) - '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.56.0': + '@typescript-eslint/scope-manager@8.56.1': dependencies: - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/visitor-keys': 8.56.0 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 - '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 10.0.2(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) @@ -17180,16 +17177,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.56.0': {} + '@typescript-eslint/types@8.56.1': {} - '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.56.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3) - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/visitor-keys': 8.56.0 + '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 debug: 4.4.3 - minimatch: 9.0.6 + minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -17197,27 +17194,27 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/types': 8.56.0 - '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) eslint: 10.0.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.56.0': + '@typescript-eslint/visitor-keys@8.56.1': dependencies: - '@typescript-eslint/types': 8.56.0 + '@typescript-eslint/types': 8.56.1 eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.0': {} '@vercel/oidc@3.0.5': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -17232,11 +17229,11 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -17251,7 +17248,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -17271,13 +17268,13 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@3.2.4': dependencies: @@ -17634,13 +17631,13 @@ snapshots: dependencies: immediate: 3.3.0 - autoprefixer@10.4.24(postcss@8.5.6): + autoprefixer@10.4.27(postcss@8.5.8): dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001774 + caniuse-lite: 1.0.30001776 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 axobject-query@4.1.0: {} @@ -17732,7 +17729,7 @@ snapshots: base64id@2.0.0: {} - baseline-browser-mapping@2.9.19: {} + baseline-browser-mapping@2.10.0: {} batch-cluster@17.3.1: {} @@ -17756,15 +17753,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5): + bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 '@internationalized/date': 3.10.0 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5) + runed: 0.35.1(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5) svelte: 5.53.5 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5) + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5) tabbable: 6.4.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -17844,7 +17841,7 @@ snapshots: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.3: + brace-expansion@5.0.4: dependencies: balanced-match: 4.0.4 @@ -17854,9 +17851,9 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001774 - electron-to-chromium: 1.5.286 + baseline-browser-mapping: 2.10.0 + caniuse-lite: 1.0.30001776 + electron-to-chromium: 1.5.302 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -17881,10 +17878,10 @@ snapshots: builtin-modules@5.0.0: {} - bullmq@5.69.3: + bullmq@5.70.1: dependencies: cron-parser: 4.9.0 - ioredis: 5.9.2 + ioredis: 5.9.3 msgpackr: 1.11.5 node-abort-controller: 3.1.1 semver: 7.7.4 @@ -17972,11 +17969,11 @@ snapshots: caniuse-api@3.0.0: dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001774 + caniuse-lite: 1.0.30001776 lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 - caniuse-lite@1.0.30001774: {} + caniuse-lite@1.0.30001776: {} canvas@2.11.2: dependencies: @@ -18100,10 +18097,10 @@ snapshots: class-transformer@0.5.1: {} - class-validator@0.14.3: + class-validator@0.14.4: dependencies: '@types/validator': 13.15.10 - libphonenumber-js: 1.12.31 + libphonenumber-js: 1.12.38 validator: 13.15.26 clean-css@5.3.3: @@ -18402,30 +18399,30 @@ snapshots: dependencies: type-fest: 1.4.0 - css-blank-pseudo@7.0.1(postcss@8.5.6): + css-blank-pseudo@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - css-declaration-sorter@7.3.0(postcss@8.5.6): + css-declaration-sorter@7.3.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - css-has-pseudo@7.0.3(postcss@8.5.6): + css-has-pseudo@7.0.3(postcss@8.5.8): dependencies: '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 css-loader@6.11.0(webpack@5.104.1): dependencies: - icss-utils: 5.1.0(postcss@8.5.6) - postcss: 8.5.6 - postcss-modules-extract-imports: 3.1.0(postcss@8.5.6) - postcss-modules-local-by-default: 4.2.0(postcss@8.5.6) - postcss-modules-scope: 3.2.1(postcss@8.5.6) - postcss-modules-values: 4.0.0(postcss@8.5.6) + icss-utils: 5.1.0(postcss@8.5.8) + postcss: 8.5.8 + postcss-modules-extract-imports: 3.1.0(postcss@8.5.8) + postcss-modules-local-by-default: 4.2.0(postcss@8.5.8) + postcss-modules-scope: 3.2.1(postcss@8.5.8) + postcss-modules-values: 4.0.0(postcss@8.5.8) postcss-value-parser: 4.2.0 semver: 7.7.4 optionalDependencies: @@ -18434,18 +18431,18 @@ snapshots: css-minimizer-webpack-plugin@5.0.1(clean-css@5.3.3)(webpack@5.104.1): dependencies: '@jridgewell/trace-mapping': 0.3.31 - cssnano: 6.1.2(postcss@8.5.6) + cssnano: 6.1.2(postcss@8.5.8) jest-worker: 29.7.0 - postcss: 8.5.6 + postcss: 8.5.8 schema-utils: 4.3.3 serialize-javascript: 6.0.2 webpack: 5.104.1 optionalDependencies: clean-css: 5.3.3 - css-prefers-color-scheme@10.0.0(postcss@8.5.6): + css-prefers-color-scheme@10.0.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 css-select@4.3.0: dependencies: @@ -18483,60 +18480,60 @@ snapshots: cssesc@3.0.0: {} - cssnano-preset-advanced@6.1.2(postcss@8.5.6): + cssnano-preset-advanced@6.1.2(postcss@8.5.8): dependencies: - autoprefixer: 10.4.24(postcss@8.5.6) + autoprefixer: 10.4.27(postcss@8.5.8) browserslist: 4.28.1 - cssnano-preset-default: 6.1.2(postcss@8.5.6) - postcss: 8.5.6 - postcss-discard-unused: 6.0.5(postcss@8.5.6) - postcss-merge-idents: 6.0.3(postcss@8.5.6) - postcss-reduce-idents: 6.0.3(postcss@8.5.6) - postcss-zindex: 6.0.2(postcss@8.5.6) + cssnano-preset-default: 6.1.2(postcss@8.5.8) + postcss: 8.5.8 + postcss-discard-unused: 6.0.5(postcss@8.5.8) + postcss-merge-idents: 6.0.3(postcss@8.5.8) + postcss-reduce-idents: 6.0.3(postcss@8.5.8) + postcss-zindex: 6.0.2(postcss@8.5.8) - cssnano-preset-default@6.1.2(postcss@8.5.6): + cssnano-preset-default@6.1.2(postcss@8.5.8): dependencies: browserslist: 4.28.1 - css-declaration-sorter: 7.3.0(postcss@8.5.6) - cssnano-utils: 4.0.2(postcss@8.5.6) - postcss: 8.5.6 - postcss-calc: 9.0.1(postcss@8.5.6) - postcss-colormin: 6.1.0(postcss@8.5.6) - postcss-convert-values: 6.1.0(postcss@8.5.6) - postcss-discard-comments: 6.0.2(postcss@8.5.6) - postcss-discard-duplicates: 6.0.3(postcss@8.5.6) - postcss-discard-empty: 6.0.3(postcss@8.5.6) - postcss-discard-overridden: 6.0.2(postcss@8.5.6) - postcss-merge-longhand: 6.0.5(postcss@8.5.6) - postcss-merge-rules: 6.1.1(postcss@8.5.6) - postcss-minify-font-values: 6.1.0(postcss@8.5.6) - postcss-minify-gradients: 6.0.3(postcss@8.5.6) - postcss-minify-params: 6.1.0(postcss@8.5.6) - postcss-minify-selectors: 6.0.4(postcss@8.5.6) - postcss-normalize-charset: 6.0.2(postcss@8.5.6) - postcss-normalize-display-values: 6.0.2(postcss@8.5.6) - postcss-normalize-positions: 6.0.2(postcss@8.5.6) - postcss-normalize-repeat-style: 6.0.2(postcss@8.5.6) - postcss-normalize-string: 6.0.2(postcss@8.5.6) - postcss-normalize-timing-functions: 6.0.2(postcss@8.5.6) - postcss-normalize-unicode: 6.1.0(postcss@8.5.6) - postcss-normalize-url: 6.0.2(postcss@8.5.6) - postcss-normalize-whitespace: 6.0.2(postcss@8.5.6) - postcss-ordered-values: 6.0.2(postcss@8.5.6) - postcss-reduce-initial: 6.1.0(postcss@8.5.6) - postcss-reduce-transforms: 6.0.2(postcss@8.5.6) - postcss-svgo: 6.0.3(postcss@8.5.6) - postcss-unique-selectors: 6.0.4(postcss@8.5.6) + css-declaration-sorter: 7.3.0(postcss@8.5.8) + cssnano-utils: 4.0.2(postcss@8.5.8) + postcss: 8.5.8 + postcss-calc: 9.0.1(postcss@8.5.8) + postcss-colormin: 6.1.0(postcss@8.5.8) + postcss-convert-values: 6.1.0(postcss@8.5.8) + postcss-discard-comments: 6.0.2(postcss@8.5.8) + postcss-discard-duplicates: 6.0.3(postcss@8.5.8) + postcss-discard-empty: 6.0.3(postcss@8.5.8) + postcss-discard-overridden: 6.0.2(postcss@8.5.8) + postcss-merge-longhand: 6.0.5(postcss@8.5.8) + postcss-merge-rules: 6.1.1(postcss@8.5.8) + postcss-minify-font-values: 6.1.0(postcss@8.5.8) + postcss-minify-gradients: 6.0.3(postcss@8.5.8) + postcss-minify-params: 6.1.0(postcss@8.5.8) + postcss-minify-selectors: 6.0.4(postcss@8.5.8) + postcss-normalize-charset: 6.0.2(postcss@8.5.8) + postcss-normalize-display-values: 6.0.2(postcss@8.5.8) + postcss-normalize-positions: 6.0.2(postcss@8.5.8) + postcss-normalize-repeat-style: 6.0.2(postcss@8.5.8) + postcss-normalize-string: 6.0.2(postcss@8.5.8) + postcss-normalize-timing-functions: 6.0.2(postcss@8.5.8) + postcss-normalize-unicode: 6.1.0(postcss@8.5.8) + postcss-normalize-url: 6.0.2(postcss@8.5.8) + postcss-normalize-whitespace: 6.0.2(postcss@8.5.8) + postcss-ordered-values: 6.0.2(postcss@8.5.8) + postcss-reduce-initial: 6.1.0(postcss@8.5.8) + postcss-reduce-transforms: 6.0.2(postcss@8.5.8) + postcss-svgo: 6.0.3(postcss@8.5.8) + postcss-unique-selectors: 6.0.4(postcss@8.5.8) - cssnano-utils@4.0.2(postcss@8.5.6): + cssnano-utils@4.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - cssnano@6.1.2(postcss@8.5.6): + cssnano@6.1.2(postcss@8.5.8): dependencies: - cssnano-preset-default: 6.1.2(postcss@8.5.6) + cssnano-preset-default: 6.1.2(postcss@8.5.8) lilconfig: 3.1.3 - postcss: 8.5.6 + postcss: 8.5.8 csso@5.0.5: dependencies: @@ -19008,7 +19005,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.286: {} + electron-to-chromium@1.5.302: {} emoji-regex@10.6.0: {} @@ -19063,7 +19060,7 @@ snapshots: - supports-color - utf-8-validate - enhanced-resolve@5.19.0: + enhanced-resolve@5.20.0: dependencies: graceful-fs: 4.2.11 tapable: 2.3.0 @@ -19242,12 +19239,11 @@ snapshots: dependencies: eslint: 10.0.2(jiti@2.6.1) - eslint-plugin-compat@6.2.0(eslint@10.0.2(jiti@2.6.1)): + eslint-plugin-compat@6.2.1(eslint@10.0.2(jiti@2.6.1)): dependencies: '@mdn/browser-compat-data': 6.1.5 ast-metadata-inferer: 0.8.1 browserslist: 4.28.1 - caniuse-lite: 1.0.30001774 eslint: 10.0.2(jiti@2.6.1) find-up: 5.0.0 globals: 15.15.0 @@ -19272,11 +19268,11 @@ snapshots: esutils: 2.0.3 globals: 16.5.0 known-css-properties: 0.37.0 - postcss: 8.5.6 - postcss-load-config: 3.1.4(postcss@8.5.6) - postcss-safe-parser: 7.0.1(postcss@8.5.6) + postcss: 8.5.8 + postcss-load-config: 3.1.4(postcss@8.5.8) + postcss-safe-parser: 7.0.1(postcss@8.5.8) semver: 7.7.4 - svelte-eslint-parser: 1.4.1(svelte@5.53.5) + svelte-eslint-parser: 1.5.1(svelte@5.53.5) optionalDependencies: svelte: 5.53.5 transitivePeerDependencies: @@ -19354,7 +19350,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.2 + minimatch: 10.2.4 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -19743,7 +19739,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.11(@swc/helpers@0.5.17))): + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17))): dependencies: '@babel/code-frame': 7.29.0 chalk: 4.1.2 @@ -19758,7 +19754,7 @@ snapshots: semver: 7.7.4 tapable: 2.3.0 typescript: 5.9.3 - webpack: 5.104.1(@swc/core@1.15.11(@swc/helpers@0.5.17)) + webpack: 5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17)) form-data-encoder@2.1.4: {} @@ -19920,20 +19916,20 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 4.1.1 - minimatch: 10.2.2 + minimatch: 10.2.4 minipass: 7.1.2 package-json-from-dist: 1.0.1 path-scurry: 2.0.1 glob@13.0.0: dependencies: - minimatch: 10.2.2 + minimatch: 10.2.4 minipass: 7.1.2 path-scurry: 2.0.1 glob@13.0.2: dependencies: - minimatch: 10.2.2 + minimatch: 10.2.4 minipass: 7.1.2 path-scurry: 2.0.1 @@ -20021,7 +20017,7 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 - happy-dom@20.6.3: + happy-dom@20.7.0: dependencies: '@types/node': 24.11.0 '@types/whatwg-mimetype': 3.0.2 @@ -20389,9 +20385,9 @@ snapshots: dependencies: safer-buffer: 2.1.2 - icss-utils@5.1.0(postcss@8.5.6): + icss-utils@5.1.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 ieee754@1.2.1: {} @@ -20488,20 +20484,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - ioredis@5.9.2: - dependencies: - '@ioredis/commands': 1.5.0 - cluster-key-slot: 1.1.2 - debug: 4.4.3 - denque: 2.1.0 - lodash.defaults: 4.2.0 - lodash.isarguments: 3.1.0 - redis-errors: 1.2.0 - redis-parser: 3.0.0 - standard-as-callback: 2.1.0 - transitivePeerDependencies: - - supports-color - ioredis@5.9.3: dependencies: '@ioredis/commands': 1.5.0 @@ -20960,7 +20942,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - libphonenumber-js@1.12.31: {} + libphonenumber-js@1.12.38: {} lightningcss-android-arm64@1.31.1: optional: true @@ -21164,7 +21146,7 @@ snapshots: transitivePeerDependencies: - supports-color - maplibre-gl@5.18.0: + maplibre-gl@5.19.0: dependencies: '@mapbox/geojson-rewind': 0.5.2 '@mapbox/jsonlint-lines-primitives': 2.0.2 @@ -21174,9 +21156,9 @@ snapshots: '@mapbox/vector-tile': 2.0.4 '@mapbox/whoots-js': 3.1.0 '@maplibre/geojson-vt': 5.0.4 - '@maplibre/maplibre-gl-style-spec': 24.4.1 + '@maplibre/maplibre-gl-style-spec': 24.6.0 '@maplibre/mlt': 1.1.6 - '@maplibre/vt-pbf': 4.2.1 + '@maplibre/vt-pbf': 4.3.0 '@types/geojson': 7946.0.16 '@types/supercluster': 7.1.3 earcut: 3.0.2 @@ -21805,9 +21787,9 @@ snapshots: minimalistic-assert@1.0.1: {} - minimatch@10.2.2: + minimatch@10.2.4: dependencies: - brace-expansion: 5.0.3 + brace-expansion: 5.0.4 minimatch@3.1.2: dependencies: @@ -21819,7 +21801,7 @@ snapshots: minimatch@9.0.6: dependencies: - brace-expansion: 5.0.3 + brace-expansion: 5.0.4 minimist@1.2.8: {} @@ -21977,12 +21959,12 @@ snapshots: neo-async@2.6.2: {} - nest-commander@3.20.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@types/inquirer@8.2.12)(@types/node@24.11.0)(typescript@5.9.3): + nest-commander@3.20.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@types/inquirer@8.2.12)(@types/node@24.11.0)(typescript@5.9.3): dependencies: '@fig/complete-commander': 3.2.0(commander@11.1.0) - '@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@types/inquirer': 8.2.12 commander: 11.1.0 cosmiconfig: 8.3.6(typescript@5.9.3) @@ -21991,25 +21973,25 @@ snapshots: - '@types/node' - typescript - nestjs-cls@5.4.3(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2): + nestjs-cls@5.4.3(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2): dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 rxjs: 7.8.2 - nestjs-kysely@3.1.2(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(kysely@0.28.2)(reflect-metadata@0.2.2): + nestjs-kysely@3.1.2(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(kysely@0.28.2)(reflect-metadata@0.2.2): dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) kysely: 0.28.2 reflect-metadata: 0.2.2 tslib: 2.8.1 - nestjs-otel@7.0.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14): + nestjs-otel@7.0.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14): dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': 1.9.0 '@opentelemetry/host-metrics': 0.36.2(@opentelemetry/api@1.9.0) response-time: 2.3.4 @@ -22424,11 +22406,11 @@ snapshots: pg-int8@1.0.1: {} - pg-pool@3.11.0(pg@8.18.0): + pg-pool@3.12.0(pg@8.19.0): dependencies: - pg: 8.18.0 + pg: 8.19.0 - pg-protocol@1.11.0: {} + pg-protocol@1.12.0: {} pg-types@2.2.0: dependencies: @@ -22438,11 +22420,11 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg@8.18.0: + pg@8.19.0: dependencies: pg-connection-string: 2.11.0 - pg-pool: 3.11.0(pg@8.18.0) - pg-protocol: 1.11.0 + pg-pool: 3.12.0(pg@8.19.0) + pg-protocol: 1.12.0 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: @@ -22514,446 +22496,446 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 - postcss-attribute-case-insensitive@7.0.1(postcss@8.5.6): + postcss-attribute-case-insensitive@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - postcss-calc@9.0.1(postcss@8.5.6): + postcss-calc@9.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 6.1.2 postcss-value-parser: 4.2.0 - postcss-clamp@4.1.0(postcss@8.5.6): + postcss-clamp@4.1.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-color-functional-notation@7.0.12(postcss@8.5.6): + postcss-color-functional-notation@7.0.12(postcss@8.5.8): dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - postcss-color-hex-alpha@10.0.0(postcss@8.5.6): + postcss-color-hex-alpha@10.0.0(postcss@8.5.8): dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-color-rebeccapurple@10.0.0(postcss@8.5.6): + postcss-color-rebeccapurple@10.0.0(postcss@8.5.8): dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-colormin@6.1.0(postcss@8.5.6): + postcss-colormin@6.1.0(postcss@8.5.8): dependencies: browserslist: 4.28.1 caniuse-api: 3.0.0 colord: 2.9.3 - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-convert-values@6.1.0(postcss@8.5.6): + postcss-convert-values@6.1.0(postcss@8.5.8): dependencies: browserslist: 4.28.1 - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-custom-media@11.0.6(postcss@8.5.6): + postcss-custom-media@11.0.6(postcss@8.5.8): dependencies: '@csstools/cascade-layer-name-parser': 2.0.5(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - postcss: 8.5.6 + postcss: 8.5.8 - postcss-custom-properties@14.0.6(postcss@8.5.6): + postcss-custom-properties@14.0.6(postcss@8.5.8): dependencies: '@csstools/cascade-layer-name-parser': 2.0.5(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-custom-selectors@8.0.5(postcss@8.5.6): + postcss-custom-selectors@8.0.5(postcss@8.5.8): dependencies: '@csstools/cascade-layer-name-parser': 2.0.5(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - postcss-dir-pseudo-class@9.0.1(postcss@8.5.6): + postcss-dir-pseudo-class@9.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - postcss-discard-comments@6.0.2(postcss@8.5.6): + postcss-discard-comments@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-discard-duplicates@6.0.3(postcss@8.5.6): + postcss-discard-duplicates@6.0.3(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-discard-empty@6.0.3(postcss@8.5.6): + postcss-discard-empty@6.0.3(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-discard-overridden@6.0.2(postcss@8.5.6): + postcss-discard-overridden@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-discard-unused@6.0.5(postcss@8.5.6): + postcss-discard-unused@6.0.5(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 6.1.2 - postcss-double-position-gradients@6.0.4(postcss@8.5.6): + postcss-double-position-gradients@6.0.4(postcss@8.5.8): dependencies: - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-focus-visible@10.0.1(postcss@8.5.6): + postcss-focus-visible@10.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - postcss-focus-within@9.0.1(postcss@8.5.6): + postcss-focus-within@9.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - postcss-font-variant@5.0.0(postcss@8.5.6): + postcss-font-variant@5.0.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-gap-properties@6.0.0(postcss@8.5.6): + postcss-gap-properties@6.0.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-image-set-function@7.0.0(postcss@8.5.6): + postcss-image-set-function@7.0.0(postcss@8.5.8): dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-import@15.1.0(postcss@8.5.6): + postcss-import@15.1.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.11 - postcss-js@4.1.0(postcss@8.5.6): + postcss-js@4.1.0(postcss@8.5.8): dependencies: camelcase-css: 2.0.1 - postcss: 8.5.6 + postcss: 8.5.8 - postcss-lab-function@7.0.12(postcss@8.5.6): + postcss-lab-function@7.0.12(postcss@8.5.8): dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/utilities': 2.0.0(postcss@8.5.6) - postcss: 8.5.6 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/utilities': 2.0.0(postcss@8.5.8) + postcss: 8.5.8 - postcss-load-config@3.1.4(postcss@8.5.6): + postcss-load-config@3.1.4(postcss@8.5.8): dependencies: lilconfig: 2.1.0 yaml: 1.10.2 optionalDependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 - postcss: 8.5.6 + postcss: 8.5.8 tsx: 4.21.0 yaml: 2.8.2 - postcss-loader@7.3.4(postcss@8.5.6)(typescript@5.9.3)(webpack@5.104.1): + postcss-loader@7.3.4(postcss@8.5.8)(typescript@5.9.3)(webpack@5.104.1): dependencies: cosmiconfig: 8.3.6(typescript@5.9.3) jiti: 1.21.7 - postcss: 8.5.6 + postcss: 8.5.8 semver: 7.7.4 webpack: 5.104.1 transitivePeerDependencies: - typescript - postcss-logical@8.1.0(postcss@8.5.6): + postcss-logical@8.1.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-merge-idents@6.0.3(postcss@8.5.6): + postcss-merge-idents@6.0.3(postcss@8.5.8): dependencies: - cssnano-utils: 4.0.2(postcss@8.5.6) - postcss: 8.5.6 + cssnano-utils: 4.0.2(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-merge-longhand@6.0.5(postcss@8.5.6): + postcss-merge-longhand@6.0.5(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - stylehacks: 6.1.1(postcss@8.5.6) + stylehacks: 6.1.1(postcss@8.5.8) - postcss-merge-rules@6.1.1(postcss@8.5.6): + postcss-merge-rules@6.1.1(postcss@8.5.8): dependencies: browserslist: 4.28.1 caniuse-api: 3.0.0 - cssnano-utils: 4.0.2(postcss@8.5.6) - postcss: 8.5.6 + cssnano-utils: 4.0.2(postcss@8.5.8) + postcss: 8.5.8 postcss-selector-parser: 6.1.2 - postcss-minify-font-values@6.1.0(postcss@8.5.6): + postcss-minify-font-values@6.1.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-minify-gradients@6.0.3(postcss@8.5.6): + postcss-minify-gradients@6.0.3(postcss@8.5.8): dependencies: colord: 2.9.3 - cssnano-utils: 4.0.2(postcss@8.5.6) - postcss: 8.5.6 + cssnano-utils: 4.0.2(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-minify-params@6.1.0(postcss@8.5.6): + postcss-minify-params@6.1.0(postcss@8.5.8): dependencies: browserslist: 4.28.1 - cssnano-utils: 4.0.2(postcss@8.5.6) - postcss: 8.5.6 + cssnano-utils: 4.0.2(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-minify-selectors@6.0.4(postcss@8.5.6): + postcss-minify-selectors@6.0.4(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 6.1.2 - postcss-modules-extract-imports@3.1.0(postcss@8.5.6): + postcss-modules-extract-imports@3.1.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-modules-local-by-default@4.2.0(postcss@8.5.6): + postcss-modules-local-by-default@4.2.0(postcss@8.5.8): dependencies: - icss-utils: 5.1.0(postcss@8.5.6) - postcss: 8.5.6 + icss-utils: 5.1.0(postcss@8.5.8) + postcss: 8.5.8 postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 - postcss-modules-scope@3.2.1(postcss@8.5.6): + postcss-modules-scope@3.2.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - postcss-modules-values@4.0.0(postcss@8.5.6): + postcss-modules-values@4.0.0(postcss@8.5.8): dependencies: - icss-utils: 5.1.0(postcss@8.5.6) - postcss: 8.5.6 + icss-utils: 5.1.0(postcss@8.5.8) + postcss: 8.5.8 - postcss-nested@6.2.0(postcss@8.5.6): + postcss-nested@6.2.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 6.1.2 - postcss-nesting@13.0.2(postcss@8.5.6): + postcss-nesting@13.0.2(postcss@8.5.8): dependencies: '@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.1) '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - postcss-normalize-charset@6.0.2(postcss@8.5.6): + postcss-normalize-charset@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-normalize-display-values@6.0.2(postcss@8.5.6): + postcss-normalize-display-values@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-positions@6.0.2(postcss@8.5.6): + postcss-normalize-positions@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-repeat-style@6.0.2(postcss@8.5.6): + postcss-normalize-repeat-style@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-string@6.0.2(postcss@8.5.6): + postcss-normalize-string@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-timing-functions@6.0.2(postcss@8.5.6): + postcss-normalize-timing-functions@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-unicode@6.1.0(postcss@8.5.6): + postcss-normalize-unicode@6.1.0(postcss@8.5.8): dependencies: browserslist: 4.28.1 - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-url@6.0.2(postcss@8.5.6): + postcss-normalize-url@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-normalize-whitespace@6.0.2(postcss@8.5.6): + postcss-normalize-whitespace@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-opacity-percentage@3.0.0(postcss@8.5.6): + postcss-opacity-percentage@3.0.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-ordered-values@6.0.2(postcss@8.5.6): + postcss-ordered-values@6.0.2(postcss@8.5.8): dependencies: - cssnano-utils: 4.0.2(postcss@8.5.6) - postcss: 8.5.6 + cssnano-utils: 4.0.2(postcss@8.5.8) + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-overflow-shorthand@6.0.0(postcss@8.5.6): + postcss-overflow-shorthand@6.0.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-page-break@3.0.4(postcss@8.5.6): + postcss-page-break@3.0.4(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-place@10.0.0(postcss@8.5.6): + postcss-place@10.0.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-preset-env@10.5.0(postcss@8.5.6): + postcss-preset-env@10.5.0(postcss@8.5.8): dependencies: - '@csstools/postcss-alpha-function': 1.0.1(postcss@8.5.6) - '@csstools/postcss-cascade-layers': 5.0.2(postcss@8.5.6) - '@csstools/postcss-color-function': 4.0.12(postcss@8.5.6) - '@csstools/postcss-color-function-display-p3-linear': 1.0.1(postcss@8.5.6) - '@csstools/postcss-color-mix-function': 3.0.12(postcss@8.5.6) - '@csstools/postcss-color-mix-variadic-function-arguments': 1.0.2(postcss@8.5.6) - '@csstools/postcss-content-alt-text': 2.0.8(postcss@8.5.6) - '@csstools/postcss-contrast-color-function': 2.0.12(postcss@8.5.6) - '@csstools/postcss-exponential-functions': 2.0.9(postcss@8.5.6) - '@csstools/postcss-font-format-keywords': 4.0.0(postcss@8.5.6) - '@csstools/postcss-gamut-mapping': 2.0.11(postcss@8.5.6) - '@csstools/postcss-gradients-interpolation-method': 5.0.12(postcss@8.5.6) - '@csstools/postcss-hwb-function': 4.0.12(postcss@8.5.6) - '@csstools/postcss-ic-unit': 4.0.4(postcss@8.5.6) - '@csstools/postcss-initial': 2.0.1(postcss@8.5.6) - '@csstools/postcss-is-pseudo-class': 5.0.3(postcss@8.5.6) - '@csstools/postcss-light-dark-function': 2.0.11(postcss@8.5.6) - '@csstools/postcss-logical-float-and-clear': 3.0.0(postcss@8.5.6) - '@csstools/postcss-logical-overflow': 2.0.0(postcss@8.5.6) - '@csstools/postcss-logical-overscroll-behavior': 2.0.0(postcss@8.5.6) - '@csstools/postcss-logical-resize': 3.0.0(postcss@8.5.6) - '@csstools/postcss-logical-viewport-units': 3.0.4(postcss@8.5.6) - '@csstools/postcss-media-minmax': 2.0.9(postcss@8.5.6) - '@csstools/postcss-media-queries-aspect-ratio-number-values': 3.0.5(postcss@8.5.6) - '@csstools/postcss-nested-calc': 4.0.0(postcss@8.5.6) - '@csstools/postcss-normalize-display-values': 4.0.0(postcss@8.5.6) - '@csstools/postcss-oklab-function': 4.0.12(postcss@8.5.6) - '@csstools/postcss-position-area-property': 1.0.0(postcss@8.5.6) - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) - '@csstools/postcss-random-function': 2.0.1(postcss@8.5.6) - '@csstools/postcss-relative-color-syntax': 3.0.12(postcss@8.5.6) - '@csstools/postcss-scope-pseudo-class': 4.0.1(postcss@8.5.6) - '@csstools/postcss-sign-functions': 1.1.4(postcss@8.5.6) - '@csstools/postcss-stepped-value-functions': 4.0.9(postcss@8.5.6) - '@csstools/postcss-system-ui-font-family': 1.0.0(postcss@8.5.6) - '@csstools/postcss-text-decoration-shorthand': 4.0.3(postcss@8.5.6) - '@csstools/postcss-trigonometric-functions': 4.0.9(postcss@8.5.6) - '@csstools/postcss-unset-value': 4.0.0(postcss@8.5.6) - autoprefixer: 10.4.24(postcss@8.5.6) + '@csstools/postcss-alpha-function': 1.0.1(postcss@8.5.8) + '@csstools/postcss-cascade-layers': 5.0.2(postcss@8.5.8) + '@csstools/postcss-color-function': 4.0.12(postcss@8.5.8) + '@csstools/postcss-color-function-display-p3-linear': 1.0.1(postcss@8.5.8) + '@csstools/postcss-color-mix-function': 3.0.12(postcss@8.5.8) + '@csstools/postcss-color-mix-variadic-function-arguments': 1.0.2(postcss@8.5.8) + '@csstools/postcss-content-alt-text': 2.0.8(postcss@8.5.8) + '@csstools/postcss-contrast-color-function': 2.0.12(postcss@8.5.8) + '@csstools/postcss-exponential-functions': 2.0.9(postcss@8.5.8) + '@csstools/postcss-font-format-keywords': 4.0.0(postcss@8.5.8) + '@csstools/postcss-gamut-mapping': 2.0.11(postcss@8.5.8) + '@csstools/postcss-gradients-interpolation-method': 5.0.12(postcss@8.5.8) + '@csstools/postcss-hwb-function': 4.0.12(postcss@8.5.8) + '@csstools/postcss-ic-unit': 4.0.4(postcss@8.5.8) + '@csstools/postcss-initial': 2.0.1(postcss@8.5.8) + '@csstools/postcss-is-pseudo-class': 5.0.3(postcss@8.5.8) + '@csstools/postcss-light-dark-function': 2.0.11(postcss@8.5.8) + '@csstools/postcss-logical-float-and-clear': 3.0.0(postcss@8.5.8) + '@csstools/postcss-logical-overflow': 2.0.0(postcss@8.5.8) + '@csstools/postcss-logical-overscroll-behavior': 2.0.0(postcss@8.5.8) + '@csstools/postcss-logical-resize': 3.0.0(postcss@8.5.8) + '@csstools/postcss-logical-viewport-units': 3.0.4(postcss@8.5.8) + '@csstools/postcss-media-minmax': 2.0.9(postcss@8.5.8) + '@csstools/postcss-media-queries-aspect-ratio-number-values': 3.0.5(postcss@8.5.8) + '@csstools/postcss-nested-calc': 4.0.0(postcss@8.5.8) + '@csstools/postcss-normalize-display-values': 4.0.0(postcss@8.5.8) + '@csstools/postcss-oklab-function': 4.0.12(postcss@8.5.8) + '@csstools/postcss-position-area-property': 1.0.0(postcss@8.5.8) + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.8) + '@csstools/postcss-random-function': 2.0.1(postcss@8.5.8) + '@csstools/postcss-relative-color-syntax': 3.0.12(postcss@8.5.8) + '@csstools/postcss-scope-pseudo-class': 4.0.1(postcss@8.5.8) + '@csstools/postcss-sign-functions': 1.1.4(postcss@8.5.8) + '@csstools/postcss-stepped-value-functions': 4.0.9(postcss@8.5.8) + '@csstools/postcss-system-ui-font-family': 1.0.0(postcss@8.5.8) + '@csstools/postcss-text-decoration-shorthand': 4.0.3(postcss@8.5.8) + '@csstools/postcss-trigonometric-functions': 4.0.9(postcss@8.5.8) + '@csstools/postcss-unset-value': 4.0.0(postcss@8.5.8) + autoprefixer: 10.4.27(postcss@8.5.8) browserslist: 4.28.1 - css-blank-pseudo: 7.0.1(postcss@8.5.6) - css-has-pseudo: 7.0.3(postcss@8.5.6) - css-prefers-color-scheme: 10.0.0(postcss@8.5.6) + css-blank-pseudo: 7.0.1(postcss@8.5.8) + css-has-pseudo: 7.0.3(postcss@8.5.8) + css-prefers-color-scheme: 10.0.0(postcss@8.5.8) cssdb: 8.5.2 - postcss: 8.5.6 - postcss-attribute-case-insensitive: 7.0.1(postcss@8.5.6) - postcss-clamp: 4.1.0(postcss@8.5.6) - postcss-color-functional-notation: 7.0.12(postcss@8.5.6) - postcss-color-hex-alpha: 10.0.0(postcss@8.5.6) - postcss-color-rebeccapurple: 10.0.0(postcss@8.5.6) - postcss-custom-media: 11.0.6(postcss@8.5.6) - postcss-custom-properties: 14.0.6(postcss@8.5.6) - postcss-custom-selectors: 8.0.5(postcss@8.5.6) - postcss-dir-pseudo-class: 9.0.1(postcss@8.5.6) - postcss-double-position-gradients: 6.0.4(postcss@8.5.6) - postcss-focus-visible: 10.0.1(postcss@8.5.6) - postcss-focus-within: 9.0.1(postcss@8.5.6) - postcss-font-variant: 5.0.0(postcss@8.5.6) - postcss-gap-properties: 6.0.0(postcss@8.5.6) - postcss-image-set-function: 7.0.0(postcss@8.5.6) - postcss-lab-function: 7.0.12(postcss@8.5.6) - postcss-logical: 8.1.0(postcss@8.5.6) - postcss-nesting: 13.0.2(postcss@8.5.6) - postcss-opacity-percentage: 3.0.0(postcss@8.5.6) - postcss-overflow-shorthand: 6.0.0(postcss@8.5.6) - postcss-page-break: 3.0.4(postcss@8.5.6) - postcss-place: 10.0.0(postcss@8.5.6) - postcss-pseudo-class-any-link: 10.0.1(postcss@8.5.6) - postcss-replace-overflow-wrap: 4.0.0(postcss@8.5.6) - postcss-selector-not: 8.0.1(postcss@8.5.6) + postcss: 8.5.8 + postcss-attribute-case-insensitive: 7.0.1(postcss@8.5.8) + postcss-clamp: 4.1.0(postcss@8.5.8) + postcss-color-functional-notation: 7.0.12(postcss@8.5.8) + postcss-color-hex-alpha: 10.0.0(postcss@8.5.8) + postcss-color-rebeccapurple: 10.0.0(postcss@8.5.8) + postcss-custom-media: 11.0.6(postcss@8.5.8) + postcss-custom-properties: 14.0.6(postcss@8.5.8) + postcss-custom-selectors: 8.0.5(postcss@8.5.8) + postcss-dir-pseudo-class: 9.0.1(postcss@8.5.8) + postcss-double-position-gradients: 6.0.4(postcss@8.5.8) + postcss-focus-visible: 10.0.1(postcss@8.5.8) + postcss-focus-within: 9.0.1(postcss@8.5.8) + postcss-font-variant: 5.0.0(postcss@8.5.8) + postcss-gap-properties: 6.0.0(postcss@8.5.8) + postcss-image-set-function: 7.0.0(postcss@8.5.8) + postcss-lab-function: 7.0.12(postcss@8.5.8) + postcss-logical: 8.1.0(postcss@8.5.8) + postcss-nesting: 13.0.2(postcss@8.5.8) + postcss-opacity-percentage: 3.0.0(postcss@8.5.8) + postcss-overflow-shorthand: 6.0.0(postcss@8.5.8) + postcss-page-break: 3.0.4(postcss@8.5.8) + postcss-place: 10.0.0(postcss@8.5.8) + postcss-pseudo-class-any-link: 10.0.1(postcss@8.5.8) + postcss-replace-overflow-wrap: 4.0.0(postcss@8.5.8) + postcss-selector-not: 8.0.1(postcss@8.5.8) - postcss-pseudo-class-any-link@10.0.1(postcss@8.5.6): + postcss-pseudo-class-any-link@10.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 - postcss-reduce-idents@6.0.3(postcss@8.5.6): + postcss-reduce-idents@6.0.3(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-reduce-initial@6.1.0(postcss@8.5.6): + postcss-reduce-initial@6.1.0(postcss@8.5.8): dependencies: browserslist: 4.28.1 caniuse-api: 3.0.0 - postcss: 8.5.6 + postcss: 8.5.8 - postcss-reduce-transforms@6.0.2(postcss@8.5.6): + postcss-reduce-transforms@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - postcss-replace-overflow-wrap@4.0.0(postcss@8.5.6): + postcss-replace-overflow-wrap@4.0.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-safe-parser@7.0.1(postcss@8.5.6): + postcss-safe-parser@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-scss@4.0.9(postcss@8.5.6): + postcss-scss@4.0.9(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-selector-not@8.0.1(postcss@8.5.6): + postcss-selector-not@8.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 7.1.1 postcss-selector-parser@6.1.2: @@ -22966,29 +22948,29 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-sort-media-queries@5.2.0(postcss@8.5.6): + postcss-sort-media-queries@5.2.0(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 sort-css-media-queries: 2.2.0 - postcss-svgo@6.0.3(postcss@8.5.6): + postcss-svgo@6.0.3(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 svgo: 3.3.2 - postcss-unique-selectors@6.0.4(postcss@8.5.6): + postcss-unique-selectors@6.0.4(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 6.1.2 postcss-value-parser@4.2.0: {} - postcss-zindex@6.0.2(postcss@8.5.6): + postcss-zindex@6.0.2(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss@8.5.6: + postcss@8.5.8: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -23635,7 +23617,7 @@ snapshots: dependencies: escalade: 3.2.0 picocolors: 1.1.1 - postcss: 8.5.6 + postcss: 8.5.8 strip-json-comments: 3.1.1 run-applescript@7.1.0: {} @@ -23646,14 +23628,14 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.35.1(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5): + runed@0.35.1(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 svelte: 5.53.5 optionalDependencies: - '@sveltejs/kit': 2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) rw@1.3.3: {} @@ -23686,7 +23668,7 @@ snapshots: htmlparser2: 8.0.2 is-plain-object: 5.0.0 parse-srcset: 1.0.2 - postcss: 8.5.6 + postcss: 8.5.8 sass@1.97.1: dependencies: @@ -24223,10 +24205,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - stylehacks@6.1.1(postcss@8.5.6): + stylehacks@6.1.1(postcss@8.5.8): dependencies: browserslist: 4.28.1 - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser: 6.1.2 stylis@4.3.6: {} @@ -24281,7 +24263,7 @@ snapshots: dependencies: svelte: 5.53.5 - svelte-check@4.4.1(picomatch@4.0.3)(svelte@5.53.5)(typescript@5.9.3): + svelte-check@4.4.3(picomatch@4.0.3)(svelte@5.53.5)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 @@ -24293,14 +24275,15 @@ snapshots: transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.4.1(svelte@5.53.5): + svelte-eslint-parser@1.5.1(svelte@5.53.5): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 - postcss: 8.5.6 - postcss-scss: 4.0.9(postcss@8.5.6) + postcss: 8.5.8 + postcss-scss: 4.0.9(postcss@8.5.8) postcss-selector-parser: 7.1.1 + semver: 7.7.4 optionalDependencies: svelte: 5.53.5 @@ -24363,7 +24346,7 @@ snapshots: d3-geo: 3.1.1 dequal: 2.0.3 just-compare: 2.3.0 - maplibre-gl: 5.18.0 + maplibre-gl: 5.19.0 pmtiles: 3.2.1 svelte: 5.53.5 @@ -24379,10 +24362,10 @@ snapshots: dependencies: svelte-floating-ui: 1.5.8 - svelte-toolbelt@0.10.6(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5) + runed: 0.35.1(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5) style-to-object: 1.0.14 svelte: 5.53.5 transitivePeerDependencies: @@ -24444,9 +24427,9 @@ snapshots: tailwind-merge@3.4.0: {} - tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.0): + tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1): dependencies: - tailwindcss: 4.2.0 + tailwindcss: 4.2.1 optionalDependencies: tailwind-merge: 3.4.0 @@ -24480,11 +24463,11 @@ snapshots: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.6 - postcss-import: 15.1.0(postcss@8.5.6) - postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) - postcss-nested: 6.2.0(postcss@8.5.6) + postcss: 8.5.8 + postcss-import: 15.1.0(postcss@8.5.8) + postcss-js: 4.1.0(postcss@8.5.8) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.2) + postcss-nested: 6.2.0(postcss@8.5.8) postcss-selector-parser: 6.1.2 resolve: 1.22.11 sucrase: 3.35.1 @@ -24492,7 +24475,7 @@ snapshots: - tsx - yaml - tailwindcss@4.2.0: {} + tailwindcss@4.2.1: {} tapable@2.3.0: {} @@ -24557,16 +24540,16 @@ snapshots: - react-native-b4a optional: true - terser-webpack-plugin@5.3.16(@swc/core@1.15.11(@swc/helpers@0.5.17))(webpack@5.104.1(@swc/core@1.15.11(@swc/helpers@0.5.17))): + terser-webpack-plugin@5.3.16(@swc/core@1.15.13(@swc/helpers@0.5.17))(webpack@5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17))): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 serialize-javascript: 6.0.2 terser: 5.44.1 - webpack: 5.104.1(@swc/core@1.15.11(@swc/helpers@0.5.17)) + webpack: 5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17)) optionalDependencies: - '@swc/core': 1.15.11(@swc/helpers@0.5.17) + '@swc/core': 1.15.13(@swc/helpers@0.5.17) terser-webpack-plugin@5.3.16(webpack@5.104.1): dependencies: @@ -24750,7 +24733,7 @@ snapshots: tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 - enhanced-resolve: 5.19.0 + enhanced-resolve: 5.20.0 tapable: 2.3.0 tsconfig-paths: 4.2.0 @@ -24802,12 +24785,12 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) eslint: 10.0.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -24942,10 +24925,10 @@ snapshots: unpipe@1.0.0: {} - unplugin-swc@1.5.9(@swc/core@1.15.11(@swc/helpers@0.5.17))(rollup@4.55.1): + unplugin-swc@1.5.9(@swc/core@1.15.13(@swc/helpers@0.5.17))(rollup@4.55.1): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.55.1) - '@swc/core': 1.15.11(@swc/helpers@0.5.17) + '@swc/core': 1.15.13(@swc/helpers@0.5.17) load-tsconfig: 0.2.5 unplugin: 2.3.11 transitivePeerDependencies: @@ -25100,13 +25083,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vite-node@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -25136,7 +25119,7 @@ snapshots: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - postcss: 8.5.6 + postcss: 8.5.8 rollup: 4.55.1 tinyglobby: 0.2.15 optionalDependencies: @@ -25149,16 +25132,16 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - postcss: 8.5.6 + postcss: 8.5.8 rollup: 4.55.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.31.1 @@ -25167,15 +25150,15 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vitefu@1.1.1(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vitefu@1.1.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): optionalDependencies: - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -25203,7 +25186,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.11.0 - happy-dom: 20.6.3 + happy-dom: 20.7.0 jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: - jiti @@ -25219,7 +25202,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -25247,7 +25230,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.11.0 - happy-dom: 20.6.3 + happy-dom: 20.7.0 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -25263,11 +25246,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -25285,13 +25268,13 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 25.3.0 - happy-dom: 20.6.3 + '@types/node': 25.3.3 + happy-dom: 20.7.0 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -25450,7 +25433,7 @@ snapshots: acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.1 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.19.0 + enhanced-resolve: 5.20.0 es-module-lexer: 2.0.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -25470,7 +25453,7 @@ snapshots: - esbuild - uglify-js - webpack@5.104.1(@swc/core@1.15.11(@swc/helpers@0.5.17)): + webpack@5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17)): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -25482,7 +25465,7 @@ snapshots: acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.1 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.19.0 + enhanced-resolve: 5.20.0 es-module-lexer: 2.0.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -25494,7 +25477,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.0 - terser-webpack-plugin: 5.3.16(@swc/core@1.15.11(@swc/helpers@0.5.17))(webpack@5.104.1(@swc/core@1.15.11(@swc/helpers@0.5.17))) + terser-webpack-plugin: 5.3.16(@swc/core@1.15.13(@swc/helpers@0.5.17))(webpack@5.104.1(@swc/core@1.15.13(@swc/helpers@0.5.17))) watchpack: 2.5.1 webpack-sources: 3.3.3 transitivePeerDependencies: From 8c40a28fef4dbabd0234ee04fa874a91fc896e2c Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:08:07 +0100 Subject: [PATCH 050/150] fix(server): clean up edited thumbnail when deleting asset (#26664) --- server/src/services/asset.service.spec.ts | 2 ++ server/src/utils/asset.util.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index db895f8321..cc8603cc5a 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -566,6 +566,8 @@ describe(AssetService.name, () => { .file({ type: AssetFileType.Thumbnail }) .file({ type: AssetFileType.Preview }) .file({ type: AssetFileType.FullSize }) + .file({ type: AssetFileType.Preview, isEdited: true }) + .file({ type: AssetFileType.Thumbnail, isEdited: true }) .build(); mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index c5d1476f65..d6ab825028 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -25,7 +25,7 @@ export const getAssetFiles = (files: AssetFile[]) => ({ editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }), editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }), - editedThumbnailFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }), + editedThumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: true }), }); export const addAssets = async ( From acac0d4f37094458f15035051d7a64fd250e1c34 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 11:14:12 +0000 Subject: [PATCH 051/150] chore(deps): update github-actions (#26656) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: bo0tzz --- .github/workflows/check-openapi.yml | 3 +-- .github/workflows/codeql-analysis.yml | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index 2aaf73ef22..ca9f91bbe8 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -24,8 +24,7 @@ jobs: persist-credentials: false - name: Check for breaking API changes - # sha is pinning to a commit instead of a tag since the action does not tag versions - uses: oasdiff/oasdiff-action/breaking@ccb863950ce437a50f8f1a40d2a1112117e06ce4 + uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30 with: base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 67e0b4b972..4f093a170e 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 + uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -70,7 +70,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 + uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -83,6 +83,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 + uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 with: category: '/language:${{matrix.language}}' From a868ae3ad011f47eaae4a5a95c6f74670a152d41 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Tue, 3 Mar 2026 06:25:03 -0500 Subject: [PATCH 052/150] perf: move album fetching into detail panel (#26632) --- .../asset-viewer/asset-viewer.svelte | 23 +---- .../asset-viewer/detail-panel.svelte | 96 ++++++++++++------- 2 files changed, 66 insertions(+), 53 deletions(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 21077c63ae..786f9fd0ec 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -30,7 +30,6 @@ import { toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetTypeEnum, - getAllAlbums, getAssetInfo, getStack, type AlbumResponseDto, @@ -105,7 +104,6 @@ const asset = $derived(cursor.current); const nextAsset = $derived(cursor.nextAsset); const previousAsset = $derived(cursor.previousAsset); - let appearsInAlbums: AlbumResponseDto[] = $state([]); let sharedLink = getSharedLink(); let previewStackedAsset: AssetResponseDto | undefined = $state(); let fullscreenElement = $state(); @@ -147,7 +145,7 @@ } }; - onMount(async () => { + onMount(() => { syncAssetViewerOpenClass(true); unsubscribes.push( slideshowState.subscribe((value) => { @@ -166,8 +164,6 @@ } }), ); - - await onAlbumAddAssets(); }); onDestroy(() => { @@ -180,18 +176,6 @@ syncAssetViewerOpenClass(false); }); - const onAlbumAddAssets = async () => { - if (authManager.isSharedLink) { - return; - } - - try { - appearsInAlbums = await getAllAlbums({ assetId: asset.id }); - } catch (error) { - console.error('Error getting album that asset belong to', error); - } - }; - const closeViewer = () => { onClose?.(asset); }; @@ -363,7 +347,6 @@ const refresh = async () => { await refreshStack(); - await onAlbumAddAssets(); ocrManager.clear(); if (!sharedLink) { if (previewStackedAsset) { @@ -441,7 +424,7 @@ - + @@ -586,7 +569,7 @@ > {#if showDetailPanel}
- +
{:else if assetViewerManager.isShowEditor}
diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 6e934874e9..e80d376f57 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -17,9 +17,16 @@ import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils'; import { delay, getDimensions } from '$lib/utils/asset-utils'; import { getByteUnitString } from '$lib/utils/byte-units'; + import { handleError } from '$lib/utils/handle-error'; import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; import { getParentPath } from '$lib/utils/tree-utils'; - import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk'; + import { + AssetMediaSize, + getAllAlbums, + getAssetInfo, + type AlbumResponseDto, + type AssetResponseDto, + } from '@immich/sdk'; import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui'; import { mdiCalendar, @@ -38,16 +45,16 @@ import { slide } from 'svelte/transition'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import PersonSidePanel from '../faces-page/person-side-panel.svelte'; + import OnEvents from '../OnEvents.svelte'; import UserAvatar from '../shared-components/user-avatar.svelte'; import AlbumListItemDetails from './album-list-item-details.svelte'; interface Props { asset: AssetResponseDto; - albums?: AlbumResponseDto[]; currentAlbum?: AlbumResponseDto | null; } - let { asset, albums = [], currentAlbum = null }: Props = $props(); + let { asset, currentAlbum = null }: Props = $props(); let showAssetPath = $state(false); let showEditFaces = $state(false); @@ -74,14 +81,33 @@ let previousId: string | undefined = $state(); let previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos()); + const refreshAlbums = async () => { + if (authManager.isSharedLink) { + return []; + } + + try { + return await getAllAlbums({ assetId: asset.id }); + } catch (error) { + handleError(error, 'Error getting asset album membership'); + return []; + } + }; + + let albums = $derived(refreshAlbums()); + $effect(() => { if (!previousId) { previousId = asset.id; + return; } - if (asset.id !== previousId) { - showEditFaces = false; - previousId = asset.id; + + if (asset.id === previousId) { + return; } + + showEditFaces = false; + previousId = asset.id; }); const getMegapixel = (width: number, height: number): number | undefined => { @@ -119,6 +145,8 @@ }; + (albums = refreshAlbums())} /> +
{/if} -{#if albums.length > 0} -
-
- {$t('appears_in')} -
- {#each albums as album (album.id)} -
-
+ {/if} +{/await} {#if $preferences?.tags?.enabled}
From 44eeb1e088dd8e0a2f7b607316e86d3c73b30d29 Mon Sep 17 00:00:00 2001 From: Joe Babbitt Date: Tue, 3 Mar 2026 06:41:29 -0500 Subject: [PATCH 053/150] fix: implement existing withStacked on searchAssetBuilder (#26607) Co-authored-by: Joe --- server/src/utils/database.ts | 1 + .../specs/services/search.service.spec.ts | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 4dd0c9b302..4a57cd1a98 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -404,6 +404,7 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!!options.isNotInAlbum && (!options.albumIds || options.albumIds.length === 0), (qb) => qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('album_asset').whereRef('assetId', '=', 'asset.id')))), ) + .$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null)) .$if(!!options.withExif, withExifInner) .$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople)) .$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null)); diff --git a/server/test/medium/specs/services/search.service.spec.ts b/server/test/medium/specs/services/search.service.spec.ts index f58ffb6a25..c20b64ca7c 100644 --- a/server/test/medium/specs/services/search.service.spec.ts +++ b/server/test/medium/specs/services/search.service.spec.ts @@ -88,4 +88,24 @@ describe(SearchService.name, () => { expect(result).toEqual({ total: 0 }); }); }); + + describe('withStacked option', () => { + it('should exclude stacked assets when withStacked is false', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + + const { asset: primaryAsset } = await ctx.newAsset({ ownerId: user.id }); + const { asset: stackedAsset } = await ctx.newAsset({ ownerId: user.id }); + const { asset: unstackedAsset } = await ctx.newAsset({ ownerId: user.id }); + + await ctx.newStack({ ownerId: user.id }, [primaryAsset.id, stackedAsset.id]); + + const auth = factory.auth({ user: { id: user.id } }); + + const response = await sut.searchMetadata(auth, { withStacked: false }); + + expect(response.assets.items.length).toBe(1); + expect(response.assets.items[0].id).toBe(unstackedAsset.id); + }); + }); }); From 2478cc40f4b049cf2beaaca7665d8c4b7e899738 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 11:42:11 +0000 Subject: [PATCH 054/150] chore(deps): update dependency terragrunt to v0.99.4 (#26658) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- deployment/mise.toml | 2 +- mise.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deployment/mise.toml b/deployment/mise.toml index d77ec84125..070e2f99e9 100644 --- a/deployment/mise.toml +++ b/deployment/mise.toml @@ -1,5 +1,5 @@ [tools] -terragrunt = "0.98.0" +terragrunt = "0.99.4" opentofu = "1.11.4" [tasks."tg:fmt"] diff --git a/mise.toml b/mise.toml index e8a62bca40..a6e77ae944 100644 --- a/mise.toml +++ b/mise.toml @@ -17,7 +17,7 @@ config_roots = [ node = "24.13.1" flutter = "3.35.7" pnpm = "10.30.3" -terragrunt = "0.98.0" +terragrunt = "0.99.4" opentofu = "1.11.4" java = "21.0.2" From 49ad411d50315e2f5a795102bed459f478bb38bd Mon Sep 17 00:00:00 2001 From: Brandon Annin <16074300+niij@users.noreply.github.com> Date: Tue, 3 Mar 2026 05:43:59 -0600 Subject: [PATCH 055/150] fix(docs): add ocr to job flow diagram (#26505) --- docs/docs/administration/jobs-workers.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/docs/administration/jobs-workers.md b/docs/docs/administration/jobs-workers.md index 74025f8ae8..dc2ca55bb9 100644 --- a/docs/docs/administration/jobs-workers.md +++ b/docs/docs/administration/jobs-workers.md @@ -67,7 +67,8 @@ graph TD C --> D["Thumbnail Generation (Large, small, blurred and person)"] D --> E[Smart Search] D --> F[Face Detection] - D --> G[Video Transcoding] - E --> H[Duplicate Detection] - F --> I[Facial Recognition] + D --> G[OCR] + D --> H[Video Transcoding] + E --> I[Duplicate Detection] + F --> J[Facial Recognition] ``` From 0560f98c2d9d7de3b70a18078b9a5d246b6be453 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:52:17 +0100 Subject: [PATCH 056/150] chore(web): clarify locale settings description (#25562) --- i18n/en.json | 8 ++++---- .../lib/components/user-settings-page/app-settings.svelte | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 97cff2c69c..43f325a34a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -871,8 +871,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}}", @@ -895,8 +895,6 @@ "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", "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", @@ -2338,6 +2336,8 @@ "url": "URL", "usage": "Usage", "use_biometric": "Use biometric", + "use_browser_locale": "Use browser locale", + "use_browser_locale_description": "Format dates, times, and numbers based on your browser locale", "use_current_connection": "Use current connection", "use_custom_date_range": "Use custom date range instead", "user": "User", diff --git a/web/src/lib/components/user-settings-page/app-settings.svelte b/web/src/lib/components/user-settings-page/app-settings.svelte index 3e82b76418..9172db5194 100644 --- a/web/src/lib/components/user-settings-page/app-settings.svelte +++ b/web/src/lib/components/user-settings-page/app-settings.svelte @@ -66,7 +66,7 @@ - + {selectedDate} From 4eb08eee18806bd33e028b7edbe8dca65e0b39da Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:28:07 +0000 Subject: [PATCH 057/150] fix(mobile): video state (#26574) Consolidate video state into a single asset-scoped provider, and reduce dependency on global state generally. Overall this should fix a few timing issues and race conditions with videos specifically, and make future changes in this area easier. --- .../lib/pages/common/gallery_viewer.page.dart | 4 - .../common/native_video_viewer.page.dart | 176 +----- mobile/lib/pages/photos/memory.page.dart | 4 - .../presentation/pages/drift_memory.page.dart | 24 +- .../add_action_button.widget.dart | 10 +- .../edit_image_action_button.widget.dart | 4 +- .../like_activity_action_button.widget.dart | 4 +- .../similar_photos_action_button.widget.dart | 2 +- .../widgets/album/album_selector.widget.dart | 4 +- .../asset_viewer/asset_details.widget.dart | 22 +- .../appears_in_details.widget.dart | 20 +- .../date_time_details.widget.dart | 18 +- .../location_details.widget.dart | 28 +- .../asset_details/people_details.widget.dart | 16 +- .../asset_details/rating_details.widget.dart | 8 +- .../technical_details.widget.dart | 11 +- .../asset_viewer/asset_page.widget.dart | 184 +++--- .../asset_viewer/asset_stack.widget.dart | 72 +-- .../asset_viewer/asset_viewer.page.dart | 105 ++-- .../asset_viewer/bottom_bar.widget.dart | 9 +- .../asset_viewer/video_viewer.widget.dart | 583 ++++++------------ .../video_viewer_controls.widget.dart | 75 ++- .../viewer_bottom_app_bar.widget.dart | 21 +- .../viewer_kebab_menu.widget.dart | 4 +- .../viewer_top_app_bar.widget.dart | 12 +- .../widgets/images/thumbnail_tile.widget.dart | 2 +- .../widgets/memory/memory_card.widget.dart | 29 +- .../asset_viewer/asset_viewer.provider.dart} | 28 +- .../video_player_controls_provider.dart | 71 --- .../asset_viewer/video_player_provider.dart | 200 ++++++ .../video_player_value_provider.dart | 88 --- .../infrastructure/action.provider.dart | 12 +- .../asset_viewer/asset.provider.dart | 50 +- mobile/lib/utils/hooks/interval_hook.dart | 15 - .../asset_viewer/bottom_gallery_bar.dart | 2 +- .../custom_video_player_controls.dart | 41 +- .../widgets/asset_viewer/video_controls.dart | 13 +- .../widgets/asset_viewer/video_position.dart | 22 +- mobile/lib/widgets/memories/memory_lane.dart | 4 - mobile/pubspec.lock | 8 +- 40 files changed, 823 insertions(+), 1182 deletions(-) rename mobile/lib/{presentation/widgets/asset_viewer/asset_viewer.state.dart => providers/asset_viewer/asset_viewer.provider.dart} (79%) delete mode 100644 mobile/lib/providers/asset_viewer/video_player_controls_provider.dart create mode 100644 mobile/lib/providers/asset_viewer/video_player_provider.dart delete mode 100644 mobile/lib/providers/asset_viewer/video_player_value_provider.dart delete mode 100644 mobile/lib/utils/hooks/interval_hook.dart diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 0ef27f854b..1d43bff167 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -20,7 +20,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; @@ -367,9 +366,6 @@ class GalleryViewerPage extends HookConsumerWidget { stackIndex.value = 0; ref.read(currentAssetProvider.notifier).set(newAsset); - if (newAsset.isVideo || newAsset.isMotionPhoto) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - } // Wait for page change animation to finish, then precache the next image Timer(const Duration(milliseconds: 400), () { diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart index 9cd9f6bd5e..b1eed29c5c 100644 --- a/mobile/lib/pages/common/native_video_viewer.page.dart +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -11,18 +11,14 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/asset.service.dart'; -import 'package:immich_mobile/utils/debounce.dart'; -import 'package:immich_mobile/utils/hooks/interval_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; @RoutePage() class NativeVideoViewerPage extends HookConsumerWidget { @@ -42,18 +38,10 @@ class NativeVideoViewerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final videoId = asset.id.toString(); final controller = useState(null); - final lastVideoPosition = useRef(-1); - final isBuffering = useRef(false); - - // Used to track whether the video should play when the app - // is brought back to the foreground final shouldPlayOnForeground = useRef(true); - // When a video is opened through the timeline, `isCurrent` will immediately be true. - // When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B. - // If the swipe is completed, `isCurrent` will be true for video B after a delay. - // If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play. final currentAsset = useState(ref.read(currentAssetProvider)); final isCurrent = currentAsset.value == asset; @@ -117,127 +105,45 @@ class NativeVideoViewerPage extends HookConsumerWidget { } }); - void checkIfBuffering() { - if (!context.mounted) { - return; - } - - final videoPlayback = ref.read(videoPlaybackValueProvider); - if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) && - videoPlayback.state != VideoPlaybackState.buffering) { - ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback.copyWith( - state: VideoPlaybackState.buffering, - ); - } - } - - // Timer to mark videos as buffering if the position does not change - useInterval(const Duration(seconds: 5), checkIfBuffering); - - // When the position changes, seek to the position - // Debounce the seek to avoid seeking too often - // But also don't delay the seek too much to maintain visual feedback - final seekDebouncer = useDebouncer( - interval: const Duration(milliseconds: 100), - maxWaitTime: const Duration(milliseconds: 200), - ); - ref.listen(videoPlayerControlsProvider, (oldControls, newControls) { - final playerController = controller.value; - if (playerController == null) { - return; - } - - final playbackInfo = playerController.playbackInfo; - if (playbackInfo == null) { - return; - } - - final oldSeek = oldControls?.position.inMilliseconds; - final newSeek = newControls.position.inMilliseconds; - if (oldSeek != newSeek || newControls.restarted) { - seekDebouncer.run(() => playerController.seekTo(newSeek)); - } - - if (oldControls?.pause != newControls.pause || newControls.restarted) { - unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause)); - } - }); - void onPlaybackReady() async { final videoController = controller.value; if (videoController == null || !isCurrent || !context.mounted) { return; } - final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); - ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; + final notifier = ref.read(videoPlayerProvider(videoId).notifier); + notifier.onNativePlaybackReady(); isVideoReady.value = true; try { final autoPlayVideo = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.autoPlayVideo); if (autoPlayVideo) { - await videoController.play(); + await notifier.play(); } - await videoController.setVolume(0.9); + await notifier.setVolume(1); } catch (error) { log.severe('Error playing video: $error'); } } void onPlaybackStatusChanged() { - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } - - final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); - if (videoPlayback.state == VideoPlaybackState.playing) { - // Sync with the controls playing - WakelockPlus.enable(); - } else { - // Sync with the controls pause - WakelockPlus.disable(); - } - - ref.read(videoPlaybackValueProvider.notifier).status = videoPlayback.state; + if (!context.mounted) return; + ref.read(videoPlayerProvider(videoId).notifier).onNativeStatusChanged(); } void onPlaybackPositionChanged() { - // When seeking, these events sometimes move the slider to an older position - if (seekDebouncer.isActive) { - return; - } - - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } - - final playbackInfo = videoController.playbackInfo; - if (playbackInfo == null) { - return; - } - - ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position); - - // Check if the video is buffering - if (playbackInfo.status == PlaybackStatus.playing) { - isBuffering.value = lastVideoPosition.value == playbackInfo.position; - lastVideoPosition.value = playbackInfo.position; - } else { - isBuffering.value = false; - lastVideoPosition.value = -1; - } + if (!context.mounted) return; + ref.read(videoPlayerProvider(videoId).notifier).onNativePositionChanged(); } void onPlaybackEnded() { - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } + if (!context.mounted) return; - if (videoController.playbackInfo?.status == PlaybackStatus.stopped && + ref.read(videoPlayerProvider(videoId).notifier).onNativePlaybackEnded(); + + final videoController = controller.value; + if (videoController?.playbackInfo?.status == PlaybackStatus.stopped && !ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.loopVideo)) { ref.read(isPlayingMotionVideoProvider.notifier).playing = false; } @@ -254,14 +160,15 @@ class NativeVideoViewerPage extends HookConsumerWidget { if (controller.value != null || !context.mounted) { return; } - ref.read(videoPlayerControlsProvider.notifier).reset(); - ref.read(videoPlaybackValueProvider.notifier).reset(); final source = await videoSource; if (source == null) { return; } + final notifier = ref.read(videoPlayerProvider(videoId).notifier); + notifier.attachController(nc); + nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged); nc.onPlaybackReady.addListener(onPlaybackReady); @@ -273,10 +180,9 @@ class NativeVideoViewerPage extends HookConsumerWidget { }), ); final loopVideo = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.loopVideo); - unawaited(nc.setLoop(loopVideo)); + await notifier.setLoop(loopVideo); controller.value = nc; - Timer(const Duration(milliseconds: 200), checkIfBuffering); } ref.listen(currentAssetProvider, (_, value) { @@ -300,10 +206,6 @@ class NativeVideoViewerPage extends HookConsumerWidget { } // Delay the video playback to avoid a stutter in the swipe animation - // Note, in some circumstances a longer delay is needed (eg: memories), - // the playbackDelayFactor can be used for this - // This delay seems like a hacky way to resolve underlying bugs in video - // playback, but other resolutions failed thus far Timer( Platform.isIOS ? Duration(milliseconds: 300 * playbackDelayFactor) @@ -337,19 +239,18 @@ class NativeVideoViewerPage extends HookConsumerWidget { playerController.stop().catchError((error) { log.fine('Error stopping video: $error'); }); - - WakelockPlus.disable(); }; }, const []); useOnAppLifecycleStateChange((_, state) async { + final notifier = ref.read(videoPlayerProvider(videoId).notifier); if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) { - await controller.value?.play(); + await notifier.play(); } else if (state == AppLifecycleState.paused) { final videoPlaying = await controller.value?.isPlaying(); if (videoPlaying ?? true) { shouldPlayOnForeground.value = true; - await controller.value?.pause(); + await notifier.pause(); } else { shouldPlayOnForeground.value = false; } @@ -374,39 +275,8 @@ class NativeVideoViewerPage extends HookConsumerWidget { ), ), ), - if (showControls) const Center(child: CustomVideoPlayerControls()), + if (showControls) Center(child: CustomVideoPlayerControls(videoId: videoId)), ], ); } - - Future _onPauseChange( - BuildContext context, - NativeVideoPlayerController controller, - Debouncer seekDebouncer, - bool isPaused, - ) async { - if (!context.mounted) { - return; - } - - // Make sure the last seek is complete before pausing or playing - // Otherwise, `onPlaybackPositionChanged` can receive outdated events - if (seekDebouncer.isActive) { - await seekDebouncer.drain(); - } - - if (!context.mounted) { - return; - } - - try { - if (isPaused) { - await controller.pause(); - } else { - await controller.play(); - } - } catch (error) { - log.severe('Error pausing or playing video: $error'); - } - } } diff --git a/mobile/lib/pages/photos/memory.page.dart b/mobile/lib/pages/photos/memory.page.dart index 20bd32a171..bd7973bc21 100644 --- a/mobile/lib/pages/photos/memory.page.dart +++ b/mobile/lib/pages/photos/memory.page.dart @@ -7,7 +7,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart'; import 'package:immich_mobile/widgets/memories/memory_bottom_info.dart'; @@ -166,9 +165,6 @@ class MemoryPage extends HookConsumerWidget { final asset = currentMemory.value.assets[otherIndex]; currentAsset.value = asset; ref.read(currentAssetProvider.notifier).set(asset); - if (asset.isVideo || asset.isMotionPhoto) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - } } /* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called diff --git a/mobile/lib/presentation/pages/drift_memory.page.dart b/mobile/lib/presentation/pages/drift_memory.page.dart index 147165f2a3..3f8879c91d 100644 --- a/mobile/lib/presentation/pages/drift_memory.page.dart +++ b/mobile/lib/presentation/pages/drift_memory.page.dart @@ -7,16 +7,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart'; import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/widgets/memories/memory_epilogue.dart'; import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart'; -/// Expects [currentAssetNotifier] to be set before navigating to this page +/// Expects the current asset to be set via [assetViewerProvider] before navigating to this page @RoutePage() class DriftMemoryPage extends HookConsumerWidget { final List memories; @@ -26,11 +25,7 @@ class DriftMemoryPage extends HookConsumerWidget { static void setMemory(WidgetRef ref, DriftMemory memory) { if (memory.assets.isNotEmpty) { - ref.read(currentAssetNotifier.notifier).setAsset(memory.assets.first); - - if (memory.assets.first.isVideo) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - } + ref.read(assetViewerProvider.notifier).setAsset(memory.assets.first); } } @@ -172,11 +167,7 @@ class DriftMemoryPage extends HookConsumerWidget { final asset = currentMemory.value.assets[otherIndex]; currentAsset.value = asset; - ref.read(currentAssetNotifier.notifier).setAsset(asset); - // if (asset.isVideo || asset.isMotionPhoto) { - if (asset.isVideo) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - } + ref.read(assetViewerProvider.notifier).setAsset(asset); } /* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called @@ -273,7 +264,12 @@ class DriftMemoryPage extends HookConsumerWidget { children: [ Container( color: Colors.black, - child: DriftMemoryCard(asset: asset, title: title, showTitle: index == 0), + child: DriftMemoryCard( + asset: asset, + title: title, + showTitle: index == 0, + isCurrent: mIndex == currentMemoryIndex.value && index == currentAssetPage.value, + ), ), Positioned.fill( child: Row( diff --git a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart index 4162f43a24..39bdef8b9a 100644 --- a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; @@ -49,7 +49,7 @@ class _AddActionButtonState extends ConsumerState { } List _buildMenuChildren() { - final asset = ref.read(currentAssetNotifier); + final asset = ref.read(assetViewerProvider).currentAsset; if (asset == null) return []; final user = ref.read(currentUserProvider); @@ -103,7 +103,7 @@ class _AddActionButtonState extends ConsumerState { } void _openAlbumSelector() { - final currentAsset = ref.read(currentAssetNotifier); + final currentAsset = ref.read(assetViewerProvider).currentAsset; if (currentAsset == null) { ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); return; @@ -133,7 +133,7 @@ class _AddActionButtonState extends ConsumerState { } Future _addCurrentAssetToAlbum(RemoteAlbum album) async { - final latest = ref.read(currentAssetNotifier); + final latest = ref.read(assetViewerProvider).currentAsset; if (latest == null) { ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); @@ -169,7 +169,7 @@ class _AddActionButtonState extends ConsumerState { @override Widget build(BuildContext context) { - final asset = ref.watch(currentAssetNotifier); + final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); if (asset == null) { return const SizedBox.shrink(); } diff --git a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart index 440985a0bb..cad74ce658 100644 --- a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/routing/router.dart'; class EditImageActionButton extends ConsumerWidget { @@ -12,7 +12,7 @@ class EditImageActionButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final currentAsset = ref.watch(currentAssetNotifier); + final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); onPress() { if (currentAsset == null) { diff --git a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart index a44b0b5815..96a7daa327 100644 --- a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -20,7 +20,7 @@ class LikeActivityActionButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final album = ref.watch(currentRemoteAlbumProvider); - final asset = ref.watch(currentAssetNotifier) as RemoteAsset?; + final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)) as RemoteAsset?; final user = ref.watch(currentUserProvider); final activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id)); diff --git a/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart index 294ddfd1f5..530c3fd8d4 100644 --- a/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart @@ -8,7 +8,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/routing/router.dart'; class SimilarPhotosActionButton extends ConsumerWidget { diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index 15749fb9af..0c039847a4 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dar import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -809,7 +809,7 @@ class CreateAlbumButton extends ConsumerWidget { return; } - final asset = ref.read(currentAssetNotifier); + final asset = ref.read(assetViewerProvider).currentAsset; if (asset == null) { ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart index 949a6917e9..e07fd79192 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart'; @@ -11,16 +12,15 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/te import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; class AssetDetails extends ConsumerWidget { + final BaseAsset asset; final double minHeight; - const AssetDetails({required this.minHeight, super.key}); + const AssetDetails({super.key, required this.asset, required this.minHeight}); @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) { - return const SizedBox.shrink(); - } + final exifInfo = ref.watch(assetExifProvider(asset)).valueOrNull; + return Container( constraints: BoxConstraints(minHeight: minHeight), decoration: BoxDecoration( @@ -31,12 +31,12 @@ class AssetDetails extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const DragHandle(), - const DateTimeDetails(), - const PeopleDetails(), - const LocationDetails(), - const TechnicalDetails(), - const RatingDetails(), - const AppearsInDetails(), + DateTimeDetails(asset: asset, exifInfo: exifInfo), + PeopleDetails(asset: asset), + LocationDetails(asset: asset, exifInfo: exifInfo), + TechnicalDetails(asset: asset, exifInfo: exifInfo), + RatingDetails(exifInfo: exifInfo), + AppearsInDetails(asset: asset), SizedBox(height: context.padding.bottom + 48), ], ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart index a3d6bdb8ab..fc15503a3f 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart @@ -8,27 +8,25 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; class AppearsInDetails extends ConsumerWidget { - const AppearsInDetails({super.key}); + final BaseAsset asset; + + const AppearsInDetails({super.key, required this.asset}); @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null || !asset.hasRemote) return const SizedBox.shrink(); + if (!asset.hasRemote) return const SizedBox.shrink(); - String? remoteAssetId; - if (asset is RemoteAsset) { - remoteAssetId = asset.id; - } else if (asset is LocalAsset) { - remoteAssetId = asset.remoteAssetId; - } + final remoteAssetId = switch (asset) { + RemoteAsset(:final id) => id, + LocalAsset(:final remoteAssetId) => remoteAssetId, + }; if (remoteAssetId == null) return const SizedBox.shrink(); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart index 4872bf9e75..27bac68310 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart @@ -10,7 +10,6 @@ import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/utils/timezone.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -18,14 +17,15 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; const _kSeparator = ' • '; class DateTimeDetails extends ConsumerWidget { - const DateTimeDetails({super.key}); + final BaseAsset asset; + final ExifInfo? exifInfo; + + const DateTimeDetails({super.key, required this.asset, this.exifInfo}); @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) return const SizedBox.shrink(); - - final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final asset = this.asset; + final exifInfo = this.exifInfo; final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null); return Column( @@ -106,9 +106,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> @override Widget build(BuildContext context) { - final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull; - - final currentDescription = currentExifInfo?.description ?? ''; + final currentDescription = widget.exif.description ?? ''; final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t( context: context, ); @@ -134,7 +132,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> errorBorder: InputBorder.none, focusedErrorBorder: InputBorder.none, ), - onTapOutside: (_) => saveDescription(currentExifInfo?.description), + onTapOutside: (_) => saveDescription(widget.exif.description), ), ), ); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart index 0665f4d46c..8c144a83bd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart @@ -8,12 +8,14 @@ import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; class LocationDetails extends ConsumerStatefulWidget { - const LocationDetails({super.key}); + final BaseAsset asset; + final ExifInfo? exifInfo; + + const LocationDetails({super.key, required this.asset, this.exifInfo}); @override ConsumerState createState() => _LocationDetailsState(); @@ -40,17 +42,15 @@ class _LocationDetailsState extends ConsumerState { _mapController = controller; } - void _onExifChanged(AsyncValue? previous, AsyncValue current) { - final currentExif = current.valueOrNull; - if (currentExif != null && currentExif.hasCoordinates) { - _mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!))); - } - } - @override - void initState() { - super.initState(); - ref.listenManual(currentAssetExifProvider, _onExifChanged, fireImmediately: true); + void didUpdateWidget(LocationDetails oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.exifInfo != oldWidget.exifInfo) { + final exif = widget.exifInfo; + if (exif != null && exif.hasCoordinates) { + _mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(exif.latitude!, exif.longitude!))); + } + } } void editLocation() async { @@ -59,8 +59,8 @@ class _LocationDetailsState extends ConsumerState { @override Widget build(BuildContext context) { - final asset = ref.watch(currentAssetNotifier); - final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final asset = widget.asset; + final exifInfo = widget.exifInfo; final hasCoordinates = exifInfo?.hasCoordinates ?? false; // Guard local assets diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart index 5074c63c9c..6c6f4a002c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart @@ -7,7 +7,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; @@ -15,17 +14,14 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/people.utils.dart'; -class PeopleDetails extends ConsumerStatefulWidget { - const PeopleDetails({super.key}); +class PeopleDetails extends ConsumerWidget { + final BaseAsset asset; + + const PeopleDetails({super.key, required this.asset}); @override - ConsumerState createState() => _PeopleDetailsState(); -} - -class _PeopleDetailsState extends ConsumerState { - @override - Widget build(BuildContext context) { - final asset = ref.watch(currentAssetNotifier); + Widget build(BuildContext context, WidgetRef ref) { + final asset = this.asset; if (asset is! RemoteAsset) { return const SizedBox.shrink(); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart index 982ea67583..fb3a9dd8a8 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart @@ -1,16 +1,18 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; class RatingDetails extends ConsumerWidget { - const RatingDetails({super.key}); + final ExifInfo? exifInfo; + + const RatingDetails({super.key, this.exifInfo}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -20,8 +22,6 @@ class RatingDetails extends ConsumerWidget { if (!isRatingEnabled) return const SizedBox.shrink(); - final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; - return Padding( padding: const EdgeInsets.only(left: 16.0, top: 16.0), child: Column( diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart index d79362b559..52d00828f1 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart @@ -6,21 +6,20 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; const _kSeparator = ' • '; class TechnicalDetails extends ConsumerWidget { - const TechnicalDetails({super.key}); + final BaseAsset asset; + final ExifInfo? exifInfo; + + const TechnicalDetails({super.key, required this.asset, this.exifInfo}); @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) return const SizedBox.shrink(); - - final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final exifInfo = this.exifInfo; final cameraTitle = _getCameraInfoTitle(exifInfo); final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null; diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index 77fe8634a9..5da8227ef0 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -12,16 +12,16 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; @@ -52,7 +52,6 @@ class _AssetPageState extends ConsumerState { final _scrollController = ScrollController(); late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController); - double _snapOffset = 0.0; DragStartDetails? _dragStart; @@ -246,14 +245,16 @@ class _AssetPageState extends ConsumerState { ref.read(isPlayingMotionVideoProvider.notifier).playing = true; void _onScaleStateChanged(PhotoViewScaleState scaleState) { - _isZoomed = switch (scaleState) { - PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true, - _ => false, - }; + _isZoomed = scaleState == PhotoViewScaleState.zoomedIn || scaleState == PhotoViewScaleState.covering; _viewer.setZoomed(_isZoomed); if (scaleState != PhotoViewScaleState.initial) { if (_dragStart == null) _viewer.setControls(false); + + final heroTag = ref.read(assetViewerProvider).currentAsset?.heroTag; + if (heroTag != null) { + ref.read(videoPlayerProvider(heroTag).notifier).pause(); + } return; } @@ -288,22 +289,20 @@ class _AssetPageState extends ConsumerState { _listenForScaleBoundaries(controller); } - Widget _buildPhotoView( - BaseAsset displayAsset, - BaseAsset asset, { - required bool isCurrentPage, - required bool showingDetails, + Widget _buildPhotoView({ + required BaseAsset asset, + required PhotoViewHeroAttributes? heroAttributes, + required bool isCurrent, required bool isPlayingMotionVideo, required BoxDecoration backgroundDecoration, }) { - final heroAttributes = isCurrentPage ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') : null; + final size = context.sizeData; - if (displayAsset.isImage && !isPlayingMotionVideo) { - final size = context.sizeData; + if (asset.isImage && !isPlayingMotionVideo) { return PhotoView( - key: Key(displayAsset.heroTag), + key: Key(asset.heroTag), index: widget.index, - imageProvider: getFullImageProvider(displayAsset, size: size), + imageProvider: getFullImageProvider(asset, size: size), heroAttributes: heroAttributes, loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()), backgroundDecoration: backgroundDecoration, @@ -311,7 +310,7 @@ class _AssetPageState extends ConsumerState { filterQuality: FilterQuality.high, tightMode: true, enablePanAlways: true, - disableScaleGestures: showingDetails, + disableScaleGestures: _showingDetails, scaleStateChangedCallback: _onScaleStateChanged, onPageBuild: _onPageBuild, onDragStart: _onDragStart, @@ -319,45 +318,42 @@ class _AssetPageState extends ConsumerState { onDragEnd: _onDragEnd, onDragCancel: _onDragCancel, onTapUp: _onTapUp, - onLongPressStart: displayAsset.isMotionPhoto ? _onLongPress : null, + onLongPressStart: asset.isMotionPhoto ? _onLongPress : null, errorBuilder: (_, __, ___) => SizedBox( width: size.width, height: size.height, - child: Thumbnail.fromAsset(asset: displayAsset, fit: BoxFit.contain), + child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain), ), ); } - final Size childSize; - if (displayAsset.width != null && displayAsset.height != null) { - final r = displayAsset.width! / displayAsset.height!; - final w = math.min(context.width, context.height * r); - childSize = Size(w, w / r); - } else { - childSize = Size(context.height, context.height); - } - return PhotoView.customChild( - key: Key(displayAsset.heroTag), - childSize: childSize, - filterQuality: FilterQuality.low, + key: Key(asset.heroTag), + childSize: asset.width != null && asset.height != null + ? Size(asset.width!.toDouble(), asset.height!.toDouble()) + : null, onDragStart: _onDragStart, onDragUpdate: _onDragUpdate, onDragEnd: _onDragEnd, onDragCancel: _onDragCancel, onTapUp: _onTapUp, - heroAttributes: heroAttributes, - basePosition: Alignment.center, - disableScaleGestures: showingDetails, scaleStateChangedCallback: _onScaleStateChanged, + heroAttributes: heroAttributes, + filterQuality: FilterQuality.high, + basePosition: Alignment.center, + disableScaleGestures: _showingDetails, + minScale: PhotoViewComputedScale.contained, + initialScale: PhotoViewComputedScale.contained, + tightMode: true, onPageBuild: _onPageBuild, enablePanAlways: true, backgroundDecoration: backgroundDecoration, child: NativeVideoViewer( - key: _NativeVideoViewerKey(displayAsset.heroTag), - asset: displayAsset, + key: _NativeVideoViewerKey(asset.heroTag), + asset: asset, + isCurrent: isCurrent, image: Image( - image: getFullImageProvider(displayAsset, size: childSize), + image: getFullImageProvider(asset, size: size), fit: BoxFit.contain, alignment: Alignment.center, ), @@ -383,6 +379,8 @@ class _AssetPageState extends ConsumerState { displayAsset = stackChildren.elementAt(stackIndex); } + final isCurrent = currentHeroTag == displayAsset.heroTag; + final viewportWidth = MediaQuery.widthOf(context); final viewportHeight = MediaQuery.heightOf(context); final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset); @@ -396,65 +394,63 @@ class _AssetPageState extends ConsumerState { _proxyScrollController.snapPosition.snapOffset = _snapOffset; } - return ProviderScope( - overrides: [ - currentAssetNotifier.overrideWith(() => ScopedAssetNotifier(asset)), - currentAssetExifProvider.overrideWith((ref) { - final a = ref.watch(currentAssetNotifier); - if (a == null) return Future.value(null); - return ref.watch(assetServiceProvider).getExif(a); - }), - ], - child: Stack( - children: [ - Offstage( - child: SingleChildScrollView( - controller: _proxyScrollController, - physics: const SnapScrollPhysics(), - child: const SizedBox.shrink(), - ), + return Stack( + children: [ + Offstage( + child: SingleChildScrollView( + controller: _proxyScrollController, + physics: const SnapScrollPhysics(), + child: const SizedBox.shrink(), ), - SingleChildScrollView( - controller: _scrollController, - physics: const NeverScrollableScrollPhysics(), - child: Stack( - children: [ - SizedBox( - width: viewportWidth, - height: viewportHeight, - child: _buildPhotoView( - displayAsset, - asset, - isCurrentPage: currentHeroTag == asset.heroTag, - showingDetails: _showingDetails, - isPlayingMotionVideo: isPlayingMotionVideo, - backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent), - ), + ), + SingleChildScrollView( + controller: _scrollController, + physics: const NeverScrollableScrollPhysics(), + child: Stack( + children: [ + SizedBox( + width: viewportWidth, + height: viewportHeight, + child: _buildPhotoView( + asset: displayAsset, + heroAttributes: isCurrent + ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') + : null, + isCurrent: isCurrent, + isPlayingMotionVideo: isPlayingMotionVideo, + backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent), ), - IgnorePointer( - ignoring: !_showingDetails, - child: Column( - children: [ - SizedBox(height: detailsOffset), - GestureDetector( - onVerticalDragStart: _beginDrag, - onVerticalDragUpdate: _updateDrag, - onVerticalDragEnd: _endDrag, - onVerticalDragCancel: _onDragCancel, - child: AnimatedOpacity( - opacity: _showingDetails ? 1.0 : 0.0, - duration: Durations.short2, - child: AssetDetails(minHeight: viewportHeight - snapTarget), - ), + ), + IgnorePointer( + ignoring: !_showingDetails, + child: Column( + children: [ + SizedBox(height: detailsOffset), + GestureDetector( + onVerticalDragStart: _beginDrag, + onVerticalDragUpdate: _updateDrag, + onVerticalDragEnd: _endDrag, + onVerticalDragCancel: _onDragCancel, + child: AnimatedOpacity( + opacity: _showingDetails ? 1.0 : 0.0, + duration: Durations.short2, + child: AssetDetails(asset: displayAsset, minHeight: viewportHeight - snapTarget), ), - ], - ), + ), + ], ), - ], - ), + ), + ], ), - ], - ), + ), + if (stackChildren != null && stackChildren.isNotEmpty) + Positioned( + left: 0, + right: 0, + bottom: context.padding.bottom, + child: AssetStackRow(stack: stackChildren), + ), + ], ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart index 2835342b85..213dc92ef3 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart @@ -1,53 +1,42 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; class AssetStackRow extends ConsumerWidget { - const AssetStackRow({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset)); - if (asset == null) { - return const SizedBox.shrink(); - } - - final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull; - if (stackChildren == null || stackChildren.isEmpty) { - return const SizedBox.shrink(); - } - - final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); - if (showingDetails) { - return const SizedBox.shrink(); - } - return _StackList(stack: stackChildren); - } -} - -class _StackList extends ConsumerWidget { final List stack; - const _StackList({required this.stack}); + const AssetStackRow({super.key, required this.stack}); @override Widget build(BuildContext context, WidgetRef ref) { - return Center( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Padding( - padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 5.0, - children: List.generate(stack.length, (i) { - final asset = stack[i]; - return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i); - }), + if (stack.isEmpty) { + return const SizedBox.shrink(); + } + + final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0); + + return IgnorePointer( + ignoring: opacity < 1.0, + child: AnimatedOpacity( + opacity: opacity, + duration: Durations.short2, + child: Center( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 5.0, + children: List.generate(stack.length, (i) { + final asset = stack[i]; + return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i); + }), + ), + ), ), ), ), @@ -67,8 +56,9 @@ class _StackItem extends ConsumerStatefulWidget { class _StackItemState extends ConsumerState<_StackItem> { void _onTap() { - ref.read(currentAssetNotifier.notifier).setAsset(widget.asset); - ref.read(assetViewerProvider.notifier).setStackIndex(widget.index); + final notifier = ref.read(assetViewerProvider.notifier); + notifier.setAsset(widget.asset); + notifier.setStackIndex(widget.index); } @override diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index b353c6d80f..903105406c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -17,13 +17,10 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/download_statu import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; @@ -72,15 +69,7 @@ class AssetViewer extends ConsumerStatefulWidget { } static void _setAsset(WidgetRef ref, BaseAsset asset) { - // Always holds the current asset from the timeline ref.read(assetViewerProvider.notifier).setAsset(asset); - // The currentAssetNotifier actually holds the current asset that is displayed - // which could be stack children as well - ref.read(currentAssetNotifier.notifier).setAsset(asset); - if (asset.isVideo || asset.isMotionPhoto) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - ref.read(videoPlayerControlsProvider.notifier).pause(); - } // Hide controls by default for videos if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false); } @@ -91,6 +80,8 @@ class _AssetViewerState extends ConsumerState { late final _pageController = PageController(initialPage: widget.initialIndex); late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted); + late int _currentPage = widget.initialIndex; + StreamSubscription? _reloadSubscription; KeepAliveLink? _stackChildrenKeepAlive; @@ -102,7 +93,9 @@ class _AssetViewerState extends ConsumerState { final target = page + direction; final maxPage = ref.read(timelineServiceProvider).totalAssets - 1; if (target >= 0 && target <= maxPage) { + _currentPage = target; _pageController.jumpToPage(target); + _onAssetChanged(target); } } @@ -110,7 +103,7 @@ class _AssetViewerState extends ConsumerState { void initState() { super.initState(); - final asset = ref.read(currentAssetNotifier); + final asset = ref.read(assetViewerProvider).currentAsset; assert(asset != null, "Current asset should not be null when opening the AssetViewer"); if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); @@ -134,6 +127,26 @@ class _AssetViewerState extends ConsumerState { super.dispose(); } + // The normal onPageChange callback listens to OnScrollUpdate events, and will + // round the current page and update whenever that value changes. In practise, + // this means that the page will change when swiped half way, and may flip + // whilst dragging. + // + // Changing the page at the end of a scroll should be more robust, and allow + // the page to be dragged more than half way whilst keeping the current video + // playing, and preventing the video on the next page from becoming ready + // unnecessarily. + bool _onScrollEnd(ScrollEndNotification notification) { + if (notification.depth != 0) return false; + + final page = _pageController.page?.round(); + if (page != null && page != _currentPage) { + _currentPage = page; + _onAssetChanged(page); + } + return false; + } + void _onAssetInit(Duration timeStamp) { _preloader.preload(widget.initialIndex, context.sizeData); _handleCasting(); @@ -153,7 +166,7 @@ class _AssetViewerState extends ConsumerState { void _handleCasting() { if (!ref.read(castProvider).isCasting) return; - final asset = ref.read(currentAssetNotifier); + final asset = ref.read(assetViewerProvider).currentAsset; if (asset == null) return; if (asset is RemoteAsset) { @@ -195,17 +208,19 @@ class _AssetViewerState extends ConsumerState { } var index = _pageController.page?.round() ?? 0; - final currentAsset = ref.read(currentAssetNotifier); + final currentAsset = ref.read(assetViewerProvider).currentAsset; if (currentAsset != null) { final newIndex = timelineService.getIndex(currentAsset.heroTag); if (newIndex != null && newIndex != index) { index = newIndex; + _currentPage = index; _pageController.jumpToPage(index); } } if (index >= totalAssets) { index = totalAssets - 1; + _currentPage = index; _pageController.jumpToPage(index); } @@ -221,7 +236,7 @@ class _AssetViewerState extends ConsumerState { final newAsset = await timelineService.getAssetAsync(index); if (newAsset == null) return; - final currentAsset = ref.read(currentAssetNotifier); + final currentAsset = ref.read(assetViewerProvider).currentAsset; // Do not reload if the asset has not changed if (newAsset.heroTag == currentAsset?.heroTag) return; @@ -258,25 +273,26 @@ class _AssetViewerState extends ConsumerState { _setSystemUIMode(controls, details); }); - return PopScope( - onPopInvokedWithResult: (didPop, result) => ref.read(currentAssetNotifier.notifier).dispose(), - child: Scaffold( - backgroundColor: backgroundColor, - appBar: const ViewerTopAppBar(), - extendBody: true, - extendBodyBehindAppBar: true, - floatingActionButton: IgnorePointer( - ignoring: !showingControls, - child: AnimatedOpacity( - opacity: showingControls ? 1.0 : 0.0, - duration: Durations.short2, - child: const DownloadStatusFloatingButton(), - ), + return Scaffold( + backgroundColor: backgroundColor, + resizeToAvoidBottomInset: false, + appBar: const ViewerTopAppBar(), + extendBody: true, + extendBodyBehindAppBar: true, + floatingActionButton: IgnorePointer( + ignoring: !showingControls, + child: AnimatedOpacity( + opacity: showingControls ? 1.0 : 0.0, + duration: Durations.short2, + child: const DownloadStatusFloatingButton(), ), - bottomNavigationBar: const ViewerBottomAppBar(), - body: Stack( - children: [ - PhotoViewGestureDetectorScope( + ), + bottomNavigationBar: const ViewerBottomAppBar(), + body: Stack( + children: [ + NotificationListener( + onNotification: _onScrollEnd, + child: PhotoViewGestureDetectorScope( axis: Axis.horizontal, child: PageView.builder( controller: _pageController, @@ -286,21 +302,20 @@ class _AssetViewerState extends ConsumerState { ? const FastScrollPhysics() : const FastClampingScrollPhysics(), itemCount: ref.read(timelineServiceProvider).totalAssets, - onPageChanged: (index) => _onAssetChanged(index), itemBuilder: (context, index) => AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate), ), ), - if (!CurrentPlatform.isIOS) - IgnorePointer( - child: AnimatedContainer( - duration: Durations.short2, - color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0), - height: context.padding.top, - ), + ), + if (!CurrentPlatform.isIOS) + IgnorePointer( + child: AnimatedContainer( + duration: Durations.short2, + color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0), + height: context.padding.top, ), - ], - ), + ), + ], ), ); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 93006ab978..113c55932f 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -9,8 +9,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_act import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -21,7 +20,7 @@ class ViewerBottomBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); + final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); if (asset == null) { return const SizedBox.shrink(); } @@ -65,9 +64,9 @@ class ViewerBottomBar extends ConsumerWidget { color: Colors.black.withAlpha(125), padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16), child: Column( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, children: [ - if (asset.isVideo) const VideoControls(), + if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag), if (!isReadonlyModeEnabled) Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), ], diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 01970422a8..ecfe0b3ddc 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -1,8 +1,6 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; @@ -11,420 +9,225 @@ import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/debounce.dart'; -import 'package:immich_mobile/utils/hooks/interval_hook.dart'; import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; -bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) { - if (asset is RemoteAsset) { - return switch (currentAsset) { - RemoteAsset remoteAsset => remoteAsset.id == asset.id, - LocalAsset localAsset => localAsset.remoteId == asset.id, - _ => false, - }; - } else if (asset is LocalAsset) { - return switch (currentAsset) { - RemoteAsset remoteAsset => remoteAsset.localId == asset.id, - LocalAsset localAsset => localAsset.id == asset.id, - _ => false, - }; - } - return false; -} - -class NativeVideoViewer extends HookConsumerWidget { - static final log = Logger('NativeVideoViewer'); +class NativeVideoViewer extends ConsumerStatefulWidget { final BaseAsset asset; - final int playbackDelayFactor; + final bool isCurrent; + final bool showControls; final Widget image; - const NativeVideoViewer({super.key, required this.asset, required this.image, this.playbackDelayFactor = 1}); + const NativeVideoViewer({ + super.key, + required this.asset, + required this.image, + this.isCurrent = false, + this.showControls = true, + }); @override - Widget build(BuildContext context, WidgetRef ref) { - final controller = useState(null); - final lastVideoPosition = useRef(-1); - final isBuffering = useRef(false); + ConsumerState createState() => _NativeVideoViewerState(); +} - // Used to track whether the video should play when the app - // is brought back to the foreground - final shouldPlayOnForeground = useRef(true); +class _NativeVideoViewerState extends ConsumerState with WidgetsBindingObserver { + static final _log = Logger('NativeVideoViewer'); - // When a video is opened through the timeline, `isCurrent` will immediately be true. - // When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B. - // If the swipe is completed, `isCurrent` will be true for video B after a delay. - // If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play. - final currentAsset = useState(ref.read(currentAssetNotifier)); - final isCurrent = _isCurrentAsset(asset, currentAsset.value); + NativeVideoPlayerController? _controller; + late final Future _videoSource; + Timer? _loadTimer; + bool _isVideoReady = false; + bool _shouldPlayOnForeground = true; - // Used to show the placeholder during hero animations for remote videos to avoid a stutter - final isVisible = useState(Platform.isIOS && asset.hasLocal); + VideoPlayerNotifier get _notifier => ref.read(videoPlayerProvider(widget.asset.heroTag).notifier); - final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); - - Future createSource() async { - if (!context.mounted) { - return null; - } - - final videoAsset = await ref.read(assetServiceProvider).getAsset(asset) ?? asset; - if (!context.mounted) { - return null; - } - - try { - if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) { - final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!; - final file = await StorageRepository().getFileForAsset(id); - if (!context.mounted) { - return null; - } - - if (file == null) { - throw Exception('No file found for the video'); - } - - // Pass a file:// URI so Android's Uri.parse doesn't - // interpret characters like '#' as fragment identifiers. - final source = await VideoSource.init( - path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path, - type: VideoSourceType.file, - ); - return source; - } - - final remoteId = (videoAsset as RemoteAsset).id; - - // Use a network URL for the video player controller - final serverEndpoint = Store.get(StoreKey.serverEndpoint); - final isOriginalVideo = ref.read(settingsProvider).get(Setting.loadOriginalVideo); - final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback'; - final String videoUrl = videoAsset.livePhotoVideoId != null - ? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl' - : '$serverEndpoint/assets/$remoteId/$postfixUrl'; - - final source = await VideoSource.init( - path: videoUrl, - type: VideoSourceType.network, - headers: ApiService.getRequestHeaders(), - ); - return source; - } catch (error) { - log.severe('Error creating video source for asset ${videoAsset.name}: $error'); - return null; - } - } - - final videoSource = useMemoized>(() => createSource()); - final aspectRatio = useState(null); - useMemoized(() async { - if (!context.mounted || aspectRatio.value != null) { - return null; - } - - try { - aspectRatio.value = await ref.read(assetServiceProvider).getAspectRatio(asset); - } catch (error) { - log.severe('Error getting aspect ratio for asset ${asset.name}: $error'); - } - }, [asset.heroTag]); - - void checkIfBuffering() { - if (!context.mounted) { - return; - } - - final videoPlayback = ref.read(videoPlaybackValueProvider); - if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) && - videoPlayback.state != VideoPlaybackState.buffering) { - ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback.copyWith( - state: VideoPlaybackState.buffering, - ); - } - } - - // Timer to mark videos as buffering if the position does not change - useInterval(const Duration(seconds: 5), checkIfBuffering); - - // When the position changes, seek to the position - // Debounce the seek to avoid seeking too often - // But also don't delay the seek too much to maintain visual feedback - final seekDebouncer = useDebouncer( - interval: const Duration(milliseconds: 100), - maxWaitTime: const Duration(milliseconds: 200), - ); - ref.listen(videoPlayerControlsProvider, (oldControls, newControls) { - final playerController = controller.value; - if (playerController == null) { - return; - } - - final playbackInfo = playerController.playbackInfo; - if (playbackInfo == null) { - return; - } - - final oldSeek = oldControls?.position.inMilliseconds; - final newSeek = newControls.position.inMilliseconds; - if (oldSeek != newSeek || newControls.restarted) { - seekDebouncer.run(() => playerController.seekTo(newSeek)); - } - - if (oldControls?.pause != newControls.pause || newControls.restarted) { - unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause)); - } - }); - - void onPlaybackReady() async { - final videoController = controller.value; - if (videoController == null || !isCurrent || !context.mounted) { - return; - } - - final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); - ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; - - if (ref.read(assetViewerProvider.select((s) => s.showingDetails))) { - return; - } - - try { - final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo); - if (autoPlayVideo) { - await videoController.play(); - } - await videoController.setVolume(0.9); - } catch (error) { - log.severe('Error playing video: $error'); - } - } - - void onPlaybackStatusChanged() { - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } - - final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); - if (videoPlayback.state == VideoPlaybackState.playing) { - // Sync with the controls playing - WakelockPlus.enable(); - } else { - // Sync with the controls pause - WakelockPlus.disable(); - } - - ref.read(videoPlaybackValueProvider.notifier).status = videoPlayback.state; - } - - void onPlaybackPositionChanged() { - // When seeking, these events sometimes move the slider to an older position - if (seekDebouncer.isActive) { - return; - } - - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } - - final playbackInfo = videoController.playbackInfo; - if (playbackInfo == null) { - return; - } - - ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position); - - // Check if the video is buffering - if (playbackInfo.status == PlaybackStatus.playing) { - isBuffering.value = lastVideoPosition.value == playbackInfo.position; - lastVideoPosition.value = playbackInfo.position; - } else { - isBuffering.value = false; - lastVideoPosition.value = -1; - } - } - - void onPlaybackEnded() { - final videoController = controller.value; - if (videoController == null || !context.mounted) { - return; - } - - if (videoController.playbackInfo?.status == PlaybackStatus.stopped) { - ref.read(isPlayingMotionVideoProvider.notifier).playing = false; - } - } - - void removeListeners(NativeVideoPlayerController controller) { - controller.onPlaybackPositionChanged.removeListener(onPlaybackPositionChanged); - controller.onPlaybackStatusChanged.removeListener(onPlaybackStatusChanged); - controller.onPlaybackReady.removeListener(onPlaybackReady); - controller.onPlaybackEnded.removeListener(onPlaybackEnded); - } - - void initController(NativeVideoPlayerController nc) async { - if (controller.value != null || !context.mounted) { - return; - } - ref.read(videoPlayerControlsProvider.notifier).reset(); - ref.read(videoPlaybackValueProvider.notifier).reset(); - - final source = await videoSource; - if (source == null || !context.mounted) { - return; - } - - nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); - nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged); - nc.onPlaybackReady.addListener(onPlaybackReady); - nc.onPlaybackEnded.addListener(onPlaybackEnded); - - unawaited( - nc.loadVideoSource(source).catchError((error) { - log.severe('Error loading video source: $error'); - }), - ); - final loopVideo = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.loopVideo); - unawaited(nc.setLoop(!asset.isMotionPhoto && loopVideo)); - - controller.value = nc; - Timer(const Duration(milliseconds: 200), checkIfBuffering); - } - - ref.listen(currentAssetNotifier, (_, value) { - final playerController = controller.value; - if (playerController != null && value != asset) { - removeListeners(playerController); - } - - if (value != null) { - isVisible.value = _isCurrentAsset(value, asset); - } - final curAsset = currentAsset.value; - if (curAsset == asset) { - return; - } - - final imageToVideo = curAsset != null && !curAsset.isVideo; - - // No need to delay video playback when swiping from an image to a video - if (imageToVideo && Platform.isIOS) { - currentAsset.value = value; - onPlaybackReady(); - return; - } - - // Delay the video playback to avoid a stutter in the swipe animation - // Note, in some circumstances a longer delay is needed (eg: memories), - // the playbackDelayFactor can be used for this - // This delay seems like a hacky way to resolve underlying bugs in video - // playback, but other resolutions failed thus far - Timer( - Platform.isIOS - ? Duration(milliseconds: 300 * playbackDelayFactor) - : imageToVideo - ? Duration(milliseconds: 200 * playbackDelayFactor) - : Duration(milliseconds: 400 * playbackDelayFactor), - () { - if (!context.mounted) { - return; - } - - currentAsset.value = value; - if (currentAsset.value == asset) { - onPlaybackReady(); - } - }, - ); - }); - - useEffect(() { - // If opening a remote video from a hero animation, delay visibility to avoid a stutter - final timer = isVisible.value ? null : Timer(const Duration(milliseconds: 300), () => isVisible.value = true); - - return () { - timer?.cancel(); - final playerController = controller.value; - if (playerController == null) { - return; - } - removeListeners(playerController); - playerController.stop().catchError((error) { - log.fine('Error stopping video: $error'); - }); - - WakelockPlus.disable(); - }; - }, const []); - - useOnAppLifecycleStateChange((_, state) async { - if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) { - await controller.value?.play(); - } else if (state == AppLifecycleState.paused) { - final videoPlaying = await controller.value?.isPlaying(); - if (videoPlaying ?? true) { - shouldPlayOnForeground.value = true; - await controller.value?.pause(); - } else { - shouldPlayOnForeground.value = false; - } - } - }); - - return Stack( - children: [ - // This remains under the video to avoid flickering - // For motion videos, this is the image portion of the asset - Center(child: image), - if (aspectRatio.value != null && !isCasting) - Visibility.maintain( - visible: isVisible.value, - child: NativeVideoPlayerView(onViewReady: initController), - ), - const Center(child: VideoViewerControls()), - ], - ); + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _videoSource = _createSource(); } - Future _onPauseChange( - BuildContext context, - NativeVideoPlayerController controller, - Debouncer seekDebouncer, - bool isPaused, - ) async { - if (!context.mounted) { + @override + void didUpdateWidget(NativeVideoViewer oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.isCurrent == oldWidget.isCurrent || _controller == null) return; + + if (!widget.isCurrent) { + _loadTimer?.cancel(); + _notifier.pause(); return; } - // Make sure the last seek is complete before pausing or playing - // Otherwise, `onPlaybackPositionChanged` can receive outdated events - if (seekDebouncer.isActive) { - await seekDebouncer.drain(); - } + // Prevent unnecessary loading when swiping between assets. + _loadTimer = Timer(const Duration(milliseconds: 200), _loadVideo); + } - try { - if (isPaused) { - await controller.pause(); - } else { - await controller.play(); - } - } catch (error) { - log.severe('Error pausing or playing video: $error'); + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _loadTimer?.cancel(); + _removeListeners(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) async { + switch (state) { + case AppLifecycleState.resumed: + if (_shouldPlayOnForeground) await _notifier.play(); + case AppLifecycleState.paused: + _shouldPlayOnForeground = await _controller?.isPlaying() ?? true; + if (_shouldPlayOnForeground) await _notifier.pause(); + default: } } + + Future _createSource() async { + if (!mounted) return null; + + final videoAsset = await ref.read(assetServiceProvider).getAsset(widget.asset) ?? widget.asset; + if (!mounted) return null; + + try { + if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) { + final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!; + final file = await StorageRepository().getFileForAsset(id); + if (!mounted) return null; + + if (file == null) { + throw Exception('No file found for the video'); + } + + // Pass a file:// URI so Android's Uri.parse doesn't + // interpret characters like '#' as fragment identifiers. + return VideoSource.init( + path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path, + type: VideoSourceType.file, + ); + } + + final remoteId = (videoAsset as RemoteAsset).id; + + final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final isOriginalVideo = ref.read(settingsProvider).get(Setting.loadOriginalVideo); + final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback'; + final String videoUrl = videoAsset.livePhotoVideoId != null + ? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl' + : '$serverEndpoint/assets/$remoteId/$postfixUrl'; + + return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders()); + } catch (error) { + _log.severe('Error creating video source for asset ${videoAsset.name}: $error'); + return null; + } + } + + void _onPlaybackReady() async { + if (!mounted || !widget.isCurrent) return; + + _notifier.onNativePlaybackReady(); + + // onPlaybackReady may be called multiple times, usually when more data + // loads. If this is not the first time that the player has become ready, we + // should not autoplay. + if (_isVideoReady) return; + + setState(() => _isVideoReady = true); + + if (ref.read(assetViewerProvider).showingDetails) return; + + final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo); + if (autoPlayVideo) await _notifier.play(); + } + + void _onPlaybackEnded() { + if (!mounted) return; + + _notifier.onNativePlaybackEnded(); + + if (_controller?.playbackInfo?.status == PlaybackStatus.stopped) { + ref.read(isPlayingMotionVideoProvider.notifier).playing = false; + } + } + + void _onPlaybackPositionChanged() { + if (!mounted) return; + _notifier.onNativePositionChanged(); + } + + void _onPlaybackStatusChanged() { + if (!mounted) return; + _notifier.onNativeStatusChanged(); + } + + void _removeListeners() { + _controller?.onPlaybackPositionChanged.removeListener(_onPlaybackPositionChanged); + _controller?.onPlaybackStatusChanged.removeListener(_onPlaybackStatusChanged); + _controller?.onPlaybackReady.removeListener(_onPlaybackReady); + _controller?.onPlaybackEnded.removeListener(_onPlaybackEnded); + } + + void _loadVideo() async { + final nc = _controller; + if (nc == null || nc.videoSource != null || !mounted) return; + + final source = await _videoSource; + if (source == null || !mounted) return; + + unawaited( + nc.loadVideoSource(source).catchError((error) { + _log.severe('Error loading video source: $error'); + }), + ); + final loopVideo = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.loopVideo); + await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo); + await _notifier.setVolume(1); + } + + void _initController(NativeVideoPlayerController nc) { + if (_controller != null || !mounted) return; + + _notifier.attachController(nc); + + nc.onPlaybackPositionChanged.addListener(_onPlaybackPositionChanged); + nc.onPlaybackStatusChanged.addListener(_onPlaybackStatusChanged); + nc.onPlaybackReady.addListener(_onPlaybackReady); + nc.onPlaybackEnded.addListener(_onPlaybackEnded); + + _controller = nc; + + if (widget.isCurrent) _loadVideo(); + } + + @override + Widget build(BuildContext context) { + // Prevent the provider from being disposed whilst the widget is alive. + ref.listen(videoPlayerProvider(widget.asset.heroTag), (_, __) {}); + + final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); + + return Stack( + children: [ + Center(child: widget.image), + if (!isCasting) + Visibility.maintain( + visible: _isVideoReady, + child: NativeVideoPlayerView(onViewReady: _initController), + ), + if (widget.showControls) Center(child: VideoViewerControls(asset: widget.asset)), + ], + ); + } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart index 28cfe5e73c..e079f666ec 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart @@ -1,29 +1,26 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/models/cast/cast_manager_state.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/utils/hooks/timer_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; class VideoViewerControls extends HookConsumerWidget { + final BaseAsset asset; final Duration hideTimerDuration; - const VideoViewerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)}); + const VideoViewerControls({super.key, required this.asset, this.hideTimerDuration = const Duration(seconds: 5)}); @override Widget build(BuildContext context, WidgetRef ref) { - final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo)); - bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); - final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); - if (showingDetails) { - showControls = false; - } - final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state)); + final videoPlayerName = asset.heroTag; + final assetIsVideo = asset.isVideo; + final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls && !s.showingDetails)); + final status = ref.watch(videoPlayerProvider(videoPlayerName).select((value) => value.status)); final cast = ref.watch(castProvider); @@ -32,14 +29,14 @@ class VideoViewerControls extends HookConsumerWidget { if (!context.mounted) { return; } - final state = ref.read(videoPlaybackValueProvider).state; + final status = ref.read(videoPlayerProvider(videoPlayerName)).status; // Do not hide on paused - if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) { + if (status != VideoPlaybackStatus.paused && status != VideoPlaybackStatus.completed && assetIsVideo) { ref.read(assetViewerProvider.notifier).setControls(false); } }); - final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting; + final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting; /// Shows the controls and starts the timer to hide them void showControlsAndStartHideTimer() { @@ -47,9 +44,11 @@ class VideoViewerControls extends HookConsumerWidget { ref.read(assetViewerProvider.notifier).setControls(true); } - // When we change position, show or hide timer - ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) { - showControlsAndStartHideTimer(); + // When playback starts, reset the hide timer + ref.listen(videoPlayerProvider(videoPlayerName).select((v) => v.status), (previous, next) { + if (next == VideoPlaybackStatus.playing) { + hideTimer.reset(); + } }); /// Toggles between playing and pausing depending on the state of the video @@ -57,34 +56,30 @@ class VideoViewerControls extends HookConsumerWidget { showControlsAndStartHideTimer(); if (cast.isCasting) { - if (cast.castState == CastState.playing) { - ref.read(castProvider.notifier).pause(); - } else if (cast.castState == CastState.paused) { - ref.read(castProvider.notifier).play(); - } else if (cast.castState == CastState.idle) { - // resend the play command since its finished - final asset = ref.read(currentAssetNotifier); - if (asset == null) { - return; - } - // ref.read(castProvider.notifier).loadMedia(asset, true); + switch (cast.castState) { + case CastState.playing: + ref.read(castProvider.notifier).pause(); + case CastState.paused: + ref.read(castProvider.notifier).play(); + default: } return; } - if (state == VideoPlaybackState.playing) { - ref.read(videoPlayerControlsProvider.notifier).pause(); - } else if (state == VideoPlaybackState.completed) { - ref.read(videoPlayerControlsProvider.notifier).restart(); - } else { - ref.read(videoPlayerControlsProvider.notifier).play(); + final notifier = ref.read(videoPlayerProvider(videoPlayerName).notifier); + switch (status) { + case VideoPlaybackStatus.playing: + notifier.pause(); + case VideoPlaybackStatus.completed: + notifier.restart(); + default: + notifier.play(); } } void toggleControlsVisibility() { - if (showBuffering) { - return; - } + if (showBuffering) return; + if (showControls) { ref.read(assetViewerProvider.notifier).setControls(false); } else { @@ -105,9 +100,9 @@ class VideoViewerControls extends HookConsumerWidget { CenterPlayButton( backgroundColor: Colors.black54, iconColor: Colors.white, - isFinished: state == VideoPlaybackState.completed, + isFinished: status == VideoPlaybackStatus.completed, isPlaying: - state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing), + status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing), show: assetIsVideo && showControls, onPressed: togglePlay, ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart index aa3b8bb93f..1c0b600843 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; class ViewerBottomAppBar extends ConsumerWidget { @@ -9,24 +8,12 @@ class ViewerBottomAppBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); - final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); - - if (!showControls) { - opacity = 0.0; - } + final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0); return IgnorePointer( ignoring: opacity < 1.0, - child: AnimatedOpacity( - opacity: opacity, - duration: Durations.short2, - child: const Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [AssetStackRow(), ViewerBottomBar()], - ), - ), + child: AnimatedOpacity(opacity: opacity, duration: Durations.short2, child: const ViewerBottomBar()), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index fb25e9e1cb..78b2e50da5 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; @@ -21,7 +21,7 @@ class ViewerKebabMenu extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); + final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); if (asset == null) { return const SizedBox.shrink(); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index 4b748abc27..4ba4152a8d 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -8,10 +8,9 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; @@ -22,7 +21,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); + final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); if (asset == null) { return const SizedBox.shrink(); } @@ -35,16 +34,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); - double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); - final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) { ref.watch(albumActivityProvider(album.id, asset.id)); } - if (!showControls) { - opacity = 0.0; - } + final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0); final originalTheme = context.themeData; diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index d6485ae7b6..3593fc75e8 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; diff --git a/mobile/lib/presentation/widgets/memory/memory_card.widget.dart b/mobile/lib/presentation/widgets/memory/memory_card.widget.dart index 7758944d37..3df9c8074e 100644 --- a/mobile/lib/presentation/widgets/memory/memory_card.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_card.widget.dart @@ -13,12 +13,14 @@ class DriftMemoryCard extends StatelessWidget { final RemoteAsset asset; final String title; final bool showTitle; + final bool isCurrent; final Function()? onVideoEnded; const DriftMemoryCard({ required this.asset, required this.title, required this.showTitle, + this.isCurrent = false, this.onVideoEnded, super.key, }); @@ -37,32 +39,35 @@ class DriftMemoryCard extends StatelessWidget { SizedBox.expand(child: _BlurredBackdrop(asset: asset)), LayoutBuilder( builder: (context, constraints) { + final r = asset.width != null && asset.height != null + ? asset.width! / asset.height! + : constraints.maxWidth / constraints.maxHeight; + // Determine the fit using the aspect ratio BoxFit fit = BoxFit.contain; if (asset.width != null && asset.height != null) { - final aspectRatio = asset.width! / asset.height!; final phoneAspectRatio = constraints.maxWidth / constraints.maxHeight; // Look for a 25% difference in either direction - if (phoneAspectRatio * .75 < aspectRatio && phoneAspectRatio * 1.25 > aspectRatio) { + if (phoneAspectRatio * .75 < r && phoneAspectRatio * 1.25 > r) { // Cover to look nice if we have nearly the same aspect ratio fit = BoxFit.cover; } } - if (asset.isImage) { - return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity)); - } else { - return SizedBox( - width: context.width, - height: context.height, + if (asset.isImage) return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity)); + + return Center( + child: AspectRatio( + aspectRatio: r, child: NativeVideoViewer( key: ValueKey(asset.id), asset: asset, - playbackDelayFactor: 2, - image: FullImage(asset, size: Size(context.width, context.height), fit: BoxFit.contain), + isCurrent: isCurrent, + showControls: false, + image: FullImage(asset, size: context.sizeData, fit: BoxFit.contain), ), - ); - } + ), + ); }, ), if (showTitle) diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart similarity index 79% rename from mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart rename to mobile/lib/providers/asset_viewer/asset_viewer.provider.dart index dc510d6017..785dfd1e4c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart +++ b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart @@ -1,5 +1,7 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; class AssetViewerState { @@ -68,6 +70,12 @@ class AssetViewerState { class AssetViewerStateNotifier extends Notifier { @override AssetViewerState build() { + ref.listen(_watchedCurrentAssetProvider, (_, next) { + final updated = next.valueOrNull; + if (updated != null) { + state = state.copyWith(currentAsset: updated); + } + }); return const AssetViewerState(); } @@ -75,10 +83,8 @@ class AssetViewerStateNotifier extends Notifier { state = const AssetViewerState(); } - void setAsset(BaseAsset? asset) { - if (asset == state.currentAsset) { - return; - } + void setAsset(BaseAsset asset) { + if (asset == state.currentAsset) return; state = state.copyWith(currentAsset: asset, stackIndex: 0); } @@ -95,7 +101,10 @@ class AssetViewerStateNotifier extends Notifier { } state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls); if (showing) { - ref.read(videoPlayerControlsProvider.notifier).pause(); + final heroTag = state.currentAsset?.heroTag; + if (heroTag != null) { + ref.read(videoPlayerProvider(heroTag).notifier).pause(); + } } } @@ -126,3 +135,10 @@ class AssetViewerStateNotifier extends Notifier { } final assetViewerProvider = NotifierProvider(AssetViewerStateNotifier.new); + +final _watchedCurrentAssetProvider = StreamProvider((ref) { + ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag)); + final asset = ref.read(assetViewerProvider).currentAsset; + if (asset == null) return const Stream.empty(); + return ref.read(assetServiceProvider).watchAsset(asset); +}); diff --git a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart deleted file mode 100644 index 44740268db..0000000000 --- a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; - -class VideoPlaybackControls { - const VideoPlaybackControls({required this.position, required this.pause, this.restarted = false}); - - final Duration position; - final bool pause; - final bool restarted; -} - -final videoPlayerControlsProvider = StateNotifierProvider((ref) { - return VideoPlayerControls(ref); -}); - -const videoPlayerControlsDefault = VideoPlaybackControls(position: Duration.zero, pause: false); - -class VideoPlayerControls extends StateNotifier { - VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault); - - final Ref ref; - - VideoPlaybackControls get value => state; - - set value(VideoPlaybackControls value) { - state = value; - } - - void reset() { - state = videoPlayerControlsDefault; - } - - Duration get position => state.position; - bool get paused => state.pause; - - set position(Duration value) { - if (state.position == value) { - return; - } - - state = VideoPlaybackControls(position: value, pause: state.pause); - } - - void pause() { - if (state.pause) { - return; - } - - state = VideoPlaybackControls(position: state.position, pause: true); - } - - void play() { - if (!state.pause) { - return; - } - - state = VideoPlaybackControls(position: state.position, pause: false); - } - - void togglePlay() { - state = VideoPlaybackControls(position: state.position, pause: !state.pause); - } - - void restart() { - state = const VideoPlaybackControls(position: Duration.zero, pause: false, restarted: true); - ref.read(videoPlaybackValueProvider.notifier).value = ref - .read(videoPlaybackValueProvider.notifier) - .value - .copyWith(state: VideoPlaybackState.playing, position: Duration.zero); - } -} diff --git a/mobile/lib/providers/asset_viewer/video_player_provider.dart b/mobile/lib/providers/asset_viewer/video_player_provider.dart new file mode 100644 index 0000000000..0ca3bf4f74 --- /dev/null +++ b/mobile/lib/providers/asset_viewer/video_player_provider.dart @@ -0,0 +1,200 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:native_video_player/native_video_player.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +enum VideoPlaybackStatus { paused, playing, buffering, completed } + +class VideoPlayerState { + final Duration position; + final Duration duration; + final VideoPlaybackStatus status; + + const VideoPlayerState({required this.position, required this.duration, required this.status}); + + VideoPlayerState copyWith({Duration? position, Duration? duration, VideoPlaybackStatus? status}) { + return VideoPlayerState( + position: position ?? this.position, + duration: duration ?? this.duration, + status: status ?? this.status, + ); + } +} + +const _defaultState = VideoPlayerState( + position: Duration.zero, + duration: Duration.zero, + status: VideoPlaybackStatus.paused, +); + +final videoPlayerProvider = StateNotifierProvider.autoDispose.family(( + ref, + name, +) { + return VideoPlayerNotifier(); +}); + +class VideoPlayerNotifier extends StateNotifier { + static final _log = Logger('VideoPlayerNotifier'); + + VideoPlayerNotifier() : super(_defaultState); + + NativeVideoPlayerController? _controller; + Timer? _bufferingTimer; + Timer? _seekTimer; + + void attachController(NativeVideoPlayerController controller) { + _controller = controller; + } + + @override + void dispose() { + _bufferingTimer?.cancel(); + _seekTimer?.cancel(); + WakelockPlus.disable(); + _controller = null; + + super.dispose(); + } + + Future pause() async { + if (_controller == null) return; + + _bufferingTimer?.cancel(); + + try { + await _controller!.pause(); + await _flushSeek(); + } catch (e) { + _log.severe('Error pausing video: $e'); + } + } + + Future play() async { + if (_controller == null) return; + + try { + await _flushSeek(); + await _controller!.play(); + } catch (e) { + _log.severe('Error playing video: $e'); + } + + _startBufferingTimer(); + } + + Future _flushSeek() async { + final timer = _seekTimer; + if (timer == null || !timer.isActive) return; + + timer.cancel(); + await _controller?.seekTo(state.position.inMilliseconds); + } + + void seekTo(Duration position) { + if (_controller == null) return; + + state = state.copyWith(position: position); + + _seekTimer?.cancel(); + _seekTimer = Timer(const Duration(milliseconds: 100), () { + _controller?.seekTo(position.inMilliseconds); + }); + } + + Future restart() async { + seekTo(Duration.zero); + await play(); + } + + Future setVolume(double volume) async { + try { + await _controller?.setVolume(volume); + } catch (e) { + _log.severe('Error setting volume: $e'); + } + } + + Future setLoop(bool loop) async { + try { + await _controller?.setLoop(loop); + } catch (e) { + _log.severe('Error setting loop: $e'); + } + } + + void onNativePlaybackReady() { + if (!mounted) return; + + final playbackInfo = _controller?.playbackInfo; + final videoInfo = _controller?.videoInfo; + + if (playbackInfo == null || videoInfo == null) return; + + state = state.copyWith( + position: Duration(milliseconds: playbackInfo.position), + duration: Duration(milliseconds: videoInfo.duration), + status: _mapStatus(playbackInfo.status), + ); + } + + void onNativePositionChanged() { + if (!mounted || (_seekTimer?.isActive ?? false)) return; + + final playbackInfo = _controller?.playbackInfo; + if (playbackInfo == null) return; + + final position = Duration(milliseconds: playbackInfo.position); + if (state.position == position) return; + + if (state.status == VideoPlaybackStatus.buffering) { + state = state.copyWith(position: position, status: VideoPlaybackStatus.playing); + } else { + state = state.copyWith(position: position); + } + + _startBufferingTimer(); + } + + void onNativeStatusChanged() { + if (!mounted) return; + + final playbackInfo = _controller?.playbackInfo; + if (playbackInfo == null) return; + + final newStatus = _mapStatus(playbackInfo.status); + switch (newStatus) { + case VideoPlaybackStatus.playing: + WakelockPlus.enable(); + _startBufferingTimer(); + default: + onNativePlaybackEnded(); + } + + if (state.status != newStatus) { + state = state.copyWith(status: newStatus); + } + } + + void onNativePlaybackEnded() { + WakelockPlus.disable(); + _bufferingTimer?.cancel(); + } + + void _startBufferingTimer() { + _bufferingTimer?.cancel(); + _bufferingTimer = Timer(const Duration(seconds: 3), () { + if (mounted && state.status == VideoPlaybackStatus.playing) { + state = state.copyWith(status: VideoPlaybackStatus.buffering); + } + }); + } + + static VideoPlaybackStatus _mapStatus(PlaybackStatus status) => switch (status) { + PlaybackStatus.playing => VideoPlaybackStatus.playing, + PlaybackStatus.paused => VideoPlaybackStatus.paused, + PlaybackStatus.stopped => VideoPlaybackStatus.completed, + }; +} diff --git a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart deleted file mode 100644 index 31b0f4656e..0000000000 --- a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:native_video_player/native_video_player.dart'; - -enum VideoPlaybackState { initializing, paused, playing, buffering, completed } - -class VideoPlaybackValue { - /// The current position of the video - final Duration position; - - /// The total duration of the video - final Duration duration; - - /// The current state of the video playback - final VideoPlaybackState state; - - /// The volume of the video - final double volume; - - const VideoPlaybackValue({required this.position, required this.duration, required this.state, required this.volume}); - - factory VideoPlaybackValue.fromNativeController(NativeVideoPlayerController controller) { - final playbackInfo = controller.playbackInfo; - final videoInfo = controller.videoInfo; - - if (playbackInfo == null || videoInfo == null) { - return videoPlaybackValueDefault; - } - - final VideoPlaybackState status = switch (playbackInfo.status) { - PlaybackStatus.playing => VideoPlaybackState.playing, - PlaybackStatus.paused => VideoPlaybackState.paused, - PlaybackStatus.stopped => VideoPlaybackState.completed, - }; - - return VideoPlaybackValue( - position: Duration(milliseconds: playbackInfo.position), - duration: Duration(milliseconds: videoInfo.duration), - state: status, - volume: playbackInfo.volume, - ); - } - - VideoPlaybackValue copyWith({Duration? position, Duration? duration, VideoPlaybackState? state, double? volume}) { - return VideoPlaybackValue( - position: position ?? this.position, - duration: duration ?? this.duration, - state: state ?? this.state, - volume: volume ?? this.volume, - ); - } -} - -const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue( - position: Duration.zero, - duration: Duration.zero, - state: VideoPlaybackState.initializing, - volume: 0.0, -); - -final videoPlaybackValueProvider = StateNotifierProvider((ref) { - return VideoPlaybackValueState(ref); -}); - -class VideoPlaybackValueState extends StateNotifier { - VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault); - - final Ref ref; - - VideoPlaybackValue get value => state; - - set value(VideoPlaybackValue value) { - state = value; - } - - set position(Duration value) { - if (state.position == value) return; - state = VideoPlaybackValue(position: value, duration: state.duration, state: state.state, volume: state.volume); - } - - set status(VideoPlaybackState value) { - if (state.state == value) return; - state = VideoPlaybackValue(position: state.position, duration: state.duration, state: value, volume: state.volume); - } - - void reset() { - state = videoPlaybackValueDefault; - } -} diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index c06bcabf26..cd75af6354 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -8,9 +8,9 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -123,7 +123,7 @@ class ActionNotifier extends Notifier { Set _getAssets(ActionSource source) { return switch (source) { ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets, - ActionSource.viewer => switch (ref.read(currentAssetNotifier)) { + ActionSource.viewer => switch (ref.read(assetViewerProvider).currentAsset) { BaseAsset asset => {asset}, null => const {}, }, @@ -307,7 +307,10 @@ class ActionNotifier extends Notifier { // does not update the currentAsset which means // the exif provider will not be refreshed automatically if (source == ActionSource.viewer) { - ref.invalidate(currentAssetExifProvider); + final currentAsset = ref.read(assetViewerProvider).currentAsset; + if (currentAsset != null) { + ref.invalidate(assetExifProvider(currentAsset)); + } } return ActionResult(count: ids.length, success: true); @@ -409,7 +412,6 @@ class ActionNotifier extends Notifier { if (source == ActionSource.viewer) { final updatedParent = await _assetService.getRemoteAsset(assets.first.id); if (updatedParent != null) { - ref.read(currentAssetNotifier.notifier).setAsset(updatedParent); ref.read(assetViewerProvider.notifier).setAsset(updatedParent); } } diff --git a/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart b/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart index 5718333759..82ab69b994 100644 --- a/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart @@ -1,52 +1,8 @@ -import 'dart:async'; - import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -final currentAssetNotifier = AutoDisposeNotifierProvider(CurrentAssetNotifier.new); - -class CurrentAssetNotifier extends AutoDisposeNotifier { - KeepAliveLink? _keepAliveLink; - StreamSubscription? _assetSubscription; - - @override - BaseAsset? build() => null; - - void setAsset(BaseAsset asset) { - _keepAliveLink?.close(); - _assetSubscription?.cancel(); - state = asset; - _assetSubscription = ref.watch(assetServiceProvider).watchAsset(asset).listen((updatedAsset) { - if (updatedAsset != null) { - state = updatedAsset; - } - }); - _keepAliveLink = ref.keepAlive(); - } - - void dispose() { - _keepAliveLink?.close(); - _assetSubscription?.cancel(); - } -} - -class ScopedAssetNotifier extends CurrentAssetNotifier { - final BaseAsset _asset; - - ScopedAssetNotifier(this._asset); - - @override - BaseAsset? build() { - setAsset(_asset); - return _asset; - } -} - -final currentAssetExifProvider = FutureProvider.autoDispose((ref) { - final currentAsset = ref.watch(currentAssetNotifier); - if (currentAsset == null) { - return null; - } - return ref.watch(assetServiceProvider).getExif(currentAsset); +final assetExifProvider = FutureProvider.autoDispose.family((ref, asset) { + return ref.watch(assetServiceProvider).getExif(asset); }); diff --git a/mobile/lib/utils/hooks/interval_hook.dart b/mobile/lib/utils/hooks/interval_hook.dart deleted file mode 100644 index 907fbad102..0000000000 --- a/mobile/lib/utils/hooks/interval_hook.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'dart:async'; -import 'dart:ui'; - -import 'package:flutter_hooks/flutter_hooks.dart'; - -// https://github.com/rrousselGit/flutter_hooks/issues/233#issuecomment-840416638 -void useInterval(Duration delay, VoidCallback callback) { - final savedCallback = useRef(callback); - savedCallback.value = callback; - - useEffect(() { - final timer = Timer.periodic(delay, (_) => savedCallback.value()); - return timer.cancel; - }, [delay]); -} diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 5707e3678f..22a7deffff 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -333,7 +333,7 @@ class BottomGalleryBar extends ConsumerWidget { padding: const EdgeInsets.only(top: 40.0), child: Column( children: [ - if (asset.isVideo) const VideoControls(), + if (asset.isVideo) VideoControls(videoPlayerName: asset.id.toString()), BottomNavigationBar( elevation: 0.0, backgroundColor: Colors.transparent, diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart index 0e766c77b9..09c0e9d091 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -3,23 +3,27 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/cast/cast_manager_state.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/utils/hooks/timer_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; class CustomVideoPlayerControls extends HookConsumerWidget { + final String videoId; final Duration hideTimerDuration; - const CustomVideoPlayerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)}); + const CustomVideoPlayerControls({ + super.key, + required this.videoId, + this.hideTimerDuration = const Duration(seconds: 5), + }); @override Widget build(BuildContext context, WidgetRef ref) { final assetIsVideo = ref.watch(currentAssetProvider.select((asset) => asset != null && asset.isVideo)); final showControls = ref.watch(showControlsProvider); - final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state)); + final status = ref.watch(videoPlayerProvider(videoId).select((value) => value.status)); final cast = ref.watch(castProvider); @@ -28,14 +32,14 @@ class CustomVideoPlayerControls extends HookConsumerWidget { if (!context.mounted) { return; } - final state = ref.read(videoPlaybackValueProvider).state; + final s = ref.read(videoPlayerProvider(videoId)).status; // Do not hide on paused - if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) { + if (s != VideoPlaybackStatus.paused && s != VideoPlaybackStatus.completed && assetIsVideo) { ref.read(showControlsProvider.notifier).show = false; } }); - final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting; + final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting; /// Shows the controls and starts the timer to hide them void showControlsAndStartHideTimer() { @@ -43,9 +47,11 @@ class CustomVideoPlayerControls extends HookConsumerWidget { ref.read(showControlsProvider.notifier).show = true; } - // When we change position, show or hide timer - ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) { - showControlsAndStartHideTimer(); + // When playback starts, reset the hide timer + ref.listen(videoPlayerProvider(videoId).select((v) => v.status), (previous, next) { + if (next == VideoPlaybackStatus.playing) { + hideTimer.reset(); + } }); /// Toggles between playing and pausing depending on the state of the video @@ -68,12 +74,13 @@ class CustomVideoPlayerControls extends HookConsumerWidget { return; } - if (state == VideoPlaybackState.playing) { - ref.read(videoPlayerControlsProvider.notifier).pause(); - } else if (state == VideoPlaybackState.completed) { - ref.read(videoPlayerControlsProvider.notifier).restart(); + final notifier = ref.read(videoPlayerProvider(videoId).notifier); + if (status == VideoPlaybackStatus.playing) { + notifier.pause(); + } else if (status == VideoPlaybackStatus.completed) { + notifier.restart(); } else { - ref.read(videoPlayerControlsProvider.notifier).play(); + notifier.play(); } } @@ -92,9 +99,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget { child: CenterPlayButton( backgroundColor: Colors.black54, iconColor: Colors.white, - isFinished: state == VideoPlaybackState.completed, + isFinished: status == VideoPlaybackStatus.completed, isPlaying: - state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing), + status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing), show: assetIsVideo && showControls, onPressed: togglePlay, ), diff --git a/mobile/lib/widgets/asset_viewer/video_controls.dart b/mobile/lib/widgets/asset_viewer/video_controls.dart index 42f6078478..381388d8d2 100644 --- a/mobile/lib/widgets/asset_viewer/video_controls.dart +++ b/mobile/lib/widgets/asset_viewer/video_controls.dart @@ -3,15 +3,20 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/widgets/asset_viewer/video_position.dart'; -/// The video controls for the [videoPlayerControlsProvider] +/// The video controls for the [videoPlayerProvider] class VideoControls extends ConsumerWidget { - const VideoControls({super.key}); + final String videoPlayerName; + + const VideoControls({super.key, required this.videoPlayerName}); @override Widget build(BuildContext context, WidgetRef ref) { final isPortrait = context.orientation == Orientation.portrait; return isPortrait - ? const VideoPosition() - : const Padding(padding: EdgeInsets.symmetric(horizontal: 60.0), child: VideoPosition()); + ? VideoPosition(videoPlayerName: videoPlayerName) + : Padding( + padding: const EdgeInsets.symmetric(horizontal: 60.0), + child: VideoPosition(videoPlayerName: videoPlayerName), + ); } } diff --git a/mobile/lib/widgets/asset_viewer/video_position.dart b/mobile/lib/widgets/asset_viewer/video_position.dart index 9d9e2821ad..cbcbdb88e7 100644 --- a/mobile/lib/widgets/asset_viewer/video_position.dart +++ b/mobile/lib/widgets/asset_viewer/video_position.dart @@ -4,13 +4,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/colors.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart'; class VideoPosition extends HookConsumerWidget { - const VideoPosition({super.key}); + final String videoPlayerName; + + const VideoPosition({super.key, required this.videoPlayerName}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -18,7 +19,7 @@ class VideoPosition extends HookConsumerWidget { final (position, duration) = isCasting ? ref.watch(castProvider.select((c) => (c.currentTime, c.duration))) - : ref.watch(videoPlaybackValueProvider.select((v) => (v.position, v.duration))); + : ref.watch(videoPlayerProvider(videoPlayerName).select((v) => (v.position, v.duration))); final wasPlaying = useRef(true); return duration == Duration.zero @@ -44,13 +45,13 @@ class VideoPosition extends HookConsumerWidget { activeColor: Colors.white, inactiveColor: whiteOpacity75, onChangeStart: (value) { - final state = ref.read(videoPlaybackValueProvider).state; - wasPlaying.value = state != VideoPlaybackState.paused; - ref.read(videoPlayerControlsProvider.notifier).pause(); + final status = ref.read(videoPlayerProvider(videoPlayerName)).status; + wasPlaying.value = status != VideoPlaybackStatus.paused; + ref.read(videoPlayerProvider(videoPlayerName).notifier).pause(); }, onChangeEnd: (value) { if (wasPlaying.value) { - ref.read(videoPlayerControlsProvider.notifier).play(); + ref.read(videoPlayerProvider(videoPlayerName).notifier).play(); } }, onChanged: (value) { @@ -61,10 +62,7 @@ class VideoPosition extends HookConsumerWidget { return; } - ref.read(videoPlayerControlsProvider.notifier).position = seekToDuration; - - // This immediately updates the slider position without waiting for the video to update - ref.read(videoPlaybackValueProvider.notifier).position = seekToDuration; + ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekToDuration); }, ), ), diff --git a/mobile/lib/widgets/memories/memory_lane.dart b/mobile/lib/widgets/memories/memory_lane.dart index 727950fd86..4cba83bea7 100644 --- a/mobile/lib/widgets/memories/memory_lane.dart +++ b/mobile/lib/widgets/memories/memory_lane.dart @@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; @@ -34,9 +33,6 @@ class MemoryLane extends HookConsumerWidget { if (memories[memoryIndex].assets.isNotEmpty) { final asset = memories[memoryIndex].assets[0]; ref.read(currentAssetProvider.notifier).set(asset); - if (asset.isVideo || asset.isMotionPhoto) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - } } context.pushRoute(MemoryRoute(memories: memories, memoryIndex: memoryIndex)); }, diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 077544b4f7..28adfc2ab7 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1217,10 +1217,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -1910,10 +1910,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" thumbhash: dependency: "direct main" description: From 8abbbc49cfef1bee9c607048f1144b112aa15382 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:53:01 +0000 Subject: [PATCH 058/150] chore(deps): update dependency opentofu to v1.11.5 (#26655) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- deployment/mise.toml | 2 +- mise.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deployment/mise.toml b/deployment/mise.toml index 070e2f99e9..4f03e27ff7 100644 --- a/deployment/mise.toml +++ b/deployment/mise.toml @@ -1,6 +1,6 @@ [tools] terragrunt = "0.99.4" -opentofu = "1.11.4" +opentofu = "1.11.5" [tasks."tg:fmt"] run = "terragrunt hclfmt" diff --git a/mise.toml b/mise.toml index a6e77ae944..0ec32de20c 100644 --- a/mise.toml +++ b/mise.toml @@ -18,7 +18,7 @@ node = "24.13.1" flutter = "3.35.7" pnpm = "10.30.3" terragrunt = "0.99.4" -opentofu = "1.11.4" +opentofu = "1.11.5" java = "21.0.2" [tools."github:CQLabs/homebrew-dcm"] From 56f14162f6c46443d3de7b414748c2e8a97505d8 Mon Sep 17 00:00:00 2001 From: Savely Krasovsky Date: Wed, 4 Mar 2026 01:54:55 +0100 Subject: [PATCH 059/150] chore: bump base images manually (#26670) --- server/Dockerfile | 4 ++-- server/Dockerfile.dev | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index a8a8b04713..9cc53c1095 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e034fd0060ea68c01854d92fcc9debc6b868b98f888ba7 AS builder +FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS builder ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ COREPACK_HOME=/tmp \ @@ -71,7 +71,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \ --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ cd plugins && mise run build -FROM ghcr.io/immich-app/base-server-prod:202601131104@sha256:c649c5838b6348836d27db6d49cadbbc6157feae7a1a237180c3dec03577ba8f +FROM ghcr.io/immich-app/base-server-prod:202603031112@sha256:bb8c8645ee61977140121e56ba09db7ae656a7506f9a6af1be8461b4d81fdf03 WORKDIR /usr/src/app ENV NODE_ENV=production \ diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index f778c20afb..f64a1a904b 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -1,9 +1,9 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e034fd0060ea68c01854d92fcc9debc6b868b98f888ba7 AS dev +FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS dev ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ - COREPACK_HOME=/tmp \ + COREPACK_HOME=/tmp \ PNPM_HOME=/buildcache/pnpm-store RUN npm install --global corepack@latest && \ From e4c24bdec8c0d0c1f8b28f0841e3a3794fe263f8 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Tue, 3 Mar 2026 22:34:48 -0500 Subject: [PATCH 060/150] chore: enable prettier caching and quiet output (#26681) --- .github/package.json | 4 ++-- cli/package.json | 4 ++-- docs/package.json | 4 ++-- e2e/package.json | 4 ++-- i18n/package.json | 4 ++-- server/package.json | 4 ++-- web/package.json | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/package.json b/.github/package.json index 9b41cc7b4e..6fca2241ca 100644 --- a/.github/package.json +++ b/.github/package.json @@ -1,7 +1,7 @@ { "scripts": { - "format": "prettier --check .", - "format:fix": "prettier --write ." + "format": "prettier --cache --check .", + "format:fix": "prettier --cache --write --list-different ." }, "devDependencies": { "prettier": "^3.7.4" diff --git a/cli/package.json b/cli/package.json index 61059476a4..ad3d1307fd 100644 --- a/cli/package.json +++ b/cli/package.json @@ -49,8 +49,8 @@ "prepack": "pnpm run build", "test": "vitest", "test:cov": "vitest --coverage", - "format": "prettier --check .", - "format:fix": "prettier --write .", + "format": "prettier --cache --check .", + "format:fix": "prettier --cache --write --list-different .", "check": "tsc --noEmit" }, "repository": { diff --git a/docs/package.json b/docs/package.json index 8c270f013b..60a6dccf87 100644 --- a/docs/package.json +++ b/docs/package.json @@ -4,8 +4,8 @@ "private": true, "scripts": { "docusaurus": "docusaurus", - "format": "prettier --check .", - "format:fix": "prettier --write .", + "format": "prettier --cache --check .", + "format:fix": "prettier --cache --write --list-different .", "start": "docusaurus start --port 3005", "copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0", "build": "pnpm run copy:openapi && docusaurus build", diff --git a/e2e/package.json b/e2e/package.json index 82166b069f..640b812165 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -14,8 +14,8 @@ "start:web": "pnpm exec playwright test --ui --project=web", "start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance", "start:web:ui": "pnpm exec playwright test --ui --project=ui", - "format": "prettier --check .", - "format:fix": "prettier --write .", + "format": "prettier --cache --check .", + "format:fix": "prettier --cache --write --list-different .", "lint": "eslint \"src/**/*.ts\" --max-warnings 0", "lint:fix": "pnpm run lint --fix", "check": "tsc --noEmit" diff --git a/i18n/package.json b/i18n/package.json index 47748c28e8..4d4aa7965c 100644 --- a/i18n/package.json +++ b/i18n/package.json @@ -3,8 +3,8 @@ "version": "2.5.6", "private": true, "scripts": { - "format": "prettier --check .", - "format:fix": "prettier --write ." + "format": "prettier --cache --check .", + "format:fix": "prettier --cache --write --list-different ." }, "devDependencies": { "prettier": "^3.7.4", diff --git a/server/package.json b/server/package.json index 3d12e0f6e7..943f630687 100644 --- a/server/package.json +++ b/server/package.json @@ -7,8 +7,8 @@ "license": "GNU Affero General Public License version 3", "scripts": { "build": "nest build", - "format": "prettier --check .", - "format:fix": "prettier --write .", + "format": "prettier --cache --check .", + "format:fix": "prettier --cache --write --list-different .", "start": "pnpm run start:dev", "nest": "nest", "start:dev": "nest start --watch --", diff --git a/web/package.json b/web/package.json index 471a31655d..77e963b82c 100644 --- a/web/package.json +++ b/web/package.json @@ -16,8 +16,8 @@ "check:all": "pnpm run check:code && pnpm run test:cov", "lint": "eslint . --max-warnings 0 --concurrency 4", "lint:fix": "pnpm run lint --fix", - "format": "prettier --check .", - "format:fix": "prettier --write .", + "format": "prettier --cache --check .", + "format:fix": "prettier --cache --write --list-different .", "test": "vitest", "test:cov": "vitest --coverage", "test:watch": "vitest dev", From 5532f669eb1b335a86f6fdf8eac030501d79f826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Maria=20Semprini?= Date: Wed, 4 Mar 2026 03:41:51 +0000 Subject: [PATCH 061/150] feat: improve HEIC, HEIF and JPEG XL browser support detection (#26122) feat: improve heic, heif and jxl browser support detection --- web/src/lib/utils/asset-utils.ts | 53 ++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 73a6965dd9..d6dd37c653 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -224,19 +224,54 @@ const supportedImageMimeTypes = new Set([ 'image/webp', ]); -const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755 -if (isSafari) { - supportedImageMimeTypes.add('image/heic').add('image/heif'); -} +async function addSupportedMimeTypes(): Promise { + const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755 + if (isSafari) { + const match = navigator.userAgent.match(/Version\/(\d+)/); -function checkJxlSupport(): void { - const img = new Image(); - img.addEventListener('load', () => { + if (!match) { + return; + } + + const majorVersion = Number.parseInt(match[1]); + const MIN_REQUIRED_VERSION = 17; + + if (majorVersion >= MIN_REQUIRED_VERSION) { + supportedImageMimeTypes.add('image/jxl').add('image/heic').add('image/heif'); + } + + return; + } + + if (globalThis.isSecureContext && typeof ImageDecoder !== 'undefined') { + const dynamicMimeTypes = [{ type: 'image/jxl' }, { type: 'image/heic', aliases: ['image/heif'] }]; + + for (const mime of dynamicMimeTypes) { + const isMimeTypeSupported = await ImageDecoder.isTypeSupported(mime.type); + if (isMimeTypeSupported) { + for (const mimeType of [mime.type, ...(mime.aliases || [])]) { + supportedImageMimeTypes.add(mimeType); + } + } + } + + return; + } + + const jxlImg = new Image(); + jxlImg.addEventListener('load', () => { supportedImageMimeTypes.add('image/jxl'); }); - img.src = 'data:image/jxl;base64,/woIAAAMABKIAgC4AF3lEgA='; // Small valid JPEG XL image + jxlImg.src = 'data:image/jxl;base64,/woIAAAMABKIAgC4AF3lEgA='; // Small valid JPEG XL image + + const heicImg = new Image(); + heicImg.addEventListener('load', () => { + supportedImageMimeTypes.add('image/heic'); + }); + heicImg.src = + 'data:image/heic;base64,AAAAGGZ0eXBoZWljAAAAAG1pZjFoZWljAAABrW1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAHBpY3QAAAAAAAAAAAAAAAAAAAAADnBpdG0AAAAAAAIAAAAQaWRhdAAAAAAAAQABAAAAOGlsb2MBAAAAREAAAgABAAAAAAAAAc0AAQAAAAAAAAAsAAIAAQAAAAAAAAABAAAAAAAAAAgAAAA4aWluZgAAAAAAAgAAABVpbmZlAgAAAQABAABodmMxAAAAABVpbmZlAgAAAAACAABncmlkAAAAANhpcHJwAAAAtmlwY28AAAB2aHZjQwEDcAAAAAAAAAAAAB7wAPz9+PgAAA8DIAABABhAAQwB//8DcAAAAwCQAAADAAADAB66AkAhAAEAKkIBAQNwAAADAJAAAAMAAAMAHqAggQWW6q6a5uBAQMCAAAADAIAAAAMAhCIAAQAGRAHBc8GJAAAAFGlzcGUAAAAAAAAAAQAAAAEAAAAUaXNwZQAAAAAAAABAAAAAQAAAABBwaXhpAAAAAAMICAgAAAAaaXBtYQAAAAAAAAACAAECgQMAAgIChAAAABppcmVmAAAAAAAAAA5kaW1nAAIAAQABAAAANG1kYXQAAAAoKAGvCchMZYA50NoPIfzz81Qfsm577GJt3lf8kLAr+NbNIoeRR7JeYA=='; // Small valid HEIC/HEIF image } -checkJxlSupport(); +void addSupportedMimeTypes(); /** * Returns true if the asset is an image supported by web browsers, false otherwise From f94e0fbc399c3273616f1d262582189a0fd6d6df Mon Sep 17 00:00:00 2001 From: Paul Makles Date: Wed, 4 Mar 2026 11:16:21 +0000 Subject: [PATCH 062/150] fix(maintenance mode): wait for valid server config on restart (#26456) Signed-off-by: izzy --- web/src/lib/managers/event-manager.svelte.ts | 2 ++ web/src/lib/stores/websocket.ts | 5 +++- web/src/routes/+layout.svelte | 26 +++++++++++--------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index b161356a68..33519fddbb 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -87,6 +87,8 @@ export type Events = { WorkflowDelete: [WorkflowResponseDto]; ReleaseEvent: [ReleaseEvent]; + + WebsocketConnect: []; }; export const eventManager = new BaseEventManager(); diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 204e44f84e..8d86fc9749 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -62,7 +62,10 @@ export const websocketStore = { export const websocketEvents = createEventEmitter(websocket); websocket - .on('connect', () => websocketStore.connected.set(true)) + .on('connect', () => { + eventManager.emit('WebsocketConnect'); + websocketStore.connected.set(true); + }) .on('disconnect', () => websocketStore.connected.set(false)) .on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion)) .on('AppRestartV1', (mode) => websocketStore.serverRestarting.set(mode)) diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index afd7b57609..046d5ce068 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import { shortcut } from '$lib/actions/shortcut'; import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte'; import ErrorLayout from '$lib/components/layouts/ErrorLayout.svelte'; + import OnEvents from '$lib/components/OnEvents.svelte'; import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte'; import UploadPanel from '$lib/components/shared-components/upload-panel.svelte'; import VersionAnnouncement from '$lib/components/VersionAnnouncement.svelte'; @@ -19,6 +20,7 @@ import { copyToClipboard } from '$lib/utils'; import { maintenanceShouldRedirect } from '$lib/utils/maintenance'; import { isAssetViewerRoute } from '$lib/utils/navigation'; + import { getServerConfig } from '@immich/sdk'; import { CommandPaletteDefaultProvider, TooltipProvider, @@ -31,6 +33,7 @@ import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiThemeLightDark } from '@mdi/js'; import { onMount, type Snippet } from 'svelte'; import { t } from 'svelte-i18n'; + import { get } from 'svelte/store'; import '../app.css'; interface Props { @@ -120,20 +123,19 @@ if (maintenanceShouldRedirect(isRestarting.isMaintenanceMode, location)) { modalManager.show(ServerRestartingModal, {}).catch((error) => console.error('Error [ServerRestartBox]:', error)); - - // we will be disconnected momentarily - // wait for reconnect then reload - let waiting = false; - websocketStore.connected.subscribe((connected) => { - if (!connected) { - waiting = true; - } else if (connected && waiting) { - location.reload(); - } - }); } }); + const onWebsocketConnect = async () => { + const isRestarting = get(serverRestarting); + if (isRestarting && maintenanceShouldRedirect(isRestarting.isMaintenanceMode, location)) { + const { maintenanceMode } = await getServerConfig(); + if (maintenanceMode === isRestarting.isMaintenanceMode) { + location.reload(); + } + } + }; + const userCommands: ActionItem[] = [ { title: $t('theme'), @@ -182,6 +184,8 @@ const commands = $derived([...userCommands, ...adminCommands]); + + From 54bc9ddd69980958cee96f70b20027a2e336d4e9 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Wed, 4 Mar 2026 08:20:43 -0500 Subject: [PATCH 063/150] chore: add vitest project names and fix server config root paths (#26684) Add `name` to all vitest configs matching CI job buckets (server:unit, server:medium, cli:unit, web:unit, e2e:server, e2e:maintenance) so they appear as filterable @tags in the Vitest VSCode extension. Fix `root` in server vitest configs to use an absolute path derived from `import.meta.url` instead of `'./'`, which resolved relative to the config file directory (`server/test/`) rather than `server/`, causing test discovery to fail in the Vitest VSCode extension. --- cli/vitest.config.ts | 1 + e2e/vitest.config.ts | 1 + e2e/vitest.maintenance.config.ts | 1 + server/test/vitest.config.medium.mjs | 7 ++++++- server/test/vitest.config.mjs | 7 ++++++- web/vite.config.ts | 1 + 6 files changed, 16 insertions(+), 2 deletions(-) diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts index 7382f40e7d..f444068181 100644 --- a/cli/vitest.config.ts +++ b/cli/vitest.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + name: 'cli:unit', globals: true, }, }); diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index 1312bf9b75..10f3aa3e4f 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -14,6 +14,7 @@ if (!skipDockerSetup) { export default defineConfig({ test: { + name: 'e2e:server', retry: process.env.CI ? 4 : 0, include: ['src/specs/server/**/*.e2e-spec.ts'], globalSetup, diff --git a/e2e/vitest.maintenance.config.ts b/e2e/vitest.maintenance.config.ts index 6bb6721a6d..665b908184 100644 --- a/e2e/vitest.maintenance.config.ts +++ b/e2e/vitest.maintenance.config.ts @@ -14,6 +14,7 @@ if (!skipDockerSetup) { export default defineConfig({ test: { + name: 'e2e:maintenance', retry: process.env.CI ? 4 : 0, include: ['src/specs/maintenance/server/**/*.e2e-spec.ts'], globalSetup, diff --git a/server/test/vitest.config.medium.mjs b/server/test/vitest.config.medium.mjs index fe6a93accb..4c3647f1df 100644 --- a/server/test/vitest.config.medium.mjs +++ b/server/test/vitest.config.medium.mjs @@ -1,10 +1,15 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import swc from 'unplugin-swc'; import tsconfigPaths from 'vite-tsconfig-paths'; import { defineConfig } from 'vitest/config'; +const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + export default defineConfig({ test: { - root: './', + name: 'server:medium', + root: serverRoot, globals: true, include: ['test/medium/**/*.spec.ts'], globalSetup: ['test/medium/globalSetup.ts'], diff --git a/server/test/vitest.config.mjs b/server/test/vitest.config.mjs index 79d053d176..1cecd62e9f 100644 --- a/server/test/vitest.config.mjs +++ b/server/test/vitest.config.mjs @@ -1,10 +1,15 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import swc from 'unplugin-swc'; import tsconfigPaths from 'vite-tsconfig-paths'; import { defineConfig } from 'vitest/config'; +const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + export default defineConfig({ test: { - root: './', + name: 'server:unit', + root: serverRoot, globals: true, include: ['src/**/*.spec.ts'], coverage: { diff --git a/web/vite.config.ts b/web/vite.config.ts index 69e1d7152f..a30f2b4103 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -56,6 +56,7 @@ export default defineConfig({ entries: ['src/**/*.{svelte,ts,html}'], }, test: { + name: 'web:unit', include: ['src/**/*.{test,spec}.{js,ts}'], globals: true, environment: 'happy-dom', From 13c4260a1f3930e788623db029113637bb2316d6 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Wed, 4 Mar 2026 08:23:58 -0500 Subject: [PATCH 064/150] fix: resolve medium test asset paths relative to file location (#26683) --- server/test/medium.factory.ts | 4 ++++ server/test/medium/specs/exif/exif-date-time.spec.ts | 4 ++-- server/test/medium/specs/exif/exif-gps.spec.ts | 4 ++-- server/test/medium/specs/exif/exif-tags.spec.ts | 4 ++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index cf863db2f0..53bf78b5b8 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -3,6 +3,7 @@ import { Insertable, Kysely } from 'kysely'; import { DateTime } from 'luxon'; import { createHash, randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; +import { resolve } from 'node:path'; import { Writable } from 'node:stream'; import { AssetFace } from 'src/database'; import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto'; @@ -78,6 +79,9 @@ import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory'; import { automock, wait } from 'test/utils'; import { Mocked } from 'vitest'; +// eslint-disable-next-line unicorn/prefer-module +export const testAssetsDir = resolve(__dirname, '../../e2e/test-assets'); + interface ClassConstructor extends Function { new (...args: any[]): T; } diff --git a/server/test/medium/specs/exif/exif-date-time.spec.ts b/server/test/medium/specs/exif/exif-date-time.spec.ts index e46f17855e..3341800b30 100644 --- a/server/test/medium/specs/exif/exif-date-time.spec.ts +++ b/server/test/medium/specs/exif/exif-date-time.spec.ts @@ -2,7 +2,7 @@ import { Kysely } from 'kysely'; import { DateTime } from 'luxon'; import { resolve } from 'node:path'; import { DB } from 'src/schema'; -import { ExifTestContext } from 'test/medium.factory'; +import { ExifTestContext, testAssetsDir } from 'test/medium.factory'; import { getKyselyDB } from 'test/utils'; let database: Kysely; @@ -11,7 +11,7 @@ const setup = async (testAssetPath: string) => { const ctx = new ExifTestContext(database); const { user } = await ctx.newUser(); - const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`); + const originalPath = resolve(testAssetsDir, testAssetPath); const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath }); return { ctx, sut: ctx.sut, asset }; diff --git a/server/test/medium/specs/exif/exif-gps.spec.ts b/server/test/medium/specs/exif/exif-gps.spec.ts index 651321b599..91326be28c 100644 --- a/server/test/medium/specs/exif/exif-gps.spec.ts +++ b/server/test/medium/specs/exif/exif-gps.spec.ts @@ -1,7 +1,7 @@ import { Kysely } from 'kysely'; import { resolve } from 'node:path'; import { DB } from 'src/schema'; -import { ExifTestContext } from 'test/medium.factory'; +import { ExifTestContext, testAssetsDir } from 'test/medium.factory'; import { getKyselyDB } from 'test/utils'; let database: Kysely; @@ -10,7 +10,7 @@ const setup = async (testAssetPath: string) => { const ctx = new ExifTestContext(database); const { user } = await ctx.newUser(); - const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`); + const originalPath = resolve(testAssetsDir, testAssetPath); const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath }); return { ctx, sut: ctx.sut, asset }; diff --git a/server/test/medium/specs/exif/exif-tags.spec.ts b/server/test/medium/specs/exif/exif-tags.spec.ts index 33a81d24b6..c65d4b3f7e 100644 --- a/server/test/medium/specs/exif/exif-tags.spec.ts +++ b/server/test/medium/specs/exif/exif-tags.spec.ts @@ -1,7 +1,7 @@ import { Kysely } from 'kysely'; import { resolve } from 'node:path'; import { DB } from 'src/schema'; -import { ExifTestContext } from 'test/medium.factory'; +import { ExifTestContext, testAssetsDir } from 'test/medium.factory'; import { getKyselyDB } from 'test/utils'; let database: Kysely; @@ -10,7 +10,7 @@ const setup = async (testAssetPath: string) => { const ctx = new ExifTestContext(database); const { user } = await ctx.newUser(); - const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`); + const originalPath = resolve(testAssetsDir, testAssetPath); const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath }); return { ctx, sut: ctx.sut, asset }; From 5989c9b4aa49096fb6053db841632dff2366bd17 Mon Sep 17 00:00:00 2001 From: Snowknight26 Date: Wed, 4 Mar 2026 07:25:29 -0600 Subject: [PATCH 065/150] fix(web): inconsistent asset nav bar state after visiting shared link (#26674) --- web/src/lib/components/pages/SharedLinkPage.svelte | 6 +++++- web/src/lib/utils.ts | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/pages/SharedLinkPage.svelte b/web/src/lib/components/pages/SharedLinkPage.svelte index 9965be2311..c6270d2de3 100644 --- a/web/src/lib/components/pages/SharedLinkPage.svelte +++ b/web/src/lib/components/pages/SharedLinkPage.svelte @@ -10,7 +10,7 @@ import { navigate } from '$lib/utils/navigation'; import { sharedLinkLogin, SharedLinkType, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; import { Button, Logo, PasswordInput } from '@immich/ui'; - import { tick } from 'svelte'; + import { onDestroy, tick } from 'svelte'; import { t } from 'svelte-i18n'; type Props = { @@ -60,6 +60,10 @@ event.preventDefault(); await handlePasswordSubmit(); }; + + onDestroy(() => { + setSharedLink(undefined); + }); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 8ef1308b4f..cb8095109e 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -145,8 +145,8 @@ export const downloadRequest = (options: DownloadRequestOptions let _sharedLink: SharedLinkResponseDto | undefined; -export const setSharedLink = (sharedLink: SharedLinkResponseDto) => (_sharedLink = sharedLink); -export const getSharedLink = (): SharedLinkResponseDto | undefined => _sharedLink; +export const setSharedLink = (sharedLink: typeof _sharedLink) => (_sharedLink = sharedLink); +export const getSharedLink = (): typeof _sharedLink => _sharedLink; const createUrl = (path: string, parameters?: Record) => { const searchParameters = new URLSearchParams(); From 3c476b1987d1a0b51a545ce83fc8133050ae3c54 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:19:13 +0100 Subject: [PATCH 066/150] chore: vitest 4 for web, cli, and e2e (#26668) --- cli/package.json | 4 +- cli/src/commands/asset.spec.ts | 81 ++-- cli/vite.config.ts | 8 +- cli/vitest.config.ts | 8 - e2e/package.json | 5 +- e2e/tsconfig.json | 2 +- e2e/vitest.config.ts | 9 +- e2e/vitest.maintenance.config.ts | 9 +- pnpm-lock.yaml | 405 ++++++++++++------ web/package.json | 4 +- web/src/lib/__mocks__/animate.mock.ts | 2 +- .../__mocks__/intersection-observer.mock.ts | 14 +- web/src/lib/__mocks__/resize-observer.mock.ts | 12 +- .../asset-viewer/asset-viewer-nav-bar.spec.ts | 15 +- .../thumbnail/__test__/thumbnail.spec.ts | 22 +- web/src/test-data/setup.ts | 22 +- 16 files changed, 399 insertions(+), 223 deletions(-) delete mode 100644 cli/vitest.config.ts diff --git a/cli/package.json b/cli/package.json index ad3d1307fd..aed8be5bba 100644 --- a/cli/package.json +++ b/cli/package.json @@ -21,7 +21,7 @@ "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", "@types/node": "^24.10.14", - "@vitest/coverage-v8": "^3.0.0", + "@vitest/coverage-v8": "^4.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", "commander": "^12.0.0", @@ -37,7 +37,7 @@ "typescript-eslint": "^8.28.0", "vite": "^7.0.0", "vite-tsconfig-paths": "^6.0.0", - "vitest": "^3.0.0", + "vitest": "^4.0.0", "vitest-fetch-mock": "^0.4.0", "yaml": "^2.3.1" }, diff --git a/cli/src/commands/asset.spec.ts b/cli/src/commands/asset.spec.ts index ea57eeb74b..21700ef963 100644 --- a/cli/src/commands/asset.spec.ts +++ b/cli/src/commands/asset.spec.ts @@ -1,6 +1,6 @@ -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; import { describe, expect, it, MockedFunction, vi } from 'vitest'; @@ -58,7 +58,7 @@ describe('uploadFiles', () => { }); it('returns new assets when upload file is successful', async () => { - fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => { + fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () { return { status: 200, body: JSON.stringify({ id: 'fc5621b1-86f6-44a1-9905-403e607df9f5', status: 'created' }), @@ -75,7 +75,7 @@ describe('uploadFiles', () => { it('returns new assets when upload file retry is successful', async () => { let counter = 0; - fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => { + fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () { counter++; if (counter < retry) { throw new Error('Network error'); @@ -96,7 +96,7 @@ describe('uploadFiles', () => { }); it('returns new assets when upload file retry is failed', async () => { - fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => { + fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () { throw new Error('Network error'); }); @@ -236,16 +236,19 @@ describe('startWatch', () => { await sleep(100); // to debounce the watcher from considering the test file as a existing file await fs.promises.writeFile(testFilePath, 'testjpg'); - await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000); - expect(checkBulkUpload).toHaveBeenCalledWith({ - assetBulkUploadCheckDto: { - assets: [ - expect.objectContaining({ - id: testFilePath, - }), - ], - }, - }); + await vi.waitFor( + () => + expect(checkBulkUpload).toHaveBeenCalledWith({ + assetBulkUploadCheckDto: { + assets: [ + expect.objectContaining({ + id: testFilePath, + }), + ], + }, + }), + { timeout: 5000 }, + ); }); it('should filter out unsupported files', async () => { @@ -257,16 +260,19 @@ describe('startWatch', () => { await fs.promises.writeFile(testFilePath, 'testjpg'); await fs.promises.writeFile(unsupportedFilePath, 'testtxt'); - await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000); - expect(checkBulkUpload).toHaveBeenCalledWith({ - assetBulkUploadCheckDto: { - assets: expect.arrayContaining([ - expect.objectContaining({ - id: testFilePath, - }), - ]), - }, - }); + await vi.waitFor( + () => + expect(checkBulkUpload).toHaveBeenCalledWith({ + assetBulkUploadCheckDto: { + assets: expect.arrayContaining([ + expect.objectContaining({ + id: testFilePath, + }), + ]), + }, + }), + { timeout: 5000 }, + ); expect(checkBulkUpload).not.toHaveBeenCalledWith({ assetBulkUploadCheckDto: { @@ -291,16 +297,19 @@ describe('startWatch', () => { await fs.promises.writeFile(testFilePath, 'testjpg'); await fs.promises.writeFile(ignoredFilePath, 'ignoredjpg'); - await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000); - expect(checkBulkUpload).toHaveBeenCalledWith({ - assetBulkUploadCheckDto: { - assets: expect.arrayContaining([ - expect.objectContaining({ - id: testFilePath, - }), - ]), - }, - }); + await vi.waitFor( + () => + expect(checkBulkUpload).toHaveBeenCalledWith({ + assetBulkUploadCheckDto: { + assets: expect.arrayContaining([ + expect.objectContaining({ + id: testFilePath, + }), + ]), + }, + }), + { timeout: 5000 }, + ); expect(checkBulkUpload).not.toHaveBeenCalledWith({ assetBulkUploadCheckDto: { diff --git a/cli/vite.config.ts b/cli/vite.config.ts index f538a9a357..c69b467011 100644 --- a/cli/vite.config.ts +++ b/cli/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite'; +import { defineConfig, UserConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; export default defineConfig({ @@ -17,4 +17,8 @@ export default defineConfig({ noExternal: /^(?!node:).*$/, }, plugins: [tsconfigPaths()], -}); + test: { + name: 'cli:unit', + globals: true, + }, +} as UserConfig); diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts deleted file mode 100644 index f444068181..0000000000 --- a/cli/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - name: 'cli:unit', - globals: true, - }, -}); diff --git a/e2e/package.json b/e2e/package.json index 640b812165..962cf86ea3 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -27,7 +27,7 @@ "@eslint/js": "^10.0.0", "@faker-js/faker": "^10.1.0", "@immich/cli": "workspace:*", - "@immich/e2e-auth-server": "workspace:*", + "@immich/e2e-auth-server": "workspace:*", "@immich/sdk": "workspace:*", "@playwright/test": "^1.44.1", "@socket.io/component-emitter": "^3.1.2", @@ -54,7 +54,8 @@ "typescript": "^5.3.3", "typescript-eslint": "^8.28.0", "utimes": "^5.2.1", - "vitest": "^3.0.0" + "vite-tsconfig-paths": "^6.1.1", + "vitest": "^4.0.0" }, "volta": { "node": "24.13.1" diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index bfad377089..f6efbf41e9 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -17,6 +17,6 @@ "esModuleInterop": true, "baseUrl": "./" }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "vitest*.config.ts"], "exclude": ["dist", "node_modules"] } diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index 10f3aa3e4f..17ece152d7 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -1,3 +1,4 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; import { defineConfig } from 'vitest/config'; const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true'; @@ -20,10 +21,8 @@ export default defineConfig({ globalSetup, testTimeout: 15_000, pool: 'threads', - poolOptions: { - threads: { - singleThread: true, - }, - }, + maxWorkers: 1, + isolate: false, }, + plugins: [tsconfigPaths()], }); diff --git a/e2e/vitest.maintenance.config.ts b/e2e/vitest.maintenance.config.ts index 665b908184..a6e96ccc0a 100644 --- a/e2e/vitest.maintenance.config.ts +++ b/e2e/vitest.maintenance.config.ts @@ -1,3 +1,4 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; import { defineConfig } from 'vitest/config'; const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true'; @@ -20,10 +21,8 @@ export default defineConfig({ globalSetup, testTimeout: 15_000, pool: 'threads', - poolOptions: { - threads: { - singleThread: true, - }, - }, + maxWorkers: 1, + isolate: false, }, + plugins: [tsconfigPaths()], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6f3c0f461..a026d30d90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,8 +66,8 @@ importers: specifier: ^24.10.14 version: 24.11.0 '@vitest/coverage-v8': - specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + specifier: ^4.0.0 + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) byte-size: specifier: ^9.0.0 version: 9.0.1 @@ -114,11 +114,11 @@ importers: specifier: ^6.0.0 version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: - specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + specifier: ^4.0.0 + version: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest-fetch-mock: specifier: ^0.4.0 - version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.4.5(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) yaml: specifier: ^2.3.1 version: 2.8.2 @@ -285,9 +285,12 @@ importers: utimes: specifier: ^5.2.1 version: 5.2.1(encoding@0.1.13) + vite-tsconfig-paths: + specifier: ^6.1.1 + version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: - specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + specifier: ^4.0.0 + version: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) e2e-auth-server: devDependencies: @@ -884,7 +887,7 @@ importers: version: 6.9.1 '@testing-library/svelte': specifier: ^5.2.8 - version: 5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.1) @@ -907,8 +910,8 @@ importers: specifier: ^1.5.5 version: 1.5.6 '@vitest/coverage-v8': - specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + specifier: ^4.0.0 + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) dotenv: specifier: ^17.0.0 version: 17.3.1 @@ -970,8 +973,8 @@ importers: specifier: ^7.1.2 version: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: - specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + specifier: ^4.0.0 + version: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -5195,9 +5198,21 @@ packages: '@vitest/browser': optional: true + '@vitest/coverage-v8@4.0.14': + resolution: {integrity: sha512-EYHLqN/BY6b47qHH7gtMxAg++saoGmsjWmAq9MlXxAz4M0NcHh9iOyKhBZyU4yxZqOd8Xnqp80/5saeitz4Cng==} + peerDependencies: + '@vitest/browser': 4.0.14 + vitest: 4.0.14 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.0.14': + resolution: {integrity: sha512-RHk63V3zvRiYOWAV0rGEBRO820ce17hz7cI2kDmEdfQsBjT2luEKB5tCOc91u1oSQoUOZkSv3ZyzkdkSLD7lKw==} + '@vitest/mocker@3.2.4': resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: @@ -5209,21 +5224,47 @@ packages: vite: optional: true + '@vitest/mocker@4.0.14': + resolution: {integrity: sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.0.14': + resolution: {integrity: sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==} + '@vitest/runner@3.2.4': resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.0.14': + resolution: {integrity: sha512-BsAIk3FAqxICqREbX8SetIteT8PiaUL/tgJjmhxJhCsigmzzH8xeadtp7LRnTpCVzvf0ib9BgAfKJHuhNllKLw==} + '@vitest/snapshot@3.2.4': resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.0.14': + resolution: {integrity: sha512-aQVBfT1PMzDSA16Y3Fp45a0q8nKexx6N5Amw3MX55BeTeZpoC08fGqEZqVmPcqN0ueZsuUQ9rriPMhZ3Mu19Ag==} + '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.0.14': + resolution: {integrity: sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==} + '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.0.14': + resolution: {integrity: sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -5833,6 +5874,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chai@6.2.1: + resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -5863,8 +5908,8 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} - check-error@2.1.1: - resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} cheerio-select@2.1.0: @@ -7175,8 +7220,8 @@ packages: resolution: {integrity: sha512-orD61HdNcdlegfD80wI+3JE/n+iobYPztpFqv2drLHb1rb2QEKR1QY62r+O0wZHHNIf3Bje+xjweS1hxWignQA==} engines: {node: '>=20.0.0'} - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} exponential-backoff@3.1.3: @@ -8605,6 +8650,9 @@ packages: magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + magicast@0.5.1: + resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==} + make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} @@ -11295,8 +11343,8 @@ packages: engines: {node: '>=10'} hasBin: true - test-exclude@7.0.1: - resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} engines: {node: '>=18'} testcontainers@11.12.0: @@ -11379,6 +11427,10 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} @@ -11863,6 +11915,40 @@ packages: jsdom: optional: true + vitest@4.0.14: + resolution: {integrity: sha512-d9B2J9Cm9dN9+6nxMnnNJKJCtcyKfnHj15N6YNJfaFHRLua/d3sRKU9RuKmO9mB0XdFtUizlxfz/VPbd3OxGhw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.14 + '@vitest/browser-preview': 4.0.14 + '@vitest/browser-webdriverio': 4.0.14 + '@vitest/ui': 4.0.14 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-jsonrpc@8.2.0: resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} engines: {node: '>=14.0.0'} @@ -16513,14 +16599,14 @@ snapshots: dependencies: svelte: 5.53.5 - '@testing-library/svelte@5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@testing-library/svelte@5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/svelte-core': 1.0.0(svelte@5.53.5) svelte: 5.53.5 optionalDependencies: vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -17227,28 +17313,43 @@ snapshots: magic-string: 0.30.21 magicast: 0.3.5 std-env: 3.10.0 - test-exclude: 7.0.1 + test-exclude: 7.0.2 tinyrainbow: 2.0.0 vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.14 ast-v8-to-istanbul: 0.3.8 - debug: 4.4.3 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.2.0 - magic-string: 0.30.21 - magicast: 0.3.5 + magicast: 0.5.1 + obug: 2.1.1 std-env: 3.10.0 - test-exclude: 7.0.1 - tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + tinyrainbow: 3.0.3 + vitest: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.14 + ast-v8-to-istanbul: 0.3.8 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magicast: 0.5.1 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -17260,6 +17361,15 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/expect@4.0.14': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.14 + '@vitest/utils': 4.0.14 + chai: 6.2.1 + tinyrainbow: 3.0.3 + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 @@ -17268,9 +17378,17 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.14(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@vitest/spy': 3.2.4 + '@vitest/spy': 4.0.14 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + '@vitest/mocker@4.0.14(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.0.14 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: @@ -17280,28 +17398,50 @@ snapshots: dependencies: tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.0.14': + dependencies: + tinyrainbow: 3.0.3 + '@vitest/runner@3.2.4': dependencies: '@vitest/utils': 3.2.4 pathe: 2.0.3 strip-literal: 3.1.0 + '@vitest/runner@4.0.14': + dependencies: + '@vitest/utils': 4.0.14 + pathe: 2.0.3 + '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/snapshot@4.0.14': + dependencies: + '@vitest/pretty-format': 4.0.14 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 + '@vitest/spy@4.0.14': {} + '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 loupe: 3.2.1 tinyrainbow: 2.0.0 + '@vitest/utils@4.0.14': + dependencies: + '@vitest/pretty-format': 4.0.14 + tinyrainbow: 3.0.3 + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -18000,11 +18140,13 @@ snapshots: chai@5.3.3: dependencies: assertion-error: 2.0.1 - check-error: 2.1.1 + check-error: 2.1.3 deep-eql: 5.0.2 loupe: 3.2.1 pathval: 2.0.1 + chai@6.2.1: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -18026,7 +18168,7 @@ snapshots: chardet@2.1.1: {} - check-error@2.1.1: {} + check-error@2.1.3: {} cheerio-select@2.1.0: dependencies: @@ -19496,7 +19638,7 @@ snapshots: optionalDependencies: exiftool-vendored.exe: 13.51.0 - expect-type@1.3.0: {} + expect-type@1.2.2: {} exponential-backoff@3.1.3: {} @@ -21122,6 +21264,12 @@ snapshots: '@babel/types': 7.28.5 source-map-js: 1.2.1 + magicast@0.5.1: + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + source-map-js: 1.2.1 + make-dir@3.1.0: dependencies: semver: 6.3.1 @@ -24567,11 +24715,11 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - test-exclude@7.0.1: + test-exclude@7.0.2: dependencies: '@istanbuljs/schema': 0.1.3 glob: 10.5.0 - minimatch: 9.0.6 + minimatch: 10.2.4 testcontainers@11.12.0: dependencies: @@ -24659,6 +24807,8 @@ snapshots: tinyrainbow@2.0.0: {} + tinyrainbow@3.0.3: {} + tinyspy@4.0.4: {} tldts-core@6.1.86: @@ -25083,27 +25233,6 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - transitivePeerDependencies: - - '@types/node' - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 @@ -25154,53 +25283,9 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vitest-fetch-mock@0.4.5(vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): - dependencies: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - debug: 4.4.3 - expect-type: 1.3.0 - magic-string: 0.30.21 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/debug': 4.1.12 - '@types/node': 24.11.0 - happy-dom: 20.7.0 - jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml + vitest: 4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: @@ -25214,7 +25299,7 @@ snapshots: '@vitest/utils': 3.2.4 chai: 5.3.3 debug: 4.4.3 - expect-type: 1.3.0 + expect-type: 1.2.2 magic-string: 0.30.21 pathe: 2.0.3 picomatch: 4.0.3 @@ -25246,33 +25331,110 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - debug: 4.4.3 - expect-type: 1.3.0 + '@vitest/expect': 4.0.14 + '@vitest/mocker': 4.0.14(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.14 + '@vitest/runner': 4.0.14 + '@vitest/snapshot': 4.0.14 + '@vitest/spy': 4.0.14 + '@vitest/utils': 4.0.14 + es-module-lexer: 1.7.0 + expect-type: 1.2.2 magic-string: 0.30.21 + obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.3 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 tinyglobby: 0.2.15 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: - '@types/debug': 4.1.12 + '@opentelemetry/api': 1.9.0 + '@types/node': 24.11.0 + happy-dom: 20.7.0 + jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@24.11.0)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@vitest/expect': 4.0.14 + '@vitest/mocker': 4.0.14(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.14 + '@vitest/runner': 4.0.14 + '@vitest/snapshot': 4.0.14 + '@vitest/spy': 4.0.14 + '@vitest/utils': 4.0.14 + es-module-lexer: 1.7.0 + expect-type: 1.2.2 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 24.11.0 + happy-dom: 20.7.0 + jsdom: 26.1.0(canvas@2.11.2) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + vitest@4.0.14(@opentelemetry/api@1.9.0)(@types/node@25.3.3)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@vitest/expect': 4.0.14 + '@vitest/mocker': 4.0.14(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.14 + '@vitest/runner': 4.0.14 + '@vitest/snapshot': 4.0.14 + '@vitest/spy': 4.0.14 + '@vitest/utils': 4.0.14 + es-module-lexer: 1.7.0 + expect-type: 1.2.2 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 '@types/node': 25.3.3 happy-dom: 20.7.0 jsdom: 26.1.0(canvas@2.11.2) @@ -25285,7 +25447,6 @@ snapshots: - sass-embedded - stylus - sugarss - - supports-color - terser - tsx - yaml diff --git a/web/package.json b/web/package.json index 77e963b82c..5bec4e786b 100644 --- a/web/package.json +++ b/web/package.json @@ -83,7 +83,7 @@ "@types/lodash-es": "^4.17.12", "@types/luxon": "^3.4.2", "@types/qrcode": "^1.5.5", - "@vitest/coverage-v8": "^3.0.0", + "@vitest/coverage-v8": "^4.0.0", "dotenv": "^17.0.0", "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", @@ -105,7 +105,7 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.2", - "vitest": "^3.0.0" + "vitest": "^4.0.0" }, "volta": { "node": "24.13.1" diff --git a/web/src/lib/__mocks__/animate.mock.ts b/web/src/lib/__mocks__/animate.mock.ts index 5f0d367d86..76ac1318b7 100644 --- a/web/src/lib/__mocks__/animate.mock.ts +++ b/web/src/lib/__mocks__/animate.mock.ts @@ -2,7 +2,7 @@ import { tick } from 'svelte'; import { vi } from 'vitest'; export const getAnimateMock = () => - vi.fn().mockImplementation(() => { + vi.fn().mockImplementation(function () { let onfinish: (() => void) | null = null; void tick().then(() => onfinish?.()); diff --git a/web/src/lib/__mocks__/intersection-observer.mock.ts b/web/src/lib/__mocks__/intersection-observer.mock.ts index 5565e9a139..9f3dc05dce 100644 --- a/web/src/lib/__mocks__/intersection-observer.mock.ts +++ b/web/src/lib/__mocks__/intersection-observer.mock.ts @@ -1,9 +1,11 @@ import { vi } from 'vitest'; export const getIntersectionObserverMock = () => - vi.fn(() => ({ - disconnect: vi.fn(), - observe: vi.fn(), - takeRecords: vi.fn(), - unobserve: vi.fn(), - })); + vi.fn(function () { + return { + disconnect: vi.fn(), + observe: vi.fn(), + takeRecords: vi.fn(), + unobserve: vi.fn(), + }; + }); diff --git a/web/src/lib/__mocks__/resize-observer.mock.ts b/web/src/lib/__mocks__/resize-observer.mock.ts index ffd1dad2fd..da4baef5ba 100644 --- a/web/src/lib/__mocks__/resize-observer.mock.ts +++ b/web/src/lib/__mocks__/resize-observer.mock.ts @@ -1,8 +1,10 @@ import { vi } from 'vitest'; export const getResizeObserverMock = () => - vi.fn(() => ({ - disconnect: vi.fn(), - observe: vi.fn(), - unobserve: vi.fn(), - })); + vi.fn(function () { + return { + disconnect: vi.fn(), + observe: vi.fn(), + unobserve: vi.fn(), + }; + }); diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts index 3f49e79ed4..08d0b9aceb 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts @@ -18,18 +18,19 @@ describe('AssetViewerNavBar component', () => { }; beforeAll(() => { - Element.prototype.animate = vi.fn().mockImplementation(() => ({ - cancel: () => {}, - })); + Element.prototype.animate = vi.fn().mockImplementation(function () { + return { + cancel: () => {}, + }; + }); vi.stubGlobal('ResizeObserver', getResizeObserverMock()); - vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => { + vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () { return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), - value: { trash: true, smartSearch: true }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any, + value: { smartSearch: true, trash: true }, + } as never, }; }); }); diff --git a/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts b/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts index f8e5fe0efa..1d78e24935 100644 --- a/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts +++ b/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts @@ -8,16 +8,18 @@ vi.hoisted(() => { Object.defineProperty(globalThis, 'matchMedia', { writable: true, enumerable: true, - value: vi.fn().mockImplementation((query) => ({ - matches: false, - media: query, - onchange: null, - addListener: vi.fn(), // deprecated - removeListener: vi.fn(), // deprecated - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })), + value: vi.fn().mockImplementation(function (query) { + return { + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }; + }), }); }); diff --git a/web/src/test-data/setup.ts b/web/src/test-data/setup.ts index 7a94c54338..b3e6a094a8 100644 --- a/web/src/test-data/setup.ts +++ b/web/src/test-data/setup.ts @@ -3,19 +3,23 @@ import { init } from 'svelte-i18n'; beforeAll(async () => { await init({ fallbackLocale: 'dev' }); - Element.prototype.animate = vi.fn().mockImplementation(() => ({ cancel: () => {}, finished: Promise.resolve() })); + Element.prototype.animate = vi.fn().mockImplementation(function () { + return { cancel: () => {}, finished: Promise.resolve() }; + }); }); Object.defineProperty(globalThis, 'matchMedia', { writable: true, - value: vi.fn().mockImplementation((query) => ({ - matches: false, - media: query, - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })), + value: vi.fn().mockImplementation(function (query) { + return { + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }; + }), }); vi.mock('$env/dynamic/public', () => { From 2725c96cb1b5d70fae2ec32b85c640e7220f61d2 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Wed, 4 Mar 2026 09:29:15 -0500 Subject: [PATCH 067/150] chore: add recommended VSCode workspace extensions (#26682) --- .vscode/extensions.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 8be57c6ba4..399fedae33 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -5,6 +5,13 @@ "dbaeumer.vscode-eslint", "dart-code.flutter", "dart-code.dart-code", - "dcmdev.dcm-vscode-extension" + "dcmdev.dcm-vscode-extension", + "bradlc.vscode-tailwindcss", + "ms-playwright.playwright", + "vitest.explorer", + "editorconfig.editorconfig", + "foxundermoon.shell-format", + "timonwong.shellcheck", + "bluebrown.yamlfmt" ] } From 011ecbb43ddcc62212da0a084822c2d72dcc4e2b Mon Sep 17 00:00:00 2001 From: Timon Date: Wed, 4 Mar 2026 16:05:44 +0100 Subject: [PATCH 068/150] refactor(web): remove replaceAsset action (#26444) --- .../asset-viewer/asset-viewer-nav-bar.svelte | 8 +------- .../asset-viewer/asset-viewer.svelte | 19 ------------------- web/src/lib/managers/event-manager.svelte.ts | 1 - web/src/lib/services/asset.service.ts | 11 ----------- 4 files changed, 1 insertion(+), 38 deletions(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index d2f1ba3b98..bb52c71260 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -21,7 +21,7 @@ import { languageManager } from '$lib/managers/language-manager.svelte'; import { Route } from '$lib/route'; import { getGlobalActions } from '$lib/services/app.service'; - import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service'; + import { getAssetActions } from '$lib/services/asset.service'; import { user } from '$lib/stores/user.store'; import { getSharedLink, withoutIcons } from '$lib/utils'; import type { OnUndoDelete } from '$lib/utils/actions'; @@ -42,7 +42,6 @@ mdiDotsVertical, mdiImageSearch, mdiPresentationPlay, - mdiUpload, mdiVideoOutline, } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -170,11 +169,6 @@ {#if !isLocked} {#if isOwner} - handleReplaceAsset(asset.id)} - text={$t('replace_with_upload')} - /> {#if !asset.isArchived && !asset.isTrashed} import { browser } from '$app/environment'; - import { goto } from '$app/navigation'; import { focusTrap } from '$lib/actions/focus-trap'; import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action'; import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte'; import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte'; import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte'; - import OnEvents from '$lib/components/OnEvents.svelte'; import { AssetAction, ProjectionType } from '$lib/constants'; import { activityManager } from '$lib/managers/activity-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; @@ -14,7 +12,6 @@ import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import { imageManager } from '$lib/managers/ImageManager.svelte'; - import { Route } from '$lib/route'; import { getAssetActions } from '$lib/services/asset.service'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { ocrManager } from '$lib/stores/ocr.svelte'; @@ -363,21 +360,6 @@ imageManager.preload(cursor.previousAsset); }); - const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => { - if (oldAssetId !== asset.id) { - return; - } - - await new Promise((promise) => setTimeout(promise, 500)); - await goto(Route.viewAsset({ id: newAssetId })); - }; - - const onAssetUpdate = (update: AssetResponseDto) => { - if (asset.id === update.id) { - cursor = { ...cursor, current: update }; - } - }; - const viewerKind = $derived.by(() => { if (previewStackedAsset) { return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer'; @@ -424,7 +406,6 @@ - diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 33519fddbb..2095a001bd 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -33,7 +33,6 @@ export type Events = { ApiKeyDelete: [ApiKeyResponseDto]; AssetUpdate: [AssetResponseDto]; - AssetReplace: [{ oldAssetId: string; newAssetId: string }]; AssetsArchive: [string[]]; AssetsDelete: [string[]]; AssetEditsApplied: [string]; diff --git a/web/src/lib/services/asset.service.ts b/web/src/lib/services/asset.service.ts index a2dddbba51..9071f87f98 100644 --- a/web/src/lib/services/asset.service.ts +++ b/web/src/lib/services/asset.service.ts @@ -9,7 +9,6 @@ import { user as authUser, preferences } from '$lib/stores/user.store'; import type { AssetControlContext } from '$lib/types'; import { getSharedLink, sleep } from '$lib/utils'; import { downloadUrl } from '$lib/utils/asset-utils'; -import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { handleError } from '$lib/utils/handle-error'; import { getFormatter } from '$lib/utils/i18n'; import { asQueryString } from '$lib/utils/shared-links'; @@ -17,8 +16,6 @@ import { AssetJobName, AssetTypeEnum, AssetVisibility, - copyAsset, - deleteAssets, getAssetInfo, getBaseUrl, runAssetJobs, @@ -362,14 +359,6 @@ const handleUnfavorite = async (asset: AssetResponseDto) => { } }; -export const handleReplaceAsset = async (oldAssetId: string) => { - const [newAssetId] = await openFileUploadDialog({ multiple: false }); - await copyAsset({ assetCopyDto: { sourceId: oldAssetId, targetId: newAssetId } }); - await deleteAssets({ assetBulkDeleteDto: { ids: [oldAssetId], force: true } }); - - eventManager.emit('AssetReplace', { oldAssetId, newAssetId }); -}; - const getAssetJobMessage = ($t: MessageFormatter, job: AssetJobName) => { const messages: Record = { [AssetJobName.RefreshFaces]: $t('refreshing_faces'), From 8279e1078ada970868c49436add169cfa103c48c Mon Sep 17 00:00:00 2001 From: Snowknight26 Date: Wed, 4 Mar 2026 09:22:48 -0600 Subject: [PATCH 069/150] fix(web): download toast showing wrong filename for motion assets (#26689) --- web/src/lib/services/asset.service.spec.ts | 50 +++++++++++++++++++++- web/src/lib/services/asset.service.ts | 4 +- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/web/src/lib/services/asset.service.spec.ts b/web/src/lib/services/asset.service.spec.ts index a94d86be47..b67db960be 100644 --- a/web/src/lib/services/asset.service.spec.ts +++ b/web/src/lib/services/asset.service.spec.ts @@ -1,9 +1,34 @@ -import { getAssetActions } from '$lib/services/asset.service'; +import { getAssetActions, handleDownloadAsset } from '$lib/services/asset.service'; import { user as userStore } from '$lib/stores/user.store'; import { setSharedLink } from '$lib/utils'; +import { getFormatter } from '$lib/utils/i18n'; +import { getAssetInfo } from '@immich/sdk'; +import { toastManager } from '@immich/ui'; import { assetFactory } from '@test-data/factories/asset-factory'; import { sharedLinkFactory } from '@test-data/factories/shared-link-factory'; import { userAdminFactory } from '@test-data/factories/user-factory'; +import { vitest } from 'vitest'; + +vitest.mock('@immich/ui', () => ({ + toastManager: { + success: vitest.fn(), + }, +})); + +vitest.mock('$lib/utils/i18n', () => ({ + getFormatter: vitest.fn(), + getPreferredLocale: vitest.fn(), +})); + +vitest.mock('@immich/sdk'); + +vitest.mock('$lib/utils', async () => { + const originalModule = await vitest.importActual('$lib/utils'); + return { + ...originalModule, + sleep: vitest.fn(), + }; +}); describe('AssetService', () => { describe('getAssetActions', () => { @@ -34,4 +59,27 @@ describe('AssetService', () => { expect(assetActions.SharedLinkDownload.$if?.()).toStrictEqual(true); }); }); + + describe('handleDownloadAsset', () => { + it('should use the asset originalFileName when showing toasts', async () => { + const $t = vitest.fn().mockReturnValue('formatter'); + vitest.mocked(getFormatter).mockResolvedValue($t); + const asset = assetFactory.build({ originalFileName: 'asset.heic' }); + await handleDownloadAsset(asset, { edited: false }); + expect($t).toHaveBeenNthCalledWith(1, 'downloading_asset_filename', { values: { filename: 'asset.heic' } }); + expect(toastManager.success).toHaveBeenCalledWith('formatter'); + }); + + it('should use the motion asset originalFileName when showing toasts', async () => { + const $t = vitest.fn().mockReturnValue('formatter'); + vitest.mocked(getFormatter).mockResolvedValue($t); + const motionAsset = assetFactory.build({ originalFileName: 'asset.mov' }); + vitest.mocked(getAssetInfo).mockResolvedValue(motionAsset); + const asset = assetFactory.build({ originalFileName: 'asset.heic', livePhotoVideoId: '1' }); + await handleDownloadAsset(asset, { edited: false }); + expect($t).toHaveBeenNthCalledWith(1, 'downloading_asset_filename', { values: { filename: 'asset.heic' } }); + expect($t).toHaveBeenNthCalledWith(2, 'downloading_asset_filename', { values: { filename: 'asset.mov' } }); + expect(toastManager.success).toHaveBeenCalledWith('formatter'); + }); + }); }); diff --git a/web/src/lib/services/asset.service.ts b/web/src/lib/services/asset.service.ts index 9071f87f98..530bbc70f1 100644 --- a/web/src/lib/services/asset.service.ts +++ b/web/src/lib/services/asset.service.ts @@ -294,7 +294,6 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: { { filename: asset.originalFileName, id: asset.id, - size: asset.exifInfo?.fileSizeInByte || 0, }, ]; @@ -308,7 +307,6 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: { assets.push({ filename: motionAsset.originalFileName, id: asset.livePhotoVideoId, - size: motionAsset.exifInfo?.fileSizeInByte || 0, }); } } @@ -322,7 +320,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: { } try { - toastManager.success($t('downloading_asset_filename', { values: { filename: asset.originalFileName } })); + toastManager.success($t('downloading_asset_filename', { values: { filename } })); downloadUrl( getBaseUrl() + `/assets/${id}/original` + From 5caa7e19021924fbf054132b004903403fcc2c86 Mon Sep 17 00:00:00 2001 From: Andreas Heinz Date: Wed, 4 Mar 2026 16:27:26 +0100 Subject: [PATCH 070/150] feat(web): bounding box for faces when hovering over the face in photo view (#26667) * feat(web): when hovering over a face already deteced, display the bounding box also shown when hovering over the person in the details-pane. * prevent lint error * fix unused var --- .../asset-viewer/photo-viewer.svelte | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 69a6f0f103..3e609ff130 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -11,7 +11,7 @@ import { imageManager } from '$lib/managers/ImageManager.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte'; - import { boundingBoxesArray } from '$lib/stores/people.store'; + import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store'; import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils'; import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; @@ -195,6 +195,42 @@ } lastUrl = imageLoaderUrl; }); + + const faceToNameMap = $derived.by(() => { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const map = new Map(); + for (const person of asset.people ?? []) { + for (const face of person.faces ?? []) { + map.set(face, person.name); + } + } + return map; + }); + + const faces = $derived(Array.from(faceToNameMap.keys())); + + const handleImageMouseMove = (event: MouseEvent) => { + $boundingBoxesArray = []; + if (!assetViewerManager.imgRef || !element || isFaceEditMode.value || ocrManager.showOverlay) { + return; + } + + const containerRect = element.getBoundingClientRect(); + const mouseX = event.clientX - containerRect.left; + const mouseY = event.clientY - containerRect.top; + + const faceBoxes = getBoundingBox(faces, overlayMetrics); + + for (const [index, box] of faceBoxes.entries()) { + if (mouseX >= box.left && mouseX <= box.left + box.width && mouseY >= box.top && mouseY <= box.top + box.height) { + $boundingBoxesArray.push(faces[index]); + } + } + }; + + const handleImageMouseLeave = () => { + $boundingBoxesArray = []; + }; @@ -218,6 +254,9 @@ class="relative h-full w-full select-none" bind:clientWidth={containerWidth} bind:clientHeight={containerHeight} + role="presentation" + onmousemove={handleImageMouseMove} + onmouseleave={handleImageMouseLeave} > {#if !imageLoaded}
@@ -248,11 +287,20 @@ : slideshowLookCssMapping[$slideshowLook]}" draggable="false" /> - {#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox (boundingbox.id)} + {#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox, index (boundingbox.id)}
+ {#if faceToNameMap.get($boundingBoxesArray[index])} +
+ {faceToNameMap.get($boundingBoxesArray[index])} +
+ {/if} {/each} {#each ocrBoxes as ocrBox (ocrBox.id)} From 16e4a2b92af8f7eaf7b81092d0a2fddd6c081542 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:43:19 +0100 Subject: [PATCH 071/150] fix(docs): we usually don't assign issues (#26691) Update CONTRIBUTING.md --- CONTRIBUTING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1695403cb4..d04f89015e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,8 @@ Please try to keep pull requests as focused as possible. A PR should do exactly If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on! +We usually do not assign issues to new contributors, since it happens often that a PR is never even opened. Again, reach out on Discord if you fear putting a lot of time into fixing an issue, but ending up with a duplicate PR. + ## Use of generative AI We ask you not to open PRs generated with an LLM. We find that code generated like this tends to need a large amount of back-and-forth, which is a very inefficient use of our time. If we want LLM-generated code, it's much faster for us to use an LLM ourselves than to go through an intermediary via a pull request. From dd03c9c0a94c42eda06d3f6fd69c4493f419e223 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:47:51 +0000 Subject: [PATCH 072/150] fix(mobile): add safe area for asset details (#26675) --- .../asset_viewer/asset_details.widget.dart | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart index e07fd79192..dd5743a2d0 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart @@ -27,18 +27,21 @@ class AssetDetails extends ConsumerWidget { color: context.colorScheme.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const DragHandle(), - DateTimeDetails(asset: asset, exifInfo: exifInfo), - PeopleDetails(asset: asset), - LocationDetails(asset: asset, exifInfo: exifInfo), - TechnicalDetails(asset: asset, exifInfo: exifInfo), - RatingDetails(exifInfo: exifInfo), - AppearsInDetails(asset: asset), - SizedBox(height: context.padding.bottom + 48), - ], + child: SafeArea( + top: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const DragHandle(), + DateTimeDetails(asset: asset, exifInfo: exifInfo), + PeopleDetails(asset: asset), + LocationDetails(asset: asset, exifInfo: exifInfo), + TechnicalDetails(asset: asset, exifInfo: exifInfo), + RatingDetails(exifInfo: exifInfo), + AppearsInDetails(asset: asset), + SizedBox(height: context.padding.bottom + 48), + ], + ), ), ); } From 7e9da945f6fda8965b1c09142f491e50efc28030 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:28:55 +0000 Subject: [PATCH 073/150] chore(mobile): simplify asset page scroll (#26635) In order to scroll smoothly without interfering with the gesture detector on the photo view, we have an offstate scroll view which we defer all drags to, and then forward scroll offsets to the real scroll controller. This works well, but it can be simpler. Instead, we can create a custom scroll controller on a scroll view with never scrollable physics, and then forward drag events to that, bypassing the need for a proxy scroll controller. Co-authored-by: Alex --- mobile/lib/extensions/scroll_extensions.dart | 109 +++++------------- .../asset_viewer/asset_page.widget.dart | 47 +++----- 2 files changed, 46 insertions(+), 110 deletions(-) diff --git a/mobile/lib/extensions/scroll_extensions.dart b/mobile/lib/extensions/scroll_extensions.dart index 5917e127bc..5b8f9e2a13 100644 --- a/mobile/lib/extensions/scroll_extensions.dart +++ b/mobile/lib/extensions/scroll_extensions.dart @@ -33,12 +33,27 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics { ); } +class SnapScrollController extends ScrollController { + SnapScrollPosition get snapPosition => position as SnapScrollPosition; + + @override + ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) => + SnapScrollPosition(physics: physics, context: context, oldPosition: oldPosition); +} + +class SnapScrollPosition extends ScrollPositionWithSingleContext { + double snapOffset; + + SnapScrollPosition({required super.physics, required super.context, super.oldPosition, this.snapOffset = 0.0}); + + @override + bool get shouldIgnorePointer => false; +} + class SnapScrollPhysics extends ScrollPhysics { static const _minFlingVelocity = 700.0; static const minSnapDistance = 30.0; - static final _spring = SpringDescription.withDampingRatio(mass: .5, stiffness: 300); - const SnapScrollPhysics({super.parent}); @override @@ -66,91 +81,21 @@ class SnapScrollPhysics extends ScrollPhysics { } } - return ScrollSpringSimulation( - _spring, - position.pixels, - target(position, velocity, snapOffset), - velocity, - tolerance: toleranceFor(position), - ); + return ScrollSpringSimulation(spring, position.pixels, target(position, velocity, snapOffset), velocity); } + @override + SpringDescription get spring => SpringDescription.withDampingRatio(mass: .5, stiffness: 300); + + @override + bool get allowImplicitScrolling => false; + + @override + bool get allowUserScrolling => false; + static double target(ScrollMetrics position, double velocity, double snapOffset) { if (velocity > _minFlingVelocity) return snapOffset; if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset; return position.pixels < minSnapDistance ? 0.0 : snapOffset; } } - -class SnapScrollPosition extends ScrollPositionWithSingleContext { - double snapOffset; - - SnapScrollPosition({this.snapOffset = 0.0, required super.physics, required super.context, super.oldPosition}); -} - -class ProxyScrollController extends ScrollController { - final ScrollController scrollController; - - ProxyScrollController({required this.scrollController}); - - SnapScrollPosition get snapPosition => position as SnapScrollPosition; - - @override - ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) { - return ProxyScrollPosition( - scrollController: scrollController, - physics: physics, - context: context, - oldPosition: oldPosition, - ); - } - - @override - void dispose() { - scrollController.dispose(); - super.dispose(); - } -} - -class ProxyScrollPosition extends SnapScrollPosition { - final ScrollController scrollController; - - ProxyScrollPosition({ - required this.scrollController, - required super.physics, - required super.context, - super.oldPosition, - }); - - @override - double setPixels(double newPixels) { - final overscroll = super.setPixels(newPixels); - if (scrollController.hasClients && scrollController.position.pixels != pixels) { - scrollController.position.forcePixels(pixels); - } - return overscroll; - } - - @override - void forcePixels(double value) { - super.forcePixels(value); - if (scrollController.hasClients && scrollController.position.pixels != pixels) { - scrollController.position.forcePixels(pixels); - } - } - - @override - double get maxScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions - ? scrollController.position.maxScrollExtent - : super.maxScrollExtent; - - @override - double get minScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions - ? scrollController.position.minScrollExtent - : super.minScrollExtent; - - @override - double get viewportDimension => scrollController.hasClients && scrollController.position.hasViewportDimension - ? scrollController.position.viewportDimension - : super.viewportDimension; -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index 5da8227ef0..ea7ff51fa6 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -50,8 +50,7 @@ class _AssetPageState extends ConsumerState { bool _showingDetails = false; bool _isZoomed = false; - final _scrollController = ScrollController(); - late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController); + final _scrollController = SnapScrollController(); double _snapOffset = 0.0; DragStartDetails? _dragStart; @@ -63,17 +62,17 @@ class _AssetPageState extends ConsumerState { super.initState(); _eventSubscription = EventStream.shared.listen(_onEvent); WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !_proxyScrollController.hasClients) return; - _proxyScrollController.snapPosition.snapOffset = _snapOffset; + if (!mounted || !_scrollController.hasClients) return; + _scrollController.snapPosition.snapOffset = _snapOffset; if (_showingDetails && _snapOffset > 0) { - _proxyScrollController.jumpTo(_snapOffset); + _scrollController.jumpTo(_snapOffset); } }); } @override void dispose() { - _proxyScrollController.dispose(); + _scrollController.dispose(); _scaleBoundarySub?.cancel(); _eventSubscription?.cancel(); super.dispose(); @@ -88,21 +87,20 @@ class _AssetPageState extends ConsumerState { } void _showDetails() { - if (!_proxyScrollController.hasClients || _snapOffset <= 0) return; + if (!_scrollController.hasClients || _snapOffset <= 0) return; _viewer.setShowingDetails(true); - _proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic); + _scrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic); } - bool _willClose(double scrollVelocity) { - if (!_proxyScrollController.hasClients || _snapOffset <= 0) return false; - - final position = _proxyScrollController.position; - return _proxyScrollController.position.pixels < _snapOffset && - SnapScrollPhysics.target(position, scrollVelocity, _snapOffset) < SnapScrollPhysics.minSnapDistance; - } + bool _willClose(double scrollVelocity) => + _scrollController.hasClients && + _snapOffset > 0 && + _scrollController.position.pixels < _snapOffset && + SnapScrollPhysics.target(_scrollController.position, scrollVelocity, _snapOffset) < + SnapScrollPhysics.minSnapDistance; void _syncShowingDetails() { - final offset = _proxyScrollController.offset; + final offset = _scrollController.offset; if (offset > SnapScrollPhysics.minSnapDistance) { _viewer.setShowingDetails(true); } else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) { @@ -124,8 +122,8 @@ class _AssetPageState extends ConsumerState { } void _startProxyDrag() { - if (_proxyScrollController.hasClients && _dragStart != null) { - _drag = _proxyScrollController.position.drag(_dragStart!, () => _drag = null); + if (_scrollController.hasClients && _dragStart != null) { + _drag = _scrollController.position.drag(_dragStart!, () => _drag = null); } } @@ -390,22 +388,15 @@ class _AssetPageState extends ConsumerState { _snapOffset = detailsOffset - snapTarget; - if (_proxyScrollController.hasClients) { - _proxyScrollController.snapPosition.snapOffset = _snapOffset; + if (_scrollController.hasClients) { + _scrollController.snapPosition.snapOffset = _snapOffset; } return Stack( children: [ - Offstage( - child: SingleChildScrollView( - controller: _proxyScrollController, - physics: const SnapScrollPhysics(), - child: const SizedBox.shrink(), - ), - ), SingleChildScrollView( controller: _scrollController, - physics: const NeverScrollableScrollPhysics(), + physics: const SnapScrollPhysics(), child: Stack( children: [ SizedBox( From 228ac63ab9b33d7a43586d9f95086423f5a116ed Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:27:11 +0000 Subject: [PATCH 074/150] feat(mobile): keep search results visible (#26498) Search results are replaced with a spinner when loading the next page, which is quite jarring. Search results now remain visible when loading the next page with a spinner at the bottom. The next page also loads sooner, which makes it feel a lot smoother. Co-authored-by: Alex --- .../domain/models/search_result.model.dart | 17 +- .../lib/domain/services/timeline.service.dart | 5 +- .../repositories/timeline.repository.dart | 13 ++ .../pages/search/drift_search.page.dart | 160 +++++++++++------- .../search/paginated_search.provider.dart | 52 +++--- .../widgets/timeline/constants.dart | 1 + .../widgets/timeline/scrubber.widget.dart | 8 +- .../widgets/timeline/timeline.widget.dart | 34 ++-- 8 files changed, 180 insertions(+), 110 deletions(-) diff --git a/mobile/lib/domain/models/search_result.model.dart b/mobile/lib/domain/models/search_result.model.dart index 947bc6192f..21134b73d8 100644 --- a/mobile/lib/domain/models/search_result.model.dart +++ b/mobile/lib/domain/models/search_result.model.dart @@ -3,30 +3,21 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; class SearchResult { final List assets; - final double scrollOffset; final int? nextPage; - const SearchResult({required this.assets, this.scrollOffset = 0.0, this.nextPage}); - - SearchResult copyWith({List? assets, int? nextPage, double? scrollOffset}) { - return SearchResult( - assets: assets ?? this.assets, - nextPage: nextPage ?? this.nextPage, - scrollOffset: scrollOffset ?? this.scrollOffset, - ); - } + const SearchResult({required this.assets, this.nextPage}); @override - String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage, scrollOffset: $scrollOffset)'; + String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage)'; @override bool operator ==(covariant SearchResult other) { if (identical(this, other)) return true; final listEquals = const DeepCollectionEquality().equals; - return listEquals(other.assets, assets) && other.nextPage == nextPage && other.scrollOffset == scrollOffset; + return listEquals(other.assets, assets) && other.nextPage == nextPage; } @override - int get hashCode => assets.hashCode ^ nextPage.hashCode ^ scrollOffset.hashCode; + int get hashCode => assets.hashCode ^ nextPage.hashCode; } diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 39aeb867a3..b33940eacd 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -78,6 +78,9 @@ class TimelineFactory { TimelineService fromAssets(List assets, TimelineOrigin type) => TimelineService(_timelineRepository.fromAssets(assets, type)); + TimelineService fromAssetStream(List Function() getAssets, Stream assetCount, TimelineOrigin type) => + TimelineService(_timelineRepository.fromAssetStream(getAssets, assetCount, type)); + TimelineService fromAssetsWithBuckets(List assets, TimelineOrigin type) => TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type)); @@ -112,7 +115,7 @@ class TimelineService { if (totalAssets == 0) { _bufferOffset = 0; - _buffer.clear(); + _buffer = []; } else { final int offset; final int count; diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 4b4a13a4f9..74af6dc3f0 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -276,6 +276,19 @@ class DriftTimelineRepository extends DriftDatabaseRepository { origin: origin, ); + TimelineQuery fromAssetStream(List Function() getAssets, Stream assetCount, TimelineOrigin origin) => + ( + bucketSource: () async* { + yield _generateBuckets(getAssets().length); + yield* assetCount.map(_generateBuckets); + }, + assetSource: (offset, count) { + final assets = getAssets(); + return Future.value(assets.skip(offset).take(count).toList(growable: false)); + }, + origin: origin, + ); + TimelineQuery fromAssetsWithBuckets(List assets, TimelineOrigin origin) { // Sort assets by date descending and group by day final sorted = List.from(assets)..sort((a, b) => b.createdAt.compareTo(a.createdAt)); diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 0ce3f20641..701a6ff74a 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -80,51 +80,28 @@ class DriftSearchPage extends HookConsumerWidget { final ratingCurrentFilterWidget = useState(null); final displayOptionCurrentFilterWidget = useState(null); - final isSearching = useState(false); - final userPreferences = ref.watch(userMetadataPreferencesProvider); - SnackBar searchInfoSnackBar(String message) { - return SnackBar( - content: Text(message, style: context.textTheme.labelLarge), - showCloseIcon: true, - behavior: SnackBarBehavior.fixed, - closeIconColor: context.colorScheme.onSurface, - ); - } - - searchFilter(SearchFilter filter) async { - if (filter.isEmpty) { - return; - } - + searchFilter(SearchFilter filter) { if (preFilter == null && filter == previousFilter.value) { return; } - isSearching.value = true; - ref.watch(paginatedSearchProvider.notifier).clear(); - final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter); + ref.read(paginatedSearchProvider.notifier).clear(); - if (!hasResult) { - context.showSnackBar(searchInfoSnackBar('search_no_result'.t(context: context))); + if (filter.isEmpty) { + previousFilter.value = null; + return; } + unawaited(ref.read(paginatedSearchProvider.notifier).search(filter)); previousFilter.value = filter; - isSearching.value = false; } search() => searchFilter(filter.value); - loadMoreSearchResult() async { - isSearching.value = true; - final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value); - - if (!hasResult) { - context.showSnackBar(searchInfoSnackBar('search_no_more_result'.t(context: context))); - } - - isSearching.value = false; + loadMoreSearchResults() { + unawaited(ref.read(paginatedSearchProvider.notifier).search(filter.value)); } searchPreFilter() { @@ -742,10 +719,10 @@ class DriftSearchPage extends HookConsumerWidget { ), ), ), - if (isSearching.value) - const SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator())) + if (filter.value.isEmpty) + const _SearchSuggestions() else - _SearchResultGrid(onScrollEnd: loadMoreSearchResult), + _SearchResultGrid(onScrollEnd: loadMoreSearchResults), ], ), ); @@ -757,45 +734,85 @@ class _SearchResultGrid extends ConsumerWidget { const _SearchResultGrid({required this.onScrollEnd}); - @override - Widget build(BuildContext context, WidgetRef ref) { - final assets = ref.watch(paginatedSearchProvider.select((s) => s.assets)); + bool _onScrollUpdateNotification(ScrollNotification notification) { + final metrics = notification.metrics; - if (assets.isEmpty) { - return const _SearchEmptyContent(); + if (metrics.axis != Axis.vertical) return false; + + final isBottomSheet = notification.context?.findAncestorWidgetOfExactType() != null; + final remaining = metrics.maxScrollExtent - metrics.pixels; + + if (remaining < metrics.viewportDimension && !isBottomSheet) { + onScrollEnd(); } - return NotificationListener( - onNotification: (notification) { - final isBottomSheetNotification = - notification.context?.findAncestorWidgetOfExactType() != null; + return false; + } - final metrics = notification.metrics; - final isVerticalScroll = metrics.axis == Axis.vertical; + Widget? _bottomWidget(BuildContext context, WidgetRef ref) { + final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading)); - if (metrics.pixels >= metrics.maxScrollExtent && isVerticalScroll && !isBottomSheetNotification) { - onScrollEnd(); - ref.read(paginatedSearchProvider.notifier).setScrollOffset(metrics.maxScrollExtent); - } + if (isLoading) { + return const SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ); + } - return true; - }, + final hasMore = ref.watch(paginatedSearchProvider.select((s) => s.nextPage != null)); + + if (hasMore) return null; + + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Center( + child: Text( + 'search_no_more_result'.t(context: context), + style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final hasAssets = ref.watch(paginatedSearchProvider.select((s) => s.assets.isNotEmpty)); + final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading)); + + if (!hasAssets && !isLoading) { + return const _SearchNoResults(); + } + + return NotificationListener( + onNotification: _onScrollUpdateNotification, child: SliverFillRemaining( child: ProviderScope( overrides: [ timelineServiceProvider.overrideWith((ref) { - final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets, TimelineOrigin.search); - ref.onDispose(timelineService.dispose); - return timelineService; + final notifier = ref.read(paginatedSearchProvider.notifier); + final service = ref + .watch(timelineFactoryProvider) + .fromAssetStream( + () => ref.read(paginatedSearchProvider).assets, + notifier.assetCount, + TimelineOrigin.search, + ); + ref.onDispose(service.dispose); + return service; }), ], child: Timeline( - key: ValueKey(assets.length), groupBy: GroupAssetsBy.none, appBar: null, bottomSheet: const GeneralBottomSheet(minChildSize: 0.20), snapToMonth: false, - initialScrollOffset: ref.read(paginatedSearchProvider.select((s) => s.scrollOffset)), + loadingWidget: const SizedBox.shrink(), + bottomSliverWidget: _bottomWidget(context, ref), ), ), ), @@ -803,8 +820,35 @@ class _SearchResultGrid extends ConsumerWidget { } } -class _SearchEmptyContent extends StatelessWidget { - const _SearchEmptyContent(); +class _SearchNoResults extends StatelessWidget { + const _SearchNoResults(); + + @override + Widget build(BuildContext context) { + return SliverFillRemaining( + hasScrollBody: false, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.all(48), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.search_off_rounded, size: 72, color: context.colorScheme.onSurfaceVariant), + const SizedBox(height: 24), + Text( + 'search_no_result'.t(context: context), + textAlign: TextAlign.center, + style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ); + } +} + +class _SearchSuggestions extends StatelessWidget { + const _SearchSuggestions(); @override Widget build(BuildContext context) { diff --git a/mobile/lib/presentation/pages/search/paginated_search.provider.dart b/mobile/lib/presentation/pages/search/paginated_search.provider.dart index e37aa7e0af..f65ca6b909 100644 --- a/mobile/lib/presentation/pages/search/paginated_search.provider.dart +++ b/mobile/lib/presentation/pages/search/paginated_search.provider.dart @@ -1,5 +1,7 @@ +import 'dart:async'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/search_result.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/search.service.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/providers/infrastructure/search.provider.dart'; @@ -21,40 +23,52 @@ class SearchFilterProvider extends Notifier { } } -final paginatedSearchProvider = StateNotifierProvider( +class SearchState { + final List assets; + final int? nextPage; + final bool isLoading; + + const SearchState({this.assets = const [], this.nextPage = 1, this.isLoading = false}); +} + +final paginatedSearchProvider = StateNotifierProvider( (ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)), ); -class PaginatedSearchNotifier extends StateNotifier { +class PaginatedSearchNotifier extends StateNotifier { final SearchService _searchService; + final _assetCountController = StreamController.broadcast(); - PaginatedSearchNotifier(this._searchService) : super(const SearchResult(assets: [], nextPage: 1)); + PaginatedSearchNotifier(this._searchService) : super(const SearchState()); - Future search(SearchFilter filter) async { - if (state.nextPage == null) { - return false; - } + Stream get assetCount => _assetCountController.stream; + + Future search(SearchFilter filter) async { + if (state.nextPage == null || state.isLoading) return; + + state = SearchState(assets: state.assets, nextPage: state.nextPage, isLoading: true); final result = await _searchService.search(filter, state.nextPage!); if (result == null) { - return false; + state = SearchState(assets: state.assets, nextPage: state.nextPage); + return; } - state = SearchResult( - assets: [...state.assets, ...result.assets], - nextPage: result.nextPage, - scrollOffset: state.scrollOffset, - ); + final assets = [...state.assets, ...result.assets]; + state = SearchState(assets: assets, nextPage: result.nextPage); - return true; + _assetCountController.add(assets.length); } - void setScrollOffset(double offset) { - state = state.copyWith(scrollOffset: offset); + void clear() { + state = const SearchState(); + _assetCountController.add(0); } - clear() { - state = const SearchResult(assets: [], nextPage: 1, scrollOffset: 0.0); + @override + void dispose() { + _assetCountController.close(); + super.dispose(); } } diff --git a/mobile/lib/presentation/widgets/timeline/constants.dart b/mobile/lib/presentation/widgets/timeline/constants.dart index 3b4269925c..84892c79f7 100644 --- a/mobile/lib/presentation/widgets/timeline/constants.dart +++ b/mobile/lib/presentation/widgets/timeline/constants.dart @@ -5,6 +5,7 @@ const Size kTimelineFixedTileExtent = Size.square(256); const double kTimelineSpacing = 2.0; const int kTimelineColumnCount = 3; +const double kScrubberThumbHeight = 48.0; const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300); const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800); diff --git a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart index d31048fbb5..f0dfef571c 100644 --- a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart @@ -530,12 +530,14 @@ class _CircularThumb extends StatelessWidget { elevation: 4.0, color: backgroundColor, borderRadius: const BorderRadius.only( - topLeft: Radius.circular(48.0), - bottomLeft: Radius.circular(48.0), + topLeft: Radius.circular(kScrubberThumbHeight), + bottomLeft: Radius.circular(kScrubberThumbHeight), topRight: Radius.circular(4.0), bottomRight: Radius.circular(4.0), ), - child: Container(constraints: BoxConstraints.tight(const Size(48.0 * 0.6, 48.0))), + child: Container( + constraints: BoxConstraints.tight(const Size(kScrubberThumbHeight * 0.6, kScrubberThumbHeight)), + ), ), ); } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 4d72a9b0a5..8d494a8452 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -17,6 +17,7 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; @@ -34,6 +35,7 @@ class Timeline extends StatelessWidget { super.key, this.topSliverWidget, this.topSliverWidgetHeight, + this.bottomSliverWidget, this.showStorageIndicator = false, this.withStack = false, this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false), @@ -41,13 +43,14 @@ class Timeline extends StatelessWidget { this.groupBy, this.withScrubber = true, this.snapToMonth = true, - this.initialScrollOffset, this.readOnly = false, this.persistentBottomBar = false, + this.loadingWidget, }); final Widget? topSliverWidget; final double? topSliverWidgetHeight; + final Widget? bottomSliverWidget; final bool showStorageIndicator; final Widget? appBar; final Widget? bottomSheet; @@ -55,9 +58,9 @@ class Timeline extends StatelessWidget { final GroupAssetsBy? groupBy; final bool withScrubber; final bool snapToMonth; - final double? initialScrollOffset; final bool readOnly; final bool persistentBottomBar; + final Widget? loadingWidget; @override Widget build(BuildContext context) { @@ -82,13 +85,14 @@ class Timeline extends StatelessWidget { child: _SliverTimeline( topSliverWidget: topSliverWidget, topSliverWidgetHeight: topSliverWidgetHeight, + bottomSliverWidget: bottomSliverWidget, appBar: appBar, bottomSheet: bottomSheet, withScrubber: withScrubber, persistentBottomBar: persistentBottomBar, snapToMonth: snapToMonth, - initialScrollOffset: initialScrollOffset, maxWidth: constraints.maxWidth, + loadingWidget: loadingWidget, ), ), ), @@ -111,24 +115,26 @@ class _SliverTimeline extends ConsumerStatefulWidget { const _SliverTimeline({ this.topSliverWidget, this.topSliverWidgetHeight, + this.bottomSliverWidget, this.appBar, this.bottomSheet, this.withScrubber = true, this.persistentBottomBar = false, this.snapToMonth = true, - this.initialScrollOffset, this.maxWidth, + this.loadingWidget, }); final Widget? topSliverWidget; final double? topSliverWidgetHeight; + final Widget? bottomSliverWidget; final Widget? appBar; final Widget? bottomSheet; final bool withScrubber; final bool persistentBottomBar; final bool snapToMonth; - final double? initialScrollOffset; final double? maxWidth; + final Widget? loadingWidget; @override ConsumerState createState() => _SliverTimelineState(); @@ -152,10 +158,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { @override void initState() { super.initState(); - _scrollController = ScrollController( - initialScrollOffset: widget.initialScrollOffset ?? 0.0, - onAttach: _restoreAssetPosition, - ); + _scrollController = ScrollController(onAttach: _restoreAssetPosition); _eventSubscription = EventStream.shared.listen(_onEvent); final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow); @@ -373,6 +376,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { } }, child: asyncSegments.widgetWhen( + onLoading: widget.loadingWidget != null ? () => widget.loadingWidget! : null, onData: (segments) { final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar @@ -380,12 +384,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { : 0; final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10; - const scrubberBottomPadding = 100.0; const bottomSheetOpenModifier = 120.0; - final bottomPadding = - context.padding.bottom + - (widget.appBar == null ? 0 : scrubberBottomPadding) + - (isMultiSelectEnabled ? bottomSheetOpenModifier : 0); + final contentBottomPadding = context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0); + final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight; final grid = CustomScrollView( primary: true, @@ -408,7 +409,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { addRepaintBoundaries: false, ), ), - SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)), + if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!, + SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)), ], ); @@ -419,7 +421,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { layoutSegments: segments, timelineHeight: maxHeight, topPadding: topPadding, - bottomPadding: bottomPadding, + bottomPadding: scrubberBottomPadding, monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight, hasAppBar: widget.appBar != null, child: grid, From 480b7e8d65d8f7540b1195c0dc435a8cbc0fd9c7 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Wed, 4 Mar 2026 14:55:02 -0500 Subject: [PATCH 075/150] chore: configure ESLint flat config and auto-fix on save in VSCode settings (#26679) --- .vscode/settings.json | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 54c018259b..76867e9b06 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,6 +15,7 @@ }, "[javascript]": { "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", "source.organizeImports": "explicit", "source.removeUnusedImports": "explicit" }, @@ -34,6 +35,7 @@ }, "[svelte]": { "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", "source.organizeImports": "explicit", "source.removeUnusedImports": "explicit" }, @@ -43,6 +45,7 @@ }, "[typescript]": { "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", "source.organizeImports": "explicit", "source.removeUnusedImports": "explicit" }, @@ -51,14 +54,43 @@ "editor.tabSize": 2 }, "cSpell.words": ["immich"], + "css.lint.unknownAtRules": "ignore", + "editor.bracketPairColorization.enabled": true, "editor.formatOnSave": true, + "editor.guides.bracketPairs": "active", + "eslint.useFlatConfig": true, "eslint.validate": ["javascript", "typescript", "svelte"], + "eslint.workingDirectories": [ + { "directory": "cli", "changeProcessCWD": true }, + { "directory": "e2e", "changeProcessCWD": true }, + { "directory": "server", "changeProcessCWD": true }, + { "directory": "web", "changeProcessCWD": true } + ], + "files.watcherExclude": { + "**/.jj/**": true, + "**/.git/**": true, + "**/node_modules/**": true, + "**/build/**": true, + "**/dist/**": true, + "**/.svelte-kit/**": true + }, "explorer.fileNesting.enabled": true, "explorer.fileNesting.patterns": { "*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart", "*.ts": "${capture}.spec.ts,${capture}.mock.ts", "package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs" }, + "search.exclude": { + "**/node_modules": true, + "**/build": true, + "**/dist": true, + "**/.svelte-kit": true, + "**/open-api/typescript-sdk/src": true + }, "svelte.enable-ts-plugin": true, - "typescript.preferences.importModuleSpecifier": "non-relative" + "tailwindCSS.experimental.configFile": { + "web/src/app.css": "web/src/**" + }, + "typescript.preferences.importModuleSpecifier": "non-relative", + "vitest.maximumConfigs": 10 } From e9451f10d6d8ac78326414b58c449fd5a922b22f Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:53:20 +0100 Subject: [PATCH 076/150] chore(web): small cleanup of timeline month (#26708) --- web/src/lib/components/timeline/Month.svelte | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/web/src/lib/components/timeline/Month.svelte b/web/src/lib/components/timeline/Month.svelte index 8a93dae633..91073a0a5f 100644 --- a/web/src/lib/components/timeline/Month.svelte +++ b/web/src/lib/components/timeline/Month.svelte @@ -35,7 +35,6 @@ let { isUploading } = uploadAssetsStore; let hoveredDayGroup = $state(null); - const isMouseOverGroup = $derived(hoveredDayGroup !== null); const transitionDuration = $derived(monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150); const filterIntersecting = (intersectables: T[]) => { @@ -68,7 +67,7 @@ onmouseenter={() => (hoveredDayGroup = dayGroup.groupTitle)} onmouseleave={() => (hoveredDayGroup = null)} > - +
onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))} onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))} > {#if isDayGroupSelected} {:else} - + {/if}
{/if} From 33d75462c9dfbd45b37b60571115ee75f96bb9dc Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:58:26 +0100 Subject: [PATCH 077/150] fix(web): combobox dropdown positioning in modals (#26707) --- .../shared-components/combobox.svelte | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index be1b73e1c5..7230146886 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -180,6 +180,17 @@ onSelect(selectedOption); }; + // TODO: move this combobox component into @immich/ui + // Bits UI dialogs use `contain: layout` so fixed descendants are positioned in dialog space + const getModalBounds = () => { + const modalRoot = input?.closest('[data-dialog-content]'); + if (!modalRoot || !getComputedStyle(modalRoot).contain.includes('layout')) { + return; + } + + return modalRoot.getBoundingClientRect(); + }; + const calculatePosition = (boundary: DOMRect | undefined) => { const visualViewport = window.visualViewport; @@ -187,29 +198,35 @@ return; } - const left = boundary.left + (visualViewport?.offsetLeft || 0); - const offsetTop = visualViewport?.offsetTop || 0; + const modalBounds = getModalBounds(); + const offsetTop = modalBounds?.top || 0; + const offsetLeft = modalBounds?.left || 0; + const rootHeight = modalBounds?.height || window.innerHeight; + + const top = boundary.top - offsetTop; + const bottom = boundary.bottom - offsetTop; + const left = boundary.left - offsetLeft; if (dropdownDirection === 'top') { return { - bottom: `${window.innerHeight - boundary.top - offsetTop}px`, + bottom: `${rootHeight - top}px`, left: `${left}px`, width: `${boundary.width}px`, - maxHeight: maxHeight(boundary.top - dropdownOffset), + maxHeight: maxHeight(top - dropdownOffset), }; } - const viewportHeight = visualViewport?.height || 0; - const availableHeight = viewportHeight - boundary.bottom; + const viewportHeight = visualViewport?.height || rootHeight; + const availableHeight = modalBounds ? rootHeight - bottom : viewportHeight - boundary.bottom; return { - top: `${boundary.bottom + offsetTop}px`, + top: `${bottom}px`, left: `${left}px`, width: `${boundary.width}px`, maxHeight: maxHeight(availableHeight - dropdownOffset), }; }; - const maxHeight = (size: number) => `min(${size}px,18rem)`; + const maxHeight = (size: number) => `min(${Math.max(size, 0)}px,18rem)`; const onPositionChange = () => { if (!isOpen) { From 78ba9cbc635fb4d2a7a12b1c334e5cec235ebcca Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:59:51 +0100 Subject: [PATCH 078/150] chore(deps): update dependency multer to v2.1.1 [security] (#26705) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a026d30d90..63ad290f7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -504,7 +504,7 @@ importers: version: 0.40.3 multer: specifier: ^2.0.2 - version: 2.1.0 + version: 2.1.1 nest-commander: specifier: ^3.16.0 version: 3.20.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@types/inquirer@8.2.12)(@types/node@24.11.0)(typescript@5.9.3) @@ -9118,8 +9118,8 @@ packages: resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} engines: {node: '>= 10.16.0'} - multer@2.1.0: - resolution: {integrity: sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==} + multer@2.1.1: + resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} engines: {node: '>= 10.16.0'} multicast-dns@7.2.5: @@ -22057,7 +22057,7 @@ snapshots: type-is: 1.6.18 xtend: 4.0.2 - multer@2.1.0: + multer@2.1.1: dependencies: append-field: 1.0.0 busboy: 1.6.0 From c259fee3093b1c7bcef308d72744c44d5231266e Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:12:59 +0100 Subject: [PATCH 079/150] chore: cleanup vscode settings (#26709) --- .vscode/settings.json | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 76867e9b06..496e7539e1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,7 @@ { "[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true, - "editor.tabSize": 2 + "editor.formatOnSave": true }, "[dart]": { "editor.defaultFormatter": "Dart-Code.dart-code", @@ -15,49 +14,40 @@ }, "[javascript]": { "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit", "source.organizeImports": "explicit", "source.removeUnusedImports": "explicit" }, "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true, - "editor.tabSize": 2 + "editor.formatOnSave": true }, "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true, - "editor.tabSize": 2 + "editor.formatOnSave": true }, "[jsonc]": { "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true, - "editor.tabSize": 2 + "editor.formatOnSave": true }, "[svelte]": { "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit", "source.organizeImports": "explicit", "source.removeUnusedImports": "explicit" }, "editor.defaultFormatter": "svelte.svelte-vscode", - "editor.formatOnSave": true, - "editor.tabSize": 2 + "editor.formatOnSave": true }, "[typescript]": { "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit", "source.organizeImports": "explicit", "source.removeUnusedImports": "explicit" }, "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true, - "editor.tabSize": 2 + "editor.formatOnSave": true }, "cSpell.words": ["immich"], "css.lint.unknownAtRules": "ignore", "editor.bracketPairColorization.enabled": true, "editor.formatOnSave": true, - "editor.guides.bracketPairs": "active", "eslint.useFlatConfig": true, "eslint.validate": ["javascript", "typescript", "svelte"], "eslint.workingDirectories": [ @@ -91,6 +81,6 @@ "tailwindCSS.experimental.configFile": { "web/src/app.css": "web/src/**" }, - "typescript.preferences.importModuleSpecifier": "non-relative", + "js/ts.preferences.importModuleSpecifier": "non-relative", "vitest.maximumConfigs": 10 } From 09fabb36b620549c68cfd3e5a9a30b85dc1fb22d Mon Sep 17 00:00:00 2001 From: Snowknight26 Date: Thu, 5 Mar 2026 09:41:27 -0600 Subject: [PATCH 080/150] fix(web): video stealing focus when it plays again when looping (#26704) --- .../lib/components/asset-viewer/video-native-viewer.svelte | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index 78fdc3a1ba..e53414be07 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -50,6 +50,7 @@ ); let isScrubbing = $state(false); let showVideo = $state(false); + let hasFocused = $state(false); onMount(() => { // Show video after mount to ensure fading in. @@ -59,6 +60,7 @@ $effect(() => { // reactive on `assetFileUrl` changes if (assetFileUrl) { + hasFocused = false; videoPlayer?.load(); } }); @@ -151,7 +153,10 @@ onseeking={() => (isScrubbing = true)} onseeked={() => (isScrubbing = false)} onplaying={(e) => { - e.currentTarget.focus(); + if (!hasFocused) { + e.currentTarget.focus(); + hasFocused = true; + } }} onclose={() => onClose()} muted={$videoViewerMuted} From 35a521c6ec95eff2a4b5d8808bfb38fec6203a4a Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:01:47 -0500 Subject: [PATCH 081/150] fix(ml): batch size setting (#26524) --- docs/docs/install/environment-variables.md | 2 + machine-learning/immich_ml/config.py | 5 +- .../models/facial_recognition/recognition.py | 2 +- .../immich_ml/models/ocr/detection.py | 4 +- .../immich_ml/models/ocr/recognition.py | 7 +- machine-learning/test_main.py | 77 ++++++++++++++++++- 6 files changed, 89 insertions(+), 8 deletions(-) diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 07b37f0e41..e9e3bb032c 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -166,6 +166,8 @@ Redis (Sentinel) URL example JSON before encoding: | `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning | | `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning | | `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning | +| `MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION` | Comma-separated list of (recognition) OCR model(s) to preload and cache | | machine learning | +| `MACHINE_LEARNING_PRELOAD__OCR__DETECTION` | Comma-separated list of (detection) OCR model(s) to preload and cache | | machine learning | | `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning | | `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning | | `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | diff --git a/machine-learning/immich_ml/config.py b/machine-learning/immich_ml/config.py index 08dca04a4d..8b383f5419 100644 --- a/machine-learning/immich_ml/config.py +++ b/machine-learning/immich_ml/config.py @@ -48,8 +48,11 @@ class PreloadModelData(BaseModel): class MaxBatchSize(BaseModel): + ocr_fallback: str | None = os.getenv("MACHINE_LEARNING_MAX_BATCH_SIZE__TEXT_RECOGNITION", None) + if ocr_fallback is not None: + os.environ["MACHINE_LEARNING_MAX_BATCH_SIZE__OCR"] = ocr_fallback facial_recognition: int | None = None - text_recognition: int | None = None + ocr: int | None = None class Settings(BaseSettings): diff --git a/machine-learning/immich_ml/models/facial_recognition/recognition.py b/machine-learning/immich_ml/models/facial_recognition/recognition.py index 759992a600..ed1897c9f9 100644 --- a/machine-learning/immich_ml/models/facial_recognition/recognition.py +++ b/machine-learning/immich_ml/models/facial_recognition/recognition.py @@ -29,7 +29,7 @@ class FaceRecognizer(InferenceModel): def __init__(self, model_name: str, **model_kwargs: Any) -> None: super().__init__(model_name, **model_kwargs) - max_batch_size = settings.max_batch_size.facial_recognition if settings.max_batch_size else None + max_batch_size = settings.max_batch_size and settings.max_batch_size.facial_recognition self.batch_size = max_batch_size if max_batch_size else self._batch_size_default def _load(self) -> ModelSession: diff --git a/machine-learning/immich_ml/models/ocr/detection.py b/machine-learning/immich_ml/models/ocr/detection.py index d34a51684e..0a2cb8ad91 100644 --- a/machine-learning/immich_ml/models/ocr/detection.py +++ b/machine-learning/immich_ml/models/ocr/detection.py @@ -22,7 +22,7 @@ class TextDetector(InferenceModel): depends = [] identity = (ModelType.DETECTION, ModelTask.OCR) - def __init__(self, model_name: str, **model_kwargs: Any) -> None: + def __init__(self, model_name: str, min_score: float = 0.5, **model_kwargs: Any) -> None: super().__init__(model_name.split("__")[-1], **model_kwargs, model_format=ModelFormat.ONNX) self.max_resolution = 736 self.mean = np.array([0.5, 0.5, 0.5], dtype=np.float32) @@ -33,7 +33,7 @@ class TextDetector(InferenceModel): } self.postprocess = DBPostProcess( thresh=0.3, - box_thresh=model_kwargs.get("minScore", 0.5), + box_thresh=model_kwargs.get("minScore", min_score), max_candidates=1000, unclip_ratio=1.6, use_dilation=True, diff --git a/machine-learning/immich_ml/models/ocr/recognition.py b/machine-learning/immich_ml/models/ocr/recognition.py index e968392881..6408e4818f 100644 --- a/machine-learning/immich_ml/models/ocr/recognition.py +++ b/machine-learning/immich_ml/models/ocr/recognition.py @@ -24,9 +24,9 @@ class TextRecognizer(InferenceModel): depends = [(ModelType.DETECTION, ModelTask.OCR)] identity = (ModelType.RECOGNITION, ModelTask.OCR) - def __init__(self, model_name: str, **model_kwargs: Any) -> None: + def __init__(self, model_name: str, min_score: float = 0.9, **model_kwargs: Any) -> None: self.language = LangRec[model_name.split("__")[0]] if "__" in model_name else LangRec.CH - self.min_score = model_kwargs.get("minScore", 0.9) + self.min_score = model_kwargs.get("minScore", min_score) self._empty: TextRecognitionOutput = { "box": np.empty(0, dtype=np.float32), "boxScore": np.empty(0, dtype=np.float32), @@ -57,10 +57,11 @@ class TextRecognizer(InferenceModel): def _load(self) -> ModelSession: # TODO: support other runtimes session = OrtSession(self.model_path) + max_batch_size = settings.max_batch_size and settings.max_batch_size.ocr self.model = RapidTextRecognizer( OcrOptions( session=session.session, - rec_batch_num=settings.max_batch_size.text_recognition if settings.max_batch_size is not None else 6, + rec_batch_num=max_batch_size if max_batch_size else 6, rec_img_shape=(3, 48, 320), lang_type=self.language, ) diff --git a/machine-learning/test_main.py b/machine-learning/test_main.py index f37880610a..a5cf1acc2e 100644 --- a/machine-learning/test_main.py +++ b/machine-learning/test_main.py @@ -18,7 +18,7 @@ from PIL import Image from pytest import MonkeyPatch from pytest_mock import MockerFixture -from immich_ml.config import Settings, settings +from immich_ml.config import MaxBatchSize, Settings, settings from immich_ml.main import load, preload_models from immich_ml.models.base import InferenceModel from immich_ml.models.cache import ModelCache @@ -26,6 +26,9 @@ from immich_ml.models.clip.textual import MClipTextualEncoder, OpenClipTextualEn from immich_ml.models.clip.visual import OpenClipVisualEncoder from immich_ml.models.facial_recognition.detection import FaceDetector from immich_ml.models.facial_recognition.recognition import FaceRecognizer +from immich_ml.models.ocr.detection import TextDetector +from immich_ml.models.ocr.recognition import TextRecognizer +from immich_ml.models.ocr.schemas import OcrOptions from immich_ml.schemas import ModelFormat, ModelPrecision, ModelTask, ModelType from immich_ml.sessions.ann import AnnSession from immich_ml.sessions.ort import OrtSession @@ -855,6 +858,78 @@ class TestFaceRecognition: onnx.load.assert_not_called() onnx.save.assert_not_called() + def test_set_custom_max_batch_size(self, mocker: MockerFixture) -> None: + mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=2)) + + recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache") + + assert recognizer.batch_size == 2 + + def test_ignore_other_custom_max_batch_size(self, mocker: MockerFixture) -> None: + mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=2)) + + recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache") + + assert recognizer.batch_size is None + + +class TestOcr: + def test_set_det_min_score(self, path: mock.Mock) -> None: + path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx" + + text_detector = TextDetector("PP-OCRv5_mobile", min_score=0.8, cache_dir="test_cache") + + assert text_detector.postprocess.box_thresh == 0.8 + + def test_set_rec_min_score(self, path: mock.Mock) -> None: + path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx" + + text_recognizer = TextRecognizer("PP-OCRv5_mobile", min_score=0.8, cache_dir="test_cache") + + assert text_recognizer.min_score == 0.8 + + def test_set_rec_set_default_max_batch_size( + self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture + ) -> None: + path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx" + mocker.patch("immich_ml.models.base.InferenceModel.download") + rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer") + + text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache") + text_recognizer.load() + + rapid_recognizer.assert_called_once_with( + OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320)) + ) + + def test_set_custom_max_batch_size(self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture) -> None: + path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx" + mocker.patch("immich_ml.models.base.InferenceModel.download") + rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer") + mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=4)) + + text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache") + text_recognizer.load() + + rapid_recognizer.assert_called_once_with( + OcrOptions(session=ort_session.return_value, rec_batch_num=4, rec_img_shape=(3, 48, 320)) + ) + + def test_ignore_other_custom_max_batch_size( + self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture + ) -> None: + path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx" + mocker.patch("immich_ml.models.base.InferenceModel.download") + rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer") + mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=3)) + + text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache") + text_recognizer.load() + + rapid_recognizer.assert_called_once_with( + OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320)) + ) + @pytest.mark.asyncio class TestCache: From a05c8c60875ccf3699ea1700e47363ef117fa859 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:04:45 -0500 Subject: [PATCH 082/150] feat(mobile): use shared native client (#25942) * use shared client in dart fix android * websocket integration platform-side headers update comment consistent platform check tweak websocket handling support streaming * redundant logging * fix proguard * formatting * handle onProgress * support videos on ios * inline return * improved ios impl * cleanup * sync stopForegroundBackup * voidify * future already completed * stream request on android * outdated ios ws code * use `choosePrivateKeyAlias` * return result * formatting * update tests * redundant check * handle custom headers * move completer outside of state * persist auth * dispose old socket * use group id for cookies * redundant headers * cache global ref * handle network switching * handle basic auth * apply custom headers immediately * video player update * fix * persist url * potential logout fix --------- Co-authored-by: Alex --- mobile/android/app/build.gradle | 1 + mobile/android/app/proguard-rules.pro | 10 +- .../android/app/src/main/cpp/native_buffer.c | 14 ++ .../app/alextran/immich/NativeBuffer.kt | 3 + .../alextran/immich/core/HttpClientManager.kt | 137 +++++++++++++++-- .../app/alextran/immich/core/Network.g.kt | 59 ++++++- .../alextran/immich/core/NetworkApiPlugin.kt | 132 ++++------------ .../alextran/immich/images/RemoteImages.g.kt | 9 +- .../immich/images/RemoteImagesImpl.kt | 18 +-- mobile/ios/Runner/Core/Network.g.swift | 51 +++++- mobile/ios/Runner/Core/NetworkApiImpl.swift | 68 +++++++- .../ios/Runner/Core/URLSessionManager.swift | 109 +++++++++---- mobile/ios/Runner/Images/RemoteImages.g.swift | 9 +- .../ios/Runner/Images/RemoteImagesImpl.swift | 5 +- .../services/background_worker.service.dart | 8 +- .../loaders/remote_image_request.dart | 7 +- .../repositories/network.repository.dart | 92 +++++------ .../repositories/sync_api.repository.dart | 9 +- mobile/lib/main.dart | 4 +- .../lib/models/backup/backup_state.model.dart | 9 +- .../backup/manual_upload_state.model.dart | 10 +- .../lib/pages/backup/drift_backup.page.dart | 8 +- .../drift_backup_album_selection.page.dart | 17 +- .../backup/drift_backup_options.page.dart | 17 +- .../pages/common/headers_settings.page.dart | 9 +- mobile/lib/platform/network_api.g.dart | 88 ++++++++++- mobile/lib/platform/remote_image_api.g.dart | 14 +- .../pages/editing/drift_edit.page.dart | 3 +- .../upload_action_button.widget.dart | 3 +- .../widgets/images/remote_image_provider.dart | 7 +- .../providers/app_life_cycle.provider.dart | 8 +- mobile/lib/providers/auth.provider.dart | 1 + .../asset_upload_progress.provider.dart | 5 +- .../lib/providers/backup/backup.provider.dart | 12 +- .../backup/drift_backup.provider.dart | 38 ++--- .../backup/manual_upload.provider.dart | 15 +- .../providers/image/cache/image_loader.dart | 40 ----- .../cache/remote_image_cache_manager.dart | 25 --- .../cache/thumbnail_image_cache_manager.dart | 13 -- .../infrastructure/action.provider.dart | 5 +- mobile/lib/providers/websocket.provider.dart | 17 +- .../lib/repositories/upload.repository.dart | 80 +++++----- mobile/lib/services/api.service.dart | 54 ++++--- mobile/lib/services/auth.service.dart | 15 +- mobile/lib/services/background.service.dart | 15 +- mobile/lib/services/backup.service.dart | 53 ++----- .../services/foreground_upload.service.dart | 145 +++++++----------- mobile/lib/utils/http_ssl_cert_override.dart | 61 -------- mobile/lib/utils/http_ssl_options.dart | 27 ---- mobile/lib/utils/isolate.dart | 2 - .../widgets/settings/advanced_settings.dart | 14 +- .../settings/ssl_client_cert_settings.dart | 40 +++-- mobile/pigeon/network_api.dart | 8 +- mobile/pigeon/remote_image_api.dart | 10 +- mobile/pubspec.lock | 73 ++++----- mobile/pubspec.yaml | 21 ++- .../sync_api_repository_test.dart | 8 - 57 files changed, 880 insertions(+), 855 deletions(-) delete mode 100644 mobile/lib/providers/image/cache/image_loader.dart delete mode 100644 mobile/lib/providers/image/cache/remote_image_cache_manager.dart delete mode 100644 mobile/lib/providers/image/cache/thumbnail_image_cache_manager.dart delete mode 100644 mobile/lib/utils/http_ssl_cert_override.dart delete mode 100644 mobile/lib/utils/http_ssl_options.dart diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 4999f9a7f9..0839000dd0 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -81,6 +81,7 @@ android { release { signingConfig signingConfigs.release + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } namespace 'app.alextran.immich' diff --git a/mobile/android/app/proguard-rules.pro b/mobile/android/app/proguard-rules.pro index 898caee06c..af43ae23c2 100644 --- a/mobile/android/app/proguard-rules.pro +++ b/mobile/android/app/proguard-rules.pro @@ -36,4 +36,12 @@ ##---------------End: proguard configuration for Gson ---------- # Keep all widget model classes and their fields for Gson --keep class app.alextran.immich.widget.model.** { *; } \ No newline at end of file +-keep class app.alextran.immich.widget.model.** { *; } + +##---------------Begin: proguard configuration for ok_http JNI ---------- +# The ok_http Dart plugin accesses OkHttp and Okio classes via JNI +# string-based reflection (JClass.forName), which R8 cannot trace. +-keep class okhttp3.** { *; } +-keep class okio.** { *; } +-keep class com.example.ok_http.** { *; } +##---------------End: proguard configuration for ok_http JNI ---------- diff --git a/mobile/android/app/src/main/cpp/native_buffer.c b/mobile/android/app/src/main/cpp/native_buffer.c index bcc9d5c7c8..bed1045382 100644 --- a/mobile/android/app/src/main/cpp/native_buffer.c +++ b/mobile/android/app/src/main/cpp/native_buffer.c @@ -36,3 +36,17 @@ Java_app_alextran_immich_NativeBuffer_copy( memcpy((void *) destAddress, (char *) src + offset, length); } } + +/** + * Creates a JNI global reference to the given object and returns its address. + * The caller is responsible for deleting the global reference when it's no longer needed. + */ +JNIEXPORT jlong JNICALL +Java_app_alextran_immich_NativeBuffer_createGlobalRef(JNIEnv *env, jobject clazz, jobject obj) { + if (obj == NULL) { + return 0; + } + + jobject globalRef = (*env)->NewGlobalRef(env, obj); + return (jlong) globalRef; +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt index a9011f3047..74f0241850 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt @@ -23,6 +23,9 @@ object NativeBuffer { @JvmStatic external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int) + + @JvmStatic + external fun createGlobalRef(obj: Any): Long } class NativeByteBuffer(initialCapacity: Int) { diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt index ee92c2120e..37435a9f02 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt @@ -1,11 +1,18 @@ package app.alextran.immich.core import android.content.Context +import android.content.SharedPreferences +import android.security.KeyChain +import androidx.core.content.edit import app.alextran.immich.BuildConfig +import app.alextran.immich.NativeBuffer import okhttp3.Cache import okhttp3.ConnectionPool import okhttp3.Dispatcher +import okhttp3.Headers +import okhttp3.Credentials import okhttp3.OkHttpClient +import org.json.JSONObject import java.io.ByteArrayInputStream import java.io.File import java.net.Socket @@ -20,8 +27,12 @@ import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509KeyManager import javax.net.ssl.X509TrustManager -const val CERT_ALIAS = "client_cert" const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}" +private const val CERT_ALIAS = "client_cert" +private const val PREFS_NAME = "immich.ssl" +private const val PREFS_CERT_ALIAS = "immich.client_cert" +private const val PREFS_HEADERS = "immich.request_headers" +private const val PREFS_SERVER_URL = "immich.server_url" /** * Manages a shared OkHttpClient with SSL configuration support. @@ -36,22 +47,56 @@ object HttpClientManager { private val clientChangedListeners = mutableListOf<() -> Unit>() private lateinit var client: OkHttpClient + private lateinit var appContext: Context + private lateinit var prefs: SharedPreferences private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - val isMtls: Boolean get() = keyStore.containsAlias(CERT_ALIAS) + var keyChainAlias: String? = null + private set + + var headers: Headers = Headers.headersOf() + private set + + val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS) fun initialize(context: Context) { if (initialized) return synchronized(this) { if (initialized) return + appContext = context.applicationContext + prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null) + + val savedHeaders = prefs.getString(PREFS_HEADERS, null) + if (savedHeaders != null) { + val json = JSONObject(savedHeaders) + val builder = Headers.Builder() + for (key in json.keys()) { + builder.add(key, json.getString(key)) + } + headers = builder.build() + } + val cacheDir = File(File(context.cacheDir, "okhttp"), "api") client = build(cacheDir) initialized = true } } + fun setKeyChainAlias(alias: String) { + synchronized(this) { + val wasMtls = isMtls + keyChainAlias = alias + prefs.edit { putString(PREFS_CERT_ALIAS, alias) } + + if (wasMtls != isMtls) { + clientChangedListeners.forEach { it() } + } + } + } + fun setKeyEntry(clientData: ByteArray, password: CharArray) { synchronized(this) { val wasMtls = isMtls @@ -63,7 +108,7 @@ object HttpClientManager { val key = tmpKeyStore.getKey(tmpAlias, password) val chain = tmpKeyStore.getCertificateChain(tmpAlias) - if (wasMtls) { + if (keyStore.containsAlias(CERT_ALIAS)) { keyStore.deleteEntry(CERT_ALIAS) } keyStore.setKeyEntry(CERT_ALIAS, key, null, chain) @@ -75,24 +120,58 @@ object HttpClientManager { fun deleteKeyEntry() { synchronized(this) { - if (!isMtls) { - return + val wasMtls = isMtls + + if (keyChainAlias != null) { + keyChainAlias = null + prefs.edit { remove(PREFS_CERT_ALIAS) } } keyStore.deleteEntry(CERT_ALIAS) - clientChangedListeners.forEach { it() } + + if (wasMtls) { + clientChangedListeners.forEach { it() } + } } } + private var clientGlobalRef: Long = 0L + @JvmStatic fun getClient(): OkHttpClient { return client } + fun getClientPointer(): Long { + if (clientGlobalRef == 0L) { + clientGlobalRef = NativeBuffer.createGlobalRef(client) + } + return clientGlobalRef + } + fun addClientChangedListener(listener: () -> Unit) { synchronized(this) { clientChangedListeners.add(listener) } } + fun setRequestHeaders(headerMap: Map, serverUrls: List) { + synchronized(this) { + val builder = Headers.Builder() + headerMap.forEach { (key, value) -> builder[key] = value } + val newHeaders = builder.build() + val headersChanged = headers != newHeaders + val newUrl = serverUrls.firstOrNull() + val urlChanged = newUrl != prefs.getString(PREFS_SERVER_URL, null) + if (!headersChanged && !urlChanged) return + headers = newHeaders + prefs.edit { + if (headersChanged) putString(PREFS_HEADERS, JSONObject(headerMap).toString()) + if (urlChanged) { + if (newUrl != null) putString(PREFS_SERVER_URL, newUrl) else remove(PREFS_SERVER_URL) + } + } + } + } + private fun build(cacheDir: File): OkHttpClient { val connectionPool = ConnectionPool( maxIdleConnections = KEEP_ALIVE_CONNECTIONS, @@ -109,8 +188,16 @@ object HttpClientManager { HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) return OkHttpClient.Builder() - .addInterceptor { chain -> - chain.proceed(chain.request().newBuilder().header("User-Agent", USER_AGENT).build()) + .addInterceptor { + val request = it.request() + val builder = request.newBuilder() + builder.header("User-Agent", USER_AGENT) + headers.forEach { (key, value) -> builder.header(key, value) } + val url = request.url + if (url.username.isNotEmpty()) { + builder.header("Authorization", Credentials.basic(url.username, url.password)) + } + it.proceed(builder.build()) } .connectionPool(connectionPool) .dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST }) @@ -119,23 +206,39 @@ object HttpClientManager { .build() } - // Reads from the key store rather than taking a snapshot at initialization time + /** + * Resolves client certificates dynamically at TLS handshake time. + * Checks the system KeyChain alias first, then falls back to the app's private KeyStore. + */ private class DynamicKeyManager : X509KeyManager { - override fun getClientAliases(keyType: String, issuers: Array?): Array? = - if (isMtls) arrayOf(CERT_ALIAS) else null + override fun getClientAliases(keyType: String, issuers: Array?): Array? { + val alias = chooseClientAlias(arrayOf(keyType), issuers, null) ?: return null + return arrayOf(alias) + } override fun chooseClientAlias( keyTypes: Array, issuers: Array?, socket: Socket? - ): String? = - if (isMtls) CERT_ALIAS else null + ): String? { + keyChainAlias?.let { return it } + if (keyStore.containsAlias(CERT_ALIAS)) return CERT_ALIAS + return null + } - override fun getCertificateChain(alias: String): Array? = - keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray() + override fun getCertificateChain(alias: String): Array? { + if (alias == keyChainAlias) { + return KeyChain.getCertificateChain(appContext, alias) + } + return keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray() + } - override fun getPrivateKey(alias: String): PrivateKey? = - keyStore.getKey(alias, null) as? PrivateKey + override fun getPrivateKey(alias: String): PrivateKey? { + if (alias == keyChainAlias) { + return KeyChain.getPrivateKey(appContext, alias) + } + return keyStore.getKey(alias, null) as? PrivateKey + } override fun getServerAliases(keyType: String, issuers: Array?): Array? = null diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt index 1e7156a147..5e48d7fef5 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt @@ -180,8 +180,11 @@ private open class NetworkPigeonCodec : StandardMessageCodec() { /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface NetworkApi { fun addCertificate(clientData: ClientCertData, callback: (Result) -> Unit) - fun selectCertificate(promptText: ClientCertPrompt, callback: (Result) -> Unit) + fun selectCertificate(promptText: ClientCertPrompt, callback: (Result) -> Unit) fun removeCertificate(callback: (Result) -> Unit) + fun hasCertificate(): Boolean + fun getClientPointer(): Long + fun setRequestHeaders(headers: Map, serverUrls: List) companion object { /** The codec used by NetworkApi. */ @@ -217,13 +220,12 @@ interface NetworkApi { channel.setMessageHandler { message, reply -> val args = message as List val promptTextArg = args[0] as ClientCertPrompt - api.selectCertificate(promptTextArg) { result: Result -> + api.selectCertificate(promptTextArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(NetworkPigeonUtils.wrapError(error)) } else { - val data = result.getOrNull() - reply.reply(NetworkPigeonUtils.wrapResult(data)) + reply.reply(NetworkPigeonUtils.wrapResult(null)) } } } @@ -248,6 +250,55 @@ interface NetworkApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.hasCertificate()) + } catch (exception: Throwable) { + NetworkPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getClientPointer()) + } catch (exception: Throwable) { + NetworkPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val headersArg = args[0] as Map + val serverUrlsArg = args[1] as List + val wrapped: List = try { + api.setRequestHeaders(headersArg, serverUrlsArg) + listOf(null) + } catch (exception: Throwable) { + NetworkPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt index 4f25896b2f..384c94cce9 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt @@ -2,20 +2,9 @@ package app.alextran.immich.core import android.app.Activity import android.content.Context -import android.net.Uri import android.os.OperationCanceledException -import android.text.InputType -import android.view.ContextThemeWrapper -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.view.ViewGroup.LayoutParams.WRAP_CONTENT -import android.widget.FrameLayout -import android.widget.LinearLayout -import androidx.activity.ComponentActivity -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout +import android.security.KeyChain +import app.alextran.immich.NativeBuffer import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -24,7 +13,7 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware { private var networkApi: NetworkApiImpl? = null override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - networkApi = NetworkApiImpl(binding.applicationContext) + networkApi = NetworkApiImpl() NetworkApi.setUp(binding.binaryMessenger, networkApi) } @@ -34,48 +23,24 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware { } override fun onAttachedToActivity(binding: ActivityPluginBinding) { - networkApi?.onAttachedToActivity(binding) + networkApi?.activity = binding.activity } override fun onDetachedFromActivityForConfigChanges() { - networkApi?.onDetachedFromActivityForConfigChanges() + networkApi?.activity = null } override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - networkApi?.onReattachedToActivityForConfigChanges(binding) + networkApi?.activity = binding.activity } override fun onDetachedFromActivity() { - networkApi?.onDetachedFromActivity() + networkApi?.activity = null } } -private class NetworkApiImpl(private val context: Context) : NetworkApi { - private var activity: Activity? = null - private var pendingCallback: ((Result) -> Unit)? = null - private var filePicker: ActivityResultLauncher>? = null - private var promptText: ClientCertPrompt? = null - - fun onAttachedToActivity(binding: ActivityPluginBinding) { - activity = binding.activity - (binding.activity as? ComponentActivity)?.let { componentActivity -> - filePicker = componentActivity.registerForActivityResult( - ActivityResultContracts.OpenDocument() - ) { uri -> uri?.let { handlePickedFile(it) } ?: pendingCallback?.invoke(Result.failure(OperationCanceledException())) } - } - } - - fun onDetachedFromActivityForConfigChanges() { - activity = null - } - - fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - activity = binding.activity - } - - fun onDetachedFromActivity() { - activity = null - } +private class NetworkApiImpl() : NetworkApi { + var activity: Activity? = null override fun addCertificate(clientData: ClientCertData, callback: (Result) -> Unit) { try { @@ -86,11 +51,19 @@ private class NetworkApiImpl(private val context: Context) : NetworkApi { } } - override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result) -> Unit) { - val picker = filePicker ?: return callback(Result.failure(IllegalStateException("No activity"))) - pendingCallback = callback - this.promptText = promptText - picker.launch(arrayOf("application/x-pkcs12", "application/x-pem-file")) + override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result) -> Unit) { + val currentActivity = activity + ?: return callback(Result.failure(IllegalStateException("No activity"))) + + val onAlias = { alias: String? -> + if (alias != null) { + HttpClientManager.setKeyChainAlias(alias) + callback(Result.success(Unit)) + } else { + callback(Result.failure(OperationCanceledException())) + } + } + KeyChain.choosePrivateKeyAlias(currentActivity, onAlias, null, null, null, null) } override fun removeCertificate(callback: (Result) -> Unit) { @@ -98,62 +71,15 @@ private class NetworkApiImpl(private val context: Context) : NetworkApi { callback(Result.success(Unit)) } - private fun handlePickedFile(uri: Uri) { - val callback = pendingCallback ?: return - pendingCallback = null - - try { - val data = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - ?: throw IllegalStateException("Could not read file") - - val activity = activity ?: throw IllegalStateException("No activity") - promptForPassword(activity) { password -> - promptText = null - if (password == null) { - callback(Result.failure(OperationCanceledException())) - return@promptForPassword - } - try { - HttpClientManager.setKeyEntry(data, password.toCharArray()) - callback(Result.success(ClientCertData(data, password))) - } catch (e: Exception) { - callback(Result.failure(e)) - } - } - } catch (e: Exception) { - callback(Result.failure(e)) - } + override fun hasCertificate(): Boolean { + return HttpClientManager.isMtls } - private fun promptForPassword(activity: Activity, callback: (String?) -> Unit) { - val themedContext = ContextThemeWrapper(activity, com.google.android.material.R.style.Theme_Material3_DayNight_Dialog) - val density = activity.resources.displayMetrics.density - val horizontalPadding = (24 * density).toInt() + override fun getClientPointer(): Long { + return HttpClientManager.getClientPointer() + } - val textInputLayout = TextInputLayout(themedContext).apply { - hint = "Password" - endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE - layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { - setMargins(horizontalPadding, 0, horizontalPadding, 0) - } - } - - val editText = TextInputEditText(textInputLayout.context).apply { - inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) - } - textInputLayout.addView(editText) - - val container = FrameLayout(themedContext).apply { addView(textInputLayout) } - - val text = promptText!! - MaterialAlertDialogBuilder(themedContext) - .setTitle(text.title) - .setMessage(text.message) - .setView(container) - .setPositiveButton(text.confirm) { _, _ -> callback(editText.text.toString()) } - .setNegativeButton(text.cancel) { _, _ -> callback(null) } - .setOnCancelListener { callback(null) } - .show() + override fun setRequestHeaders(headers: Map, serverUrls: List) { + HttpClientManager.setRequestHeaders(headers, serverUrls) } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt index a04dedb676..bef6418904 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt @@ -47,7 +47,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() { /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface RemoteImageApi { - fun requestImage(url: String, headers: Map, requestId: Long, preferEncoded: Boolean, callback: (Result?>) -> Unit) + fun requestImage(url: String, requestId: Long, preferEncoded: Boolean, callback: (Result?>) -> Unit) fun cancelRequest(requestId: Long) fun clearCache(callback: (Result) -> Unit) @@ -66,10 +66,9 @@ interface RemoteImageApi { channel.setMessageHandler { message, reply -> val args = message as List val urlArg = args[0] as String - val headersArg = args[1] as Map - val requestIdArg = args[2] as Long - val preferEncodedArg = args[3] as Boolean - api.requestImage(urlArg, headersArg, requestIdArg, preferEncodedArg) { result: Result?> -> + val requestIdArg = args[1] as Long + val preferEncodedArg = args[2] as Boolean + api.requestImage(urlArg, requestIdArg, preferEncodedArg) { result: Result?> -> val error = result.exceptionOrNull() if (error != null) { reply.reply(RemoteImagesPigeonUtils.wrapError(error)) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index 6b15f33414..21e3c603e6 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -15,6 +15,8 @@ import okhttp3.Callback import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import okhttp3.Credentials +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.chromium.net.CronetEngine import org.chromium.net.CronetException import org.chromium.net.UrlRequest @@ -49,7 +51,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { override fun requestImage( url: String, - headers: Map, requestId: Long, @Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android callback: (Result?>) -> Unit @@ -59,7 +60,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { ImageFetcherManager.fetch( url, - headers, signal, onSuccess = { buffer -> requestMap.remove(requestId) @@ -120,12 +120,11 @@ private object ImageFetcherManager { fun fetch( url: String, - headers: Map, signal: CancellationSignal, onSuccess: (NativeByteBuffer) -> Unit, onFailure: (Exception) -> Unit, ) { - fetcher.fetch(url, headers, signal, onSuccess, onFailure) + fetcher.fetch(url, signal, onSuccess, onFailure) } fun clearCache(onCleared: (Result) -> Unit) { @@ -152,7 +151,6 @@ private object ImageFetcherManager { private sealed interface ImageFetcher { fun fetch( url: String, - headers: Map, signal: CancellationSignal, onSuccess: (NativeByteBuffer) -> Unit, onFailure: (Exception) -> Unit, @@ -179,7 +177,6 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche override fun fetch( url: String, - headers: Map, signal: CancellationSignal, onSuccess: (NativeByteBuffer) -> Unit, onFailure: (Exception) -> Unit, @@ -194,7 +191,12 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche val callback = FetchCallback(onSuccess, onFailure, ::onComplete) val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor) - headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) } + HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) } + url.toHttpUrlOrNull()?.let { httpUrl -> + if (httpUrl.username.isNotEmpty()) { + requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password)) + } + } val request = requestBuilder.build() signal.setOnCancelListener(request::cancel) request.start() @@ -391,7 +393,6 @@ private class OkHttpImageFetcher private constructor( override fun fetch( url: String, - headers: Map, signal: CancellationSignal, onSuccess: (NativeByteBuffer) -> Unit, onFailure: (Exception) -> Unit, @@ -404,7 +405,6 @@ private class OkHttpImageFetcher private constructor( } val requestBuilder = Request.Builder().url(url) - headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) } val call = client.newCall(requestBuilder.build()) signal.setOnCancelListener(call::cancel) diff --git a/mobile/ios/Runner/Core/Network.g.swift b/mobile/ios/Runner/Core/Network.g.swift index 0f678ce4a4..96294c1cd4 100644 --- a/mobile/ios/Runner/Core/Network.g.swift +++ b/mobile/ios/Runner/Core/Network.g.swift @@ -221,8 +221,11 @@ class NetworkPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol NetworkApi { func addCertificate(clientData: ClientCertData, completion: @escaping (Result) -> Void) - func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result) -> Void) + func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result) -> Void) func removeCertificate(completion: @escaping (Result) -> Void) + func hasCertificate() throws -> Bool + func getClientPointer() throws -> Int64 + func setRequestHeaders(headers: [String: String], serverUrls: [String]) throws } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -255,8 +258,8 @@ class NetworkApiSetup { let promptTextArg = args[0] as! ClientCertPrompt api.selectCertificate(promptText: promptTextArg) { result in switch result { - case .success(let res): - reply(wrapResult(res)) + case .success: + reply(wrapResult(nil)) case .failure(let error): reply(wrapError(error)) } @@ -280,5 +283,47 @@ class NetworkApiSetup { } else { removeCertificateChannel.setMessageHandler(nil) } + let hasCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + hasCertificateChannel.setMessageHandler { _, reply in + do { + let result = try api.hasCertificate() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + hasCertificateChannel.setMessageHandler(nil) + } + let getClientPointerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getClientPointerChannel.setMessageHandler { _, reply in + do { + let result = try api.getClientPointer() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getClientPointerChannel.setMessageHandler(nil) + } + let setRequestHeadersChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setRequestHeadersChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let headersArg = args[0] as! [String: String] + let serverUrlsArg = args[1] as! [String] + do { + try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + setRequestHeadersChannel.setMessageHandler(nil) + } } } diff --git a/mobile/ios/Runner/Core/NetworkApiImpl.swift b/mobile/ios/Runner/Core/NetworkApiImpl.swift index d67c392a3a..480286b2af 100644 --- a/mobile/ios/Runner/Core/NetworkApiImpl.swift +++ b/mobile/ios/Runner/Core/NetworkApiImpl.swift @@ -1,5 +1,6 @@ import Foundation import UniformTypeIdentifiers +import native_video_player enum ImportError: Error { case noFile @@ -16,14 +17,25 @@ class NetworkApiImpl: NetworkApi { self.viewController = viewController } - func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result) -> Void) { + func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result) -> Void) { let importer = CertImporter(promptText: promptText, completion: { [weak self] result in self?.activeImporter = nil - completion(result.map { ClientCertData(data: FlutterStandardTypedData(bytes: $0.0), password: $0.1) }) + completion(result) }, viewController: viewController) activeImporter = importer importer.load() } + + func hasCertificate() throws -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: CLIENT_CERT_LABEL, + kSecReturnRef as String: true, + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + return status == errSecSuccess + } func removeCertificate(completion: @escaping (Result) -> Void) { let status = clearCerts() @@ -40,14 +52,58 @@ class NetworkApiImpl: NetworkApi { } completion(.failure(ImportError.keychainError(status))) } + + func getClientPointer() throws -> Int64 { + let pointer = URLSessionManager.shared.sessionPointer + return Int64(Int(bitPattern: pointer)) + } + + func setRequestHeaders(headers: [String : String], serverUrls: [String]) throws { + var headers = headers + if let token = headers.removeValue(forKey: "x-immich-user-token") { + for serverUrl in serverUrls { + guard let url = URL(string: serverUrl), let domain = url.host else { continue } + let isSecure = serverUrl.hasPrefix("https") + let cookies: [(String, String, Bool)] = [ + ("immich_access_token", token, true), + ("immich_is_authenticated", "true", false), + ("immich_auth_type", "password", true), + ] + let expiry = Date().addingTimeInterval(400 * 24 * 60 * 60) + for (name, value, httpOnly) in cookies { + var properties: [HTTPCookiePropertyKey: Any] = [ + .name: name, + .value: value, + .domain: domain, + .path: "/", + .expires: expiry, + ] + if isSecure { properties[.secure] = "TRUE" } + if httpOnly { properties[.init("HttpOnly")] = "TRUE" } + if let cookie = HTTPCookie(properties: properties) { + URLSessionManager.cookieStorage.setCookie(cookie) + } + } + } + } + + if serverUrls.first != UserDefaults.group.string(forKey: SERVER_URL_KEY) { + UserDefaults.group.set(serverUrls.first, forKey: SERVER_URL_KEY) + } + + if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] { + UserDefaults.group.set(headers, forKey: HEADERS_KEY) + URLSessionManager.shared.recreateSession() // Recreate session to apply custom headers without app restart + } + } } private class CertImporter: NSObject, UIDocumentPickerDelegate { private let promptText: ClientCertPrompt - private var completion: ((Result<(Data, String), Error>) -> Void) + private var completion: ((Result) -> Void) private weak var viewController: UIViewController? - - init(promptText: ClientCertPrompt, completion: (@escaping (Result<(Data, String), Error>) -> Void), viewController: UIViewController?) { + + init(promptText: ClientCertPrompt, completion: (@escaping (Result) -> Void), viewController: UIViewController?) { self.promptText = promptText self.completion = completion self.viewController = viewController @@ -81,7 +137,7 @@ private class CertImporter: NSObject, UIDocumentPickerDelegate { } await URLSessionManager.shared.session.flush() - self.completion(.success((data, password))) + self.completion(.success(())) } catch { completion(.failure(error)) } diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift index 73145dbce5..411b828ea1 100644 --- a/mobile/ios/Runner/Core/URLSessionManager.swift +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -1,49 +1,77 @@ import Foundation +import native_video_player let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity" +let HEADERS_KEY = "immich.request_headers" +let SERVER_URL_KEY = "immich.server_url" +let APP_GROUP = "group.app.immich.share" + +extension UserDefaults { + static let group = UserDefaults(suiteName: APP_GROUP)! +} /// Manages a shared URLSession with SSL configuration support. +/// Old sessions are kept alive by Dart's FFI retain until all isolates release them. class URLSessionManager: NSObject { static let shared = URLSessionManager() - let session: URLSession - private let configuration = { - let config = URLSessionConfiguration.default - - let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) + private(set) var session: URLSession + let delegate: URLSessionManagerDelegate + private static let cacheDir: URL = { + let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) .first! .appendingPathComponent("api", isDirectory: true) - try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) - - config.urlCache = URLCache( - memoryCapacity: 0, - diskCapacity: 1024 * 1024 * 1024, - directory: cacheDir - ) - + try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + }() + private static let urlCache = URLCache( + memoryCapacity: 0, + diskCapacity: 1024 * 1024 * 1024, + directory: cacheDir + ) + private static let userAgent: String = { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" + return "Immich_iOS_\(version)" + }() + static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP) + + var sessionPointer: UnsafeMutableRawPointer { + Unmanaged.passUnretained(session).toOpaque() + } + + private override init() { + delegate = URLSessionManagerDelegate() + session = Self.buildSession(delegate: delegate) + super.init() + } + + func recreateSession() { + session = Self.buildSession(delegate: delegate) + } + + private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession { + let config = URLSessionConfiguration.default + config.urlCache = urlCache + config.httpCookieStorage = cookieStorage config.httpMaximumConnectionsPerHost = 64 config.timeoutIntervalForRequest = 60 config.timeoutIntervalForResource = 300 - - let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" - config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"] - - return config - }() - - private override init() { - session = URLSession(configuration: configuration, delegate: URLSessionManagerDelegate(), delegateQueue: nil) - super.init() + + var headers = UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] ?? [:] + headers["User-Agent"] = headers["User-Agent"] ?? userAgent + config.httpAdditionalHeaders = headers + + return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) } } -class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate { +class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate { func urlSession( _ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { - handleChallenge(challenge, completionHandler: completionHandler) + handleChallenge(session, challenge, completionHandler) } func urlSession( @@ -52,20 +80,24 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate { didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { - handleChallenge(challenge, completionHandler: completionHandler) + handleChallenge(session, challenge, completionHandler, task: task) } func handleChallenge( + _ session: URLSession, _ challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + _ completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void, + task: URLSessionTask? = nil ) { switch challenge.protectionSpace.authenticationMethod { - case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(completion: completionHandler) + case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(session, completion: completionHandler) + case NSURLAuthenticationMethodHTTPBasic: handleBasicAuth(session, task: task, completion: completionHandler) default: completionHandler(.performDefaultHandling, nil) } } private func handleClientCertificate( + _ session: URLSession, completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { let query: [String: Any] = [ @@ -80,8 +112,29 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate { let credential = URLCredential(identity: identity as! SecIdentity, certificates: nil, persistence: .forSession) + if #available(iOS 15, *) { + VideoProxyServer.shared.session = session + } return completion(.useCredential, credential) } completion(.performDefaultHandling, nil) } + + private func handleBasicAuth( + _ session: URLSession, + task: URLSessionTask?, + completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard let url = task?.originalRequest?.url, + let user = url.user, + let password = url.password + else { + return completion(.performDefaultHandling, nil) + } + if #available(iOS 15, *) { + VideoProxyServer.shared.session = session + } + let credential = URLCredential(user: user, password: password, persistence: .forSession) + completion(.useCredential, credential) + } } diff --git a/mobile/ios/Runner/Images/RemoteImages.g.swift b/mobile/ios/Runner/Images/RemoteImages.g.swift index 5123a12f3e..9fcffd4233 100644 --- a/mobile/ios/Runner/Images/RemoteImages.g.swift +++ b/mobile/ios/Runner/Images/RemoteImages.g.swift @@ -70,7 +70,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol RemoteImageApi { - func requestImage(url: String, headers: [String: String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) + func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) func cancelRequest(requestId: Int64) throws func clearCache(completion: @escaping (Result) -> Void) } @@ -86,10 +86,9 @@ class RemoteImageApiSetup { requestImageChannel.setMessageHandler { message, reply in let args = message as! [Any?] let urlArg = args[0] as! String - let headersArg = args[1] as! [String: String] - let requestIdArg = args[2] as! Int64 - let preferEncodedArg = args[3] as! Bool - api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in + let requestIdArg = args[1] as! Int64 + let preferEncodedArg = args[2] as! Bool + api.requestImage(url: urlArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in switch result { case .success(let res): reply(wrapResult(res)) diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index fe318800b8..f2a0c37254 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -33,12 +33,9 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { kCGImageSourceCreateThumbnailFromImageAlways: true ] as CFDictionary - func requestImage(url: String, headers: [String : String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { + func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { var urlRequest = URLRequest(url: URL(string: url)!) urlRequest.cachePolicy = .returnCacheDataElseLoad - for (key, value) in headers { - urlRequest.setValue(value, forHTTPHeaderField: key) - } let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error) diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index 6de13b6244..93a2a14127 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'dart:ui'; import 'package:background_downloader/background_downloader.dart'; -import 'package:cancellation_token_http/http.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; @@ -28,7 +27,6 @@ import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/wm_executor.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; @@ -64,7 +62,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { final Drift _drift; final DriftLogger _driftLogger; final BackgroundWorkerBgHostApi _backgroundHostApi; - final CancellationToken _cancellationToken = CancellationToken(); + final _cancellationToken = Completer(); final Logger _logger = Logger('BackgroundWorkerBgService'); bool _isCleanedUp = false; @@ -88,8 +86,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { Future init() async { try { - HttpSSLOptions.apply(); - await Future.wait( [ loadTranslations(), @@ -198,7 +194,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { _ref?.dispose(); _ref = null; - _cancellationToken.cancel(); + _cancellationToken.complete(); _logger.info("Cleaning up background worker"); final cleanupFutures = [ diff --git a/mobile/lib/infrastructure/loaders/remote_image_request.dart b/mobile/lib/infrastructure/loaders/remote_image_request.dart index 40ed304bbe..bcfa9a93c7 100644 --- a/mobile/lib/infrastructure/loaders/remote_image_request.dart +++ b/mobile/lib/infrastructure/loaders/remote_image_request.dart @@ -2,9 +2,8 @@ part of 'image_request.dart'; class RemoteImageRequest extends ImageRequest { final String uri; - final Map headers; - RemoteImageRequest({required this.uri, required this.headers}); + RemoteImageRequest({required this.uri}); @override Future load(ImageDecoderCallback decode, {double scale = 1.0}) async { @@ -12,7 +11,7 @@ class RemoteImageRequest extends ImageRequest { return null; } - final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: false); + final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false); // Android always returns encoded data, so we need to check for both shapes of the response. final frame = switch (info) { {'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length), @@ -29,7 +28,7 @@ class RemoteImageRequest extends ImageRequest { return null; } - final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: true); + final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: true); if (info == null) return null; final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null); diff --git a/mobile/lib/infrastructure/repositories/network.repository.dart b/mobile/lib/infrastructure/repositories/network.repository.dart index a73322cb5c..adf1ee5694 100644 --- a/mobile/lib/infrastructure/repositories/network.repository.dart +++ b/mobile/lib/infrastructure/repositories/network.repository.dart @@ -1,67 +1,55 @@ +import 'dart:ffi'; import 'dart:io'; -import 'package:cronet_http/cronet_http.dart'; import 'package:cupertino_http/cupertino_http.dart'; import 'package:http/http.dart' as http; -import 'package:immich_mobile/utils/user_agent.dart'; -import 'package:path_provider/path_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:ok_http/ok_http.dart'; +import 'package:web_socket/web_socket.dart'; class NetworkRepository { - static late Directory _cachePath; - static late String _userAgent; - static final _clients = {}; + static http.Client? _client; + static Pointer? _clientPointer; - static Future init() { - return ( - getTemporaryDirectory().then((cachePath) => _cachePath = cachePath), - getUserAgentString().then((userAgent) => _userAgent = userAgent), - ).wait; + static Future init() async { + final clientPointer = Pointer.fromAddress(await networkApi.getClientPointer()); + if (clientPointer == _clientPointer) { + return; + } + _clientPointer = clientPointer; + _client?.close(); + if (Platform.isIOS) { + final session = URLSession.fromRawPointer(clientPointer.cast()); + _client = CupertinoClient.fromSharedSession(session); + } else { + _client = OkHttpClient.fromJniGlobalRef(clientPointer); + } } - static void reset() { - Future.microtask(init); - for (final client in _clients.values) { - client.close(); + static Future setHeaders(Map headers, List serverUrls) async { + await networkApi.setRequestHeaders(headers, serverUrls); + if (Platform.isIOS) { + await init(); + } + } + + // ignore: avoid-unused-parameters + static Future createWebSocket(Uri uri, {Map? headers, Iterable? protocols}) { + if (Platform.isIOS) { + final session = URLSession.fromRawPointer(_clientPointer!.cast()); + return CupertinoWebSocket.connectWithSession(session, uri, protocols: protocols); + } else { + return OkHttpWebSocket.connectFromJniGlobalRef(_clientPointer!, uri, protocols: protocols); } - _clients.clear(); } const NetworkRepository(); - /// Note: when disk caching is enabled, only one client may use a given directory at a time. - /// Different isolates or engines must use different directories. - http.Client getHttpClient( - String directoryName, { - CacheMode cacheMode = CacheMode.memory, - int diskCapacity = 0, - int maxConnections = 6, - int memoryCapacity = 10 << 20, - }) { - final cachedClient = _clients[directoryName]; - if (cachedClient != null) { - return cachedClient; - } - - final directory = Directory('${_cachePath.path}/$directoryName'); - directory.createSync(recursive: true); - if (Platform.isAndroid) { - final engine = CronetEngine.build( - cacheMode: cacheMode, - cacheMaxSize: diskCapacity, - storagePath: directory.path, - userAgent: _userAgent, - ); - return _clients[directoryName] = CronetClient.fromCronetEngine(engine, closeEngine: true); - } - - final config = URLSessionConfiguration.defaultSessionConfiguration() - ..httpMaximumConnectionsPerHost = maxConnections - ..cache = URLCache.withCapacity( - diskCapacity: diskCapacity, - memoryCapacity: memoryCapacity, - directory: directory.uri, - ) - ..httpAdditionalHeaders = {'User-Agent': _userAgent}; - return _clients[directoryName] = CupertinoClient.fromSessionConfiguration(config); - } + /// Returns a shared HTTP client that uses native SSL configuration. + /// + /// On iOS: Uses SharedURLSessionManager's URLSession. + /// On Android: Uses SharedHttpClientManager's OkHttpClient. + /// + /// Must call [init] before using this method. + static http.Client get client => _client!; } diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index 0e5c99edd7..12e817f706 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/semver.dart'; import 'package:logging/logging.dart'; @@ -32,15 +33,11 @@ class SyncApiRepository { http.Client? httpClient, }) async { final stopwatch = Stopwatch()..start(); - final client = httpClient ?? http.Client(); + final client = httpClient ?? NetworkRepository.client; final endpoint = "${_api.apiClient.basePath}/sync/stream"; final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'}; - final headerParams = {}; - await _api.applyToParams([], headerParams); - headers.addAll(headerParams); - final shouldReset = Store.get(StoreKey.shouldResetSync, false); final request = http.Request('POST', Uri.parse(endpoint)); request.headers.addAll(headers); @@ -119,8 +116,6 @@ class SyncApiRepository { } } catch (error, stack) { return Future.error(error, stack); - } finally { - client.close(); } stopwatch.stop(); _logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms"); diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index c35c27e141..7e7c709eeb 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -40,7 +40,6 @@ import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/cache/widgets_binding.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/utils/licenses.dart'; import 'package:immich_mobile/utils/migration.dart'; import 'package:immich_mobile/wm_executor.dart'; @@ -60,7 +59,6 @@ void main() async { // Warm-up isolate pool for worker manager await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5)); await migrateDatabaseIfNeeded(isar, drift); - HttpSSLOptions.apply(); runApp( ProviderScope( @@ -246,7 +244,7 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve @override void reassemble() { if (kDebugMode) { - NetworkRepository.reset(); + NetworkRepository.init(); } super.reassemble(); } diff --git a/mobile/lib/models/backup/backup_state.model.dart b/mobile/lib/models/backup/backup_state.model.dart index 635d925c3f..51a17de4fc 100644 --- a/mobile/lib/models/backup/backup_state.model.dart +++ b/mobile/lib/models/backup/backup_state.model.dart @@ -1,6 +1,5 @@ // ignore_for_file: public_member_api_docs, sort_constructors_first -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; @@ -21,7 +20,6 @@ class BackUpState { final DateTime progressInFileSpeedUpdateTime; final int progressInFileSpeedUpdateSentBytes; final double iCloudDownloadProgress; - final CancellationToken cancelToken; final ServerDiskInfo serverInfo; final bool autoBackup; final bool backgroundBackup; @@ -53,7 +51,6 @@ class BackUpState { required this.progressInFileSpeedUpdateTime, required this.progressInFileSpeedUpdateSentBytes, required this.iCloudDownloadProgress, - required this.cancelToken, required this.serverInfo, required this.autoBackup, required this.backgroundBackup, @@ -78,7 +75,6 @@ class BackUpState { DateTime? progressInFileSpeedUpdateTime, int? progressInFileSpeedUpdateSentBytes, double? iCloudDownloadProgress, - CancellationToken? cancelToken, ServerDiskInfo? serverInfo, bool? autoBackup, bool? backgroundBackup, @@ -102,7 +98,6 @@ class BackUpState { progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress, - cancelToken: cancelToken ?? this.cancelToken, serverInfo: serverInfo ?? this.serverInfo, autoBackup: autoBackup ?? this.autoBackup, backgroundBackup: backgroundBackup ?? this.backgroundBackup, @@ -120,7 +115,7 @@ class BackUpState { @override String toString() { - return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)'; + return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: $iCloudDownloadProgress, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)'; } @override @@ -137,7 +132,6 @@ class BackUpState { other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime && other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes && other.iCloudDownloadProgress == iCloudDownloadProgress && - other.cancelToken == cancelToken && other.serverInfo == serverInfo && other.autoBackup == autoBackup && other.backgroundBackup == backgroundBackup && @@ -163,7 +157,6 @@ class BackUpState { progressInFileSpeedUpdateTime.hashCode ^ progressInFileSpeedUpdateSentBytes.hashCode ^ iCloudDownloadProgress.hashCode ^ - cancelToken.hashCode ^ serverInfo.hashCode ^ autoBackup.hashCode ^ backgroundBackup.hashCode ^ diff --git a/mobile/lib/models/backup/manual_upload_state.model.dart b/mobile/lib/models/backup/manual_upload_state.model.dart index 7f797334de..120327c611 100644 --- a/mobile/lib/models/backup/manual_upload_state.model.dart +++ b/mobile/lib/models/backup/manual_upload_state.model.dart @@ -1,11 +1,8 @@ -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; class ManualUploadState { - final CancellationToken cancelToken; - // Current Backup Asset final CurrentUploadAsset currentUploadAsset; final int currentAssetIndex; @@ -29,7 +26,6 @@ class ManualUploadState { required this.progressInFileSpeeds, required this.progressInFileSpeedUpdateTime, required this.progressInFileSpeedUpdateSentBytes, - required this.cancelToken, required this.currentUploadAsset, required this.totalAssetsToUpload, required this.currentAssetIndex, @@ -44,7 +40,6 @@ class ManualUploadState { List? progressInFileSpeeds, DateTime? progressInFileSpeedUpdateTime, int? progressInFileSpeedUpdateSentBytes, - CancellationToken? cancelToken, CurrentUploadAsset? currentUploadAsset, int? totalAssetsToUpload, int? successfulUploads, @@ -58,7 +53,6 @@ class ManualUploadState { progressInFileSpeeds: progressInFileSpeeds ?? this.progressInFileSpeeds, progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes, - cancelToken: cancelToken ?? this.cancelToken, currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset, totalAssetsToUpload: totalAssetsToUpload ?? this.totalAssetsToUpload, currentAssetIndex: currentAssetIndex ?? this.currentAssetIndex, @@ -69,7 +63,7 @@ class ManualUploadState { @override String toString() { - return 'ManualUploadState(progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, cancelToken: $cancelToken, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)'; + return 'ManualUploadState(progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)'; } @override @@ -84,7 +78,6 @@ class ManualUploadState { collectionEquals(other.progressInFileSpeeds, progressInFileSpeeds) && other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime && other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes && - other.cancelToken == cancelToken && other.currentUploadAsset == currentUploadAsset && other.totalAssetsToUpload == totalAssetsToUpload && other.currentAssetIndex == currentAssetIndex && @@ -100,7 +93,6 @@ class ManualUploadState { progressInFileSpeeds.hashCode ^ progressInFileSpeedUpdateTime.hashCode ^ progressInFileSpeedUpdateSentBytes.hashCode ^ - cancelToken.hashCode ^ currentUploadAsset.hashCode ^ totalAssetsToUpload.hashCode ^ currentAssetIndex.hashCode ^ diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index cd6c2a62b0..c5084c0236 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -96,10 +96,6 @@ class _DriftBackupPageState extends ConsumerState { await backupNotifier.startForegroundBackup(currentUser.id); } - Future stopBackup() async { - await backupNotifier.stopForegroundBackup(); - } - return Scaffold( appBar: AppBar( elevation: 0, @@ -136,9 +132,9 @@ class _DriftBackupPageState extends ConsumerState { const Divider(), BackupToggleButton( onStart: () async => await startBackup(), - onStop: () async { + onStop: () { syncSuccess = null; - await stopBackup(); + backupNotifier.stopForegroundBackup(); }, ), switch (error) { diff --git a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart index 93ab659032..1732385675 100644 --- a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart @@ -112,16 +112,15 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState backgroundSync.hashAssets())); if (isBackupEnabled) { + backupNotifier.stopForegroundBackup(); unawaited( - backupNotifier.stopForegroundBackup().whenComplete( - () => backgroundSync.syncRemote().then((success) { - if (success) { - return backupNotifier.startForegroundBackup(user.id); - } else { - Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup'); - } - }), - ), + backgroundSync.syncRemote().then((success) { + if (success) { + return backupNotifier.startForegroundBackup(user.id); + } else { + Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup'); + } + }), ); } } diff --git a/mobile/lib/pages/backup/drift_backup_options.page.dart b/mobile/lib/pages/backup/drift_backup_options.page.dart index f43c8b6a8e..79891d7002 100644 --- a/mobile/lib/pages/backup/drift_backup_options.page.dart +++ b/mobile/lib/pages/backup/drift_backup_options.page.dart @@ -59,16 +59,15 @@ class DriftBackupOptionsPage extends ConsumerWidget { final backupNotifier = ref.read(driftBackupProvider.notifier); final backgroundSync = ref.read(backgroundSyncProvider); + backupNotifier.stopForegroundBackup(); unawaited( - backupNotifier.stopForegroundBackup().whenComplete( - () => backgroundSync.syncRemote().then((success) { - if (success) { - return backupNotifier.startForegroundBackup(currentUser.id); - } else { - Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup'); - } - }), - ), + backgroundSync.syncRemote().then((success) { + if (success) { + return backupNotifier.startForegroundBackup(currentUser.id); + } else { + Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup'); + } + }), ); } }, diff --git a/mobile/lib/pages/common/headers_settings.page.dart b/mobile/lib/pages/common/headers_settings.page.dart index c7c34b9cd2..6eba49442f 100644 --- a/mobile/lib/pages/common/headers_settings.page.dart +++ b/mobile/lib/pages/common/headers_settings.page.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; class SettingsHeader { String key = ""; @@ -20,7 +21,6 @@ class HeaderSettingsPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // final apiService = ref.watch(apiServiceProvider); final headers = useState>([]); final setInitialHeaders = useState(false); @@ -75,7 +75,7 @@ class HeaderSettingsPage extends HookConsumerWidget { ], ), body: PopScope( - onPopInvokedWithResult: (didPop, _) => saveHeaders(headers.value), + onPopInvokedWithResult: (didPop, _) => saveHeaders(ref, headers.value), child: ListView.separated( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0), itemCount: list.length, @@ -87,7 +87,7 @@ class HeaderSettingsPage extends HookConsumerWidget { ); } - saveHeaders(List headers) { + saveHeaders(WidgetRef ref, List headers) async { final headersMap = {}; for (var header in headers) { final key = header.key.trim(); @@ -98,7 +98,8 @@ class HeaderSettingsPage extends HookConsumerWidget { } var encoded = jsonEncode(headersMap); - Store.put(StoreKey.customHeaders, encoded); + await Store.put(StoreKey.customHeaders, encoded); + await ref.read(apiServiceProvider).updateHeaders(); } } diff --git a/mobile/lib/platform/network_api.g.dart b/mobile/lib/platform/network_api.g.dart index 6ddb3cdb71..314a943f7d 100644 --- a/mobile/lib/platform/network_api.g.dart +++ b/mobile/lib/platform/network_api.g.dart @@ -179,7 +179,7 @@ class NetworkApi { } } - Future selectCertificate(ClientCertPrompt promptText) async { + Future selectCertificate(ClientCertPrompt promptText) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( @@ -197,13 +197,8 @@ class NetworkApi { message: pigeonVar_replyList[1] as String?, details: pigeonVar_replyList[2], ); - } else if (pigeonVar_replyList[0] == null) { - throw PlatformException( - code: 'null-error', - message: 'Host platform returned null value for non-null return value.', - ); } else { - return (pigeonVar_replyList[0] as ClientCertData?)!; + return; } } @@ -229,4 +224,83 @@ class NetworkApi { return; } } + + Future hasCertificate() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as bool?)!; + } + } + + Future getClientPointer() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as int?)!; + } + } + + Future setRequestHeaders(Map headers, List serverUrls) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([headers, serverUrls]); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } } diff --git a/mobile/lib/platform/remote_image_api.g.dart b/mobile/lib/platform/remote_image_api.g.dart index 24390293c9..474f033f1f 100644 --- a/mobile/lib/platform/remote_image_api.g.dart +++ b/mobile/lib/platform/remote_image_api.g.dart @@ -49,12 +49,7 @@ class RemoteImageApi { final String pigeonVar_messageChannelSuffix; - Future?> requestImage( - String url, { - required Map headers, - required int requestId, - required bool preferEncoded, - }) async { + Future?> requestImage(String url, {required int requestId, required bool preferEncoded}) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( @@ -62,12 +57,7 @@ class RemoteImageApi { pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([ - url, - headers, - requestId, - preferEncoded, - ]); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([url, requestId, preferEncoded]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); diff --git a/mobile/lib/presentation/pages/editing/drift_edit.page.dart b/mobile/lib/presentation/pages/editing/drift_edit.page.dart index a10202973d..6d4ea4d3a6 100644 --- a/mobile/lib/presentation/pages/editing/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/editing/drift_edit.page.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:cancellation_token_http/http.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -62,7 +61,7 @@ class DriftEditImagePage extends ConsumerWidget { return; } - await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], CancellationToken()); + await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset]); } catch (e) { ImmichToast.show( durationInSecond: 6, diff --git a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart index d69c5bced3..98eb09a4aa 100644 --- a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart @@ -101,7 +101,8 @@ class _UploadProgressDialog extends ConsumerWidget { actions: [ ImmichTextButton( onPressed: () { - ref.read(manualUploadCancelTokenProvider)?.cancel(); + ref.read(manualUploadCancelTokenProvider)?.complete(); + ref.read(manualUploadCancelTokenProvider.notifier).state = null; Navigator.of(context).pop(); }, labelText: 'cancel'.t(context: context), diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index e7e5deb6a6..f3877f2ad2 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -6,7 +6,6 @@ import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:openapi/api.dart'; @@ -37,7 +36,7 @@ class RemoteImageProvider extends CancellableImageProvider } Stream _codec(RemoteImageProvider key, ImageDecoderCallback decode) { - final request = this.request = RemoteImageRequest(uri: key.url, headers: ApiService.getRequestHeaders()); + final request = this.request = RemoteImageRequest(uri: key.url); return loadRequest(request, decode); } @@ -88,10 +87,8 @@ class RemoteFullImageProvider extends CancellableImageProvider { } } - Future _performPause() async { + Future _performPause() { if (_ref.read(authProvider).isAuthenticated) { if (!Store.isBetaTimelineEnabled) { // Do not cancel backup if manual upload is in progress @@ -240,15 +240,13 @@ class AppLifeCycleNotifier extends StateNotifier { _ref.read(backupProvider.notifier).cancelBackup(); } } else { - await _ref.read(driftBackupProvider.notifier).stopForegroundBackup(); + _ref.read(driftBackupProvider.notifier).stopForegroundBackup(); } _ref.read(websocketProvider.notifier).disconnect(); } - try { - await LogService.I.flush(); - } catch (_) {} + return LogService.I.flush().catchError((_) {}); } Future handleAppDetached() async { diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 49dc10240b..ee3367eef2 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -124,6 +124,7 @@ class AuthNotifier extends StateNotifier { Future saveAuthInfo({required String accessToken}) async { await _apiService.setAccessToken(accessToken); + await _apiService.updateHeaders(); final serverEndpoint = Store.get(StoreKey.serverEndpoint); final customHeaders = Store.tryGet(StoreKey.customHeaders); diff --git a/mobile/lib/providers/backup/asset_upload_progress.provider.dart b/mobile/lib/providers/backup/asset_upload_progress.provider.dart index e8aba430da..60936ef871 100644 --- a/mobile/lib/providers/backup/asset_upload_progress.provider.dart +++ b/mobile/lib/providers/backup/asset_upload_progress.provider.dart @@ -1,4 +1,5 @@ -import 'package:cancellation_token_http/http.dart'; +import 'dart:async'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; /// Tracks per-asset upload progress. @@ -30,4 +31,4 @@ final assetUploadProgressProvider = NotifierProvider((ref) => null); +final manualUploadCancelTokenProvider = StateProvider?>((ref) => null); diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 9eb01b6109..5f3ad3d058 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -1,6 +1,6 @@ +import 'dart:async'; import 'dart:io'; -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; @@ -68,7 +68,6 @@ class BackupNotifier extends StateNotifier { progressInFileSpeeds: const [], progressInFileSpeedUpdateTime: DateTime.now(), progressInFileSpeedUpdateSentBytes: 0, - cancelToken: CancellationToken(), autoBackup: Store.get(StoreKey.autoBackup, false), backgroundBackup: Store.get(StoreKey.backgroundBackup, false), backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true), @@ -102,6 +101,7 @@ class BackupNotifier extends StateNotifier { final FileMediaRepository _fileMediaRepository; final BackupAlbumService _backupAlbumService; final Ref ref; + Completer? _cancelToken; /// /// UI INTERACTION @@ -454,7 +454,8 @@ class BackupNotifier extends StateNotifier { } // Perform Backup - state = state.copyWith(cancelToken: CancellationToken()); + _cancelToken?.complete(); + _cancelToken = Completer(); final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; @@ -465,7 +466,7 @@ class BackupNotifier extends StateNotifier { await _backupService.backupAsset( assetsWillBeBackup, - state.cancelToken, + _cancelToken!, pmProgressHandler: pmProgressHandler, onSuccess: _onAssetUploaded, onProgress: _onUploadProgress, @@ -494,7 +495,8 @@ class BackupNotifier extends StateNotifier { if (state.backupProgress != BackUpProgressEnum.inProgress) { notifyBackgroundServiceCanRun(); } - state.cancelToken.cancel(); + _cancelToken?.complete(); + _cancelToken = null; state = state.copyWith( backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0, diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index 624c21f158..4507747c7d 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:logging/logging.dart'; @@ -109,7 +108,6 @@ class DriftBackupState { final BackupError error; final Map uploadItems; - final CancellationToken? cancelToken; final Map iCloudDownloadProgress; @@ -121,7 +119,6 @@ class DriftBackupState { required this.isSyncing, this.error = BackupError.none, required this.uploadItems, - this.cancelToken, this.iCloudDownloadProgress = const {}, }); @@ -133,7 +130,6 @@ class DriftBackupState { bool? isSyncing, BackupError? error, Map? uploadItems, - CancellationToken? cancelToken, Map? iCloudDownloadProgress, }) { return DriftBackupState( @@ -144,7 +140,6 @@ class DriftBackupState { isSyncing: isSyncing ?? this.isSyncing, error: error ?? this.error, uploadItems: uploadItems ?? this.uploadItems, - cancelToken: cancelToken ?? this.cancelToken, iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress, ); } @@ -153,7 +148,7 @@ class DriftBackupState { @override String toString() { - return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, cancelToken: $cancelToken, iCloudDownloadProgress: $iCloudDownloadProgress)'; + return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, iCloudDownloadProgress: $iCloudDownloadProgress)'; } @override @@ -168,8 +163,7 @@ class DriftBackupState { other.isSyncing == isSyncing && other.error == error && mapEquals(other.iCloudDownloadProgress, iCloudDownloadProgress) && - mapEquals(other.uploadItems, uploadItems) && - other.cancelToken == cancelToken; + mapEquals(other.uploadItems, uploadItems); } @override @@ -181,7 +175,6 @@ class DriftBackupState { isSyncing.hashCode ^ error.hashCode ^ uploadItems.hashCode ^ - cancelToken.hashCode ^ iCloudDownloadProgress.hashCode; } } @@ -211,6 +204,7 @@ class DriftBackupNotifier extends StateNotifier { final ForegroundUploadService _foregroundUploadService; final BackgroundUploadService _backgroundUploadService; final UploadSpeedManager _uploadSpeedManager; + Completer? _cancelToken; final _logger = Logger("DriftBackupNotifier"); @@ -246,7 +240,7 @@ class DriftBackupNotifier extends StateNotifier { ); } - void updateError(BackupError error) async { + void updateError(BackupError error) { if (!mounted) { _logger.warning("Skip updateError: notifier disposed"); return; @@ -254,24 +248,23 @@ class DriftBackupNotifier extends StateNotifier { state = state.copyWith(error: error); } - void updateSyncing(bool isSyncing) async { + void updateSyncing(bool isSyncing) { state = state.copyWith(isSyncing: isSyncing); } - Future startForegroundBackup(String userId) async { + Future startForegroundBackup(String userId) { // Cancel any existing backup before starting a new one - if (state.cancelToken != null) { - await stopForegroundBackup(); + if (_cancelToken != null) { + stopForegroundBackup(); } state = state.copyWith(error: BackupError.none); - final cancelToken = CancellationToken(); - state = state.copyWith(cancelToken: cancelToken); + _cancelToken = Completer(); return _foregroundUploadService.uploadCandidates( userId, - cancelToken, + _cancelToken!, callbacks: UploadCallbacks( onProgress: _handleForegroundBackupProgress, onSuccess: _handleForegroundBackupSuccess, @@ -281,10 +274,11 @@ class DriftBackupNotifier extends StateNotifier { ); } - Future stopForegroundBackup() async { - state.cancelToken?.cancel(); + void stopForegroundBackup() { + _cancelToken?.complete(); + _cancelToken = null; _uploadSpeedManager.clear(); - state = state.copyWith(cancelToken: null, uploadItems: {}, iCloudDownloadProgress: {}); + state = state.copyWith(uploadItems: {}, iCloudDownloadProgress: {}); } void _handleICloudProgress(String localAssetId, double progress) { @@ -300,7 +294,7 @@ class DriftBackupNotifier extends StateNotifier { } void _handleForegroundBackupProgress(String localAssetId, String filename, int bytes, int totalBytes) { - if (state.cancelToken == null) { + if (_cancelToken == null) { return; } @@ -399,7 +393,7 @@ class DriftBackupNotifier extends StateNotifier { } } -final driftBackupCandidateProvider = FutureProvider.autoDispose>((ref) async { +final driftBackupCandidateProvider = FutureProvider.autoDispose>((ref) { final user = ref.watch(currentUserProvider); if (user == null) { return []; diff --git a/mobile/lib/providers/backup/manual_upload.provider.dart b/mobile/lib/providers/backup/manual_upload.provider.dart index 6ad8730356..40efcd7422 100644 --- a/mobile/lib/providers/backup/manual_upload.provider.dart +++ b/mobile/lib/providers/backup/manual_upload.provider.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:io'; -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/widgets.dart'; @@ -50,6 +49,7 @@ class ManualUploadNotifier extends StateNotifier { final BackupService _backupService; final BackupAlbumService _backupAlbumService; final Ref ref; + Completer? _cancelToken; ManualUploadNotifier( this._localNotificationService, @@ -65,7 +65,6 @@ class ManualUploadNotifier extends StateNotifier { progressInFileSpeeds: const [], progressInFileSpeedUpdateTime: DateTime.now(), progressInFileSpeedUpdateSentBytes: 0, - cancelToken: CancellationToken(), currentUploadAsset: CurrentUploadAsset( id: '...', fileCreatedAt: DateTime.parse('2020-10-04'), @@ -236,7 +235,6 @@ class ManualUploadNotifier extends StateNotifier { fileName: '...', fileType: '...', ), - cancelToken: CancellationToken(), ); // Reset Error List ref.watch(errorBackupListProvider.notifier).empty(); @@ -252,11 +250,13 @@ class ManualUploadNotifier extends StateNotifier { state = state.copyWith(showDetailedNotification: showDetailedNotification); final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; + _cancelToken?.complete(); + _cancelToken = Completer(); final bool ok = await ref .read(backupServiceProvider) .backupAsset( uploadAssets, - state.cancelToken, + _cancelToken!, pmProgressHandler: pmProgressHandler, onSuccess: _onAssetUploaded, onProgress: _onProgress, @@ -273,14 +273,14 @@ class ManualUploadNotifier extends StateNotifier { ); // User cancelled upload - if (!ok && state.cancelToken.isCancelled) { + if (!ok && _cancelToken == null) { await _localNotificationService.showOrUpdateManualUploadStatus( "backup_manual_title".tr(), "backup_manual_cancelled".tr(), presentBanner: true, ); hasErrors = true; - } else if (state.successfulUploads == 0 || (!ok && !state.cancelToken.isCancelled)) { + } else if (state.successfulUploads == 0 || (!ok && _cancelToken != null)) { await _localNotificationService.showOrUpdateManualUploadStatus( "backup_manual_title".tr(), "failed".tr(), @@ -324,7 +324,8 @@ class ManualUploadNotifier extends StateNotifier { _backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) { _backupProvider.notifyBackgroundServiceCanRun(); } - state.cancelToken.cancel(); + _cancelToken?.complete(); + _cancelToken = null; if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) { _backupProvider.updateBackupProgress(BackUpProgressEnum.idle); } diff --git a/mobile/lib/providers/image/cache/image_loader.dart b/mobile/lib/providers/image/cache/image_loader.dart deleted file mode 100644 index 50530f7cdf..0000000000 --- a/mobile/lib/providers/image/cache/image_loader.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'dart:async'; -import 'dart:ui' as ui; - -import 'package:flutter/material.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart'; -import 'package:immich_mobile/services/api.service.dart'; - -/// Loads the codec from the URI and sends the events to the [chunkEvents] stream -/// -/// Credit to [flutter_cached_network_image](https://github.com/Baseflow/flutter_cached_network_image/blob/develop/cached_network_image/lib/src/image_provider/_image_loader.dart) -/// for this wonderful implementation of their image loader -class ImageLoader { - static Future loadImageFromCache( - String uri, { - required CacheManager cache, - required ImageDecoderCallback decode, - StreamController? chunkEvents, - }) async { - final headers = ApiService.getRequestHeaders(); - - final stream = cache.getFileStream(uri, withProgress: chunkEvents != null, headers: headers); - - await for (final result in stream) { - if (result is DownloadProgress) { - // We are downloading the file, so update the [chunkEvents] - chunkEvents?.add( - ImageChunkEvent(cumulativeBytesLoaded: result.downloaded, expectedTotalBytes: result.totalSize), - ); - } else if (result is FileInfo) { - // We have the file - final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path); - return decode(buffer); - } - } - - // If we get here, the image failed to load from the cache stream - throw const ImageLoadingException('Could not load image from stream'); - } -} diff --git a/mobile/lib/providers/image/cache/remote_image_cache_manager.dart b/mobile/lib/providers/image/cache/remote_image_cache_manager.dart deleted file mode 100644 index d3de4b80c9..0000000000 --- a/mobile/lib/providers/image/cache/remote_image_cache_manager.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; - -class RemoteImageCacheManager extends CacheManager { - static const key = 'remoteImageCacheKey'; - static final RemoteImageCacheManager _instance = RemoteImageCacheManager._(); - static final _config = Config(key, maxNrOfCacheObjects: 500, stalePeriod: const Duration(days: 30)); - - factory RemoteImageCacheManager() { - return _instance; - } - - RemoteImageCacheManager._() : super(_config); -} - -class RemoteThumbnailCacheManager extends CacheManager { - static const key = 'remoteThumbnailCacheKey'; - static final RemoteThumbnailCacheManager _instance = RemoteThumbnailCacheManager._(); - static final _config = Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30)); - - factory RemoteThumbnailCacheManager() { - return _instance; - } - - RemoteThumbnailCacheManager._() : super(_config); -} diff --git a/mobile/lib/providers/image/cache/thumbnail_image_cache_manager.dart b/mobile/lib/providers/image/cache/thumbnail_image_cache_manager.dart deleted file mode 100644 index bfea36eef6..0000000000 --- a/mobile/lib/providers/image/cache/thumbnail_image_cache_manager.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; - -/// The cache manager for thumbnail images [ImmichRemoteThumbnailProvider] -class ThumbnailImageCacheManager extends CacheManager { - static const key = 'thumbnailImageCacheKey'; - static final ThumbnailImageCacheManager _instance = ThumbnailImageCacheManager._(); - - factory ThumbnailImageCacheManager() { - return _instance; - } - - ThumbnailImageCacheManager._() : super(Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30))); -} diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index cd75af6354..bad0d986d0 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:background_downloader/background_downloader.dart'; -import 'package:cancellation_token_http/http.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -455,7 +454,7 @@ class ActionNotifier extends Notifier { final assetsToUpload = assets ?? _getAssets(source).whereType().toList(); final progressNotifier = ref.read(assetUploadProgressProvider.notifier); - final cancelToken = CancellationToken(); + final cancelToken = Completer(); ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken; // Initialize progress for all assets @@ -466,7 +465,7 @@ class ActionNotifier extends Notifier { try { await _foregroundUploadService.uploadManual( assetsToUpload, - cancelToken, + cancelToken: cancelToken, callbacks: UploadCallbacks( onProgress: (localAssetId, filename, bytes, totalBytes) { final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index f9473ce440..09f699ba7f 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -1,18 +1,17 @@ import 'dart:async'; -import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/utils/debounce.dart'; import 'package:immich_mobile/utils/debug_print.dart'; @@ -99,11 +98,6 @@ class WebsocketNotifier extends StateNotifier { if (authenticationState.isAuthenticated) { try { final endpoint = Uri.parse(Store.get(StoreKey.serverEndpoint)); - final headers = ApiService.getRequestHeaders(); - if (endpoint.userInfo.isNotEmpty) { - headers["Authorization"] = "Basic ${base64.encode(utf8.encode(endpoint.userInfo))}"; - } - dPrint(() => "Attempting to connect to websocket"); // Configure socket transports must be specified Socket socket = io( @@ -111,11 +105,11 @@ class WebsocketNotifier extends StateNotifier { OptionBuilder() .setPath("${endpoint.path}/socket.io") .setTransports(['websocket']) + .setWebSocketConnector(NetworkRepository.createWebSocket) .enableReconnection() .enableForceNew() .enableForceNewConnection() .enableAutoConnect() - .setExtraHeaders(headers) .build(), ); @@ -160,11 +154,8 @@ class WebsocketNotifier extends StateNotifier { _batchedAssetUploadReady.clear(); - var socket = state.socket?.disconnect(); - - if (socket?.disconnected == true) { - state = WebsocketState(isConnected: false, socket: null, pendingChanges: state.pendingChanges); - } + state.socket?.dispose(); + state = WebsocketState(isConnected: false, socket: null, pendingChanges: state.pendingChanges); } void stopListenToEvent(String eventName) { diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index aff84683c3..98c6202e19 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -3,21 +3,15 @@ import 'dart:convert'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; -import 'package:cancellation_token_http/http.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:logging/logging.dart'; +import 'package:http/http.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -class UploadTaskWithFile { - final File file; - final UploadTask task; - - const UploadTaskWithFile({required this.file, required this.task}); -} - final uploadRepositoryProvider = Provider((ref) => UploadRepository()); class UploadRepository { @@ -97,26 +91,27 @@ class UploadRepository { Future uploadFile({ required File file, required String originalFileName, - required Map headers, required Map fields, - required Client httpClient, - required CancellationToken cancelToken, - required void Function(int bytes, int totalBytes) onProgress, + required Completer? cancelToken, + void Function(int bytes, int totalBytes)? onProgress, required String logContext, }) async { final String savedEndpoint = Store.get(StoreKey.serverEndpoint); + final baseRequest = ProgressMultipartRequest( + 'POST', + Uri.parse('$savedEndpoint/assets'), + abortTrigger: cancelToken?.future, + onProgress: onProgress, + ); try { final fileStream = file.openRead(); final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName); - final baseRequest = _CustomMultipartRequest('POST', Uri.parse('$savedEndpoint/assets'), onProgress: onProgress); - - baseRequest.headers.addAll(headers); baseRequest.fields.addAll(fields); baseRequest.files.add(assetRawUploadData); - final response = await httpClient.send(baseRequest, cancellationToken: cancelToken); + final response = await NetworkRepository.client.send(baseRequest); final responseBodyString = await response.stream.bytesToString(); if (![200, 201].contains(response.statusCode)) { @@ -145,7 +140,7 @@ class UploadRepository { } catch (e) { return UploadResult.error(errorMessage: 'Failed to parse server response'); } - } on CancelledException { + } on RequestAbortedException { logger.warning("Upload $logContext was cancelled"); return UploadResult.cancelled(); } catch (error, stackTrace) { @@ -155,6 +150,34 @@ class UploadRepository { } } +class ProgressMultipartRequest extends MultipartRequest with Abortable { + ProgressMultipartRequest(super.method, super.url, {this.abortTrigger, this.onProgress}); + + @override + final Future? abortTrigger; + + final void Function(int bytes, int totalBytes)? onProgress; + + @override + ByteStream finalize() { + final byteStream = super.finalize(); + if (onProgress == null) return byteStream; + + final total = contentLength; + var bytes = 0; + final stream = byteStream.transform( + StreamTransformer.fromHandlers( + handleData: (List data, EventSink> sink) { + bytes += data.length; + onProgress!(bytes, total); + sink.add(data); + }, + ), + ); + return ByteStream(stream); + } +} + class UploadResult { final bool isSuccess; final bool isCancelled; @@ -182,26 +205,3 @@ class UploadResult { return const UploadResult(isSuccess: false, isCancelled: true); } } - -class _CustomMultipartRequest extends MultipartRequest { - _CustomMultipartRequest(super.method, super.url, {required this.onProgress}); - - final void Function(int bytes, int totalBytes) onProgress; - - @override - ByteStream finalize() { - final byteStream = super.finalize(); - final total = contentLength; - var bytes = 0; - - final t = StreamTransformer.fromHandlers( - handleData: (List data, EventSink> sink) { - bytes += data.length; - onProgress.call(bytes, total); - sink.add(data); - }, - ); - final stream = byteStream.transform(t); - return ByteStream(stream); - } -} diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index bafe780647..566ec7aa31 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -3,12 +3,11 @@ import 'dart:convert'; import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; -import 'package:http/http.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/url_helper.dart'; -import 'package:immich_mobile/utils/user_agent.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -49,9 +48,14 @@ class ApiService implements Authentication { String? _accessToken; final _log = Logger("ApiService"); + Future updateHeaders() async { + await NetworkRepository.setHeaders(getRequestHeaders(), getServerUrls()); + _apiClient.client = NetworkRepository.client; + } + setEndpoint(String endpoint) { _apiClient = ApiClient(basePath: endpoint, authentication: this); - _setUserAgentHeader(); + _apiClient.client = NetworkRepository.client; if (_accessToken != null) { setAccessToken(_accessToken!); } @@ -78,11 +82,6 @@ class ApiService implements Authentication { tagsApi = TagsApi(_apiClient); } - Future _setUserAgentHeader() async { - final userAgent = await getUserAgentString(); - _apiClient.addDefaultHeader('User-Agent', userAgent); - } - Future resolveAndSetEndpoint(String serverUrl) async { final endpoint = await resolveEndpoint(serverUrl); setEndpoint(endpoint); @@ -136,14 +135,9 @@ class ApiService implements Authentication { } Future _getWellKnownEndpoint(String baseUrl) async { - final Client client = Client(); - try { - var headers = {"Accept": "application/json"}; - headers.addAll(getRequestHeaders()); - - final res = await client - .get(Uri.parse("$baseUrl/.well-known/immich"), headers: headers) + final res = await NetworkRepository.client + .get(Uri.parse("$baseUrl/.well-known/immich")) .timeout(const Duration(seconds: 5)); if (res.statusCode == 200) { @@ -185,6 +179,31 @@ class ApiService implements Authentication { } } + static List getServerUrls() { + final urls = []; + final serverEndpoint = Store.tryGet(StoreKey.serverEndpoint); + if (serverEndpoint != null && serverEndpoint.isNotEmpty) { + urls.add(serverEndpoint); + } + final serverUrl = Store.tryGet(StoreKey.serverUrl); + if (serverUrl != null && serverUrl.isNotEmpty) { + urls.add(serverUrl); + } + final localEndpoint = Store.tryGet(StoreKey.localEndpoint); + if (localEndpoint != null && localEndpoint.isNotEmpty) { + urls.add(localEndpoint); + } + final externalJson = Store.tryGet(StoreKey.externalEndpointList); + if (externalJson != null) { + final List list = jsonDecode(externalJson); + for (final entry in list) { + final url = entry['url'] as String?; + if (url != null && url.isNotEmpty) urls.add(url); + } + } + return urls; + } + static Map getRequestHeaders() { var accessToken = Store.get(StoreKey.accessToken, ""); var customHeadersStr = Store.get(StoreKey.customHeaders, ""); @@ -207,10 +226,7 @@ class ApiService implements Authentication { @override Future applyToParams(List queryParams, Map headerParams) { - return Future(() { - var headers = ApiService.getRequestHeaders(); - headerParams.addAll(headers); - }); + return Future.value(); } ApiClient get apiClient => _apiClient; diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index 3173f49957..c5f3fa6a4a 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -1,10 +1,10 @@ import 'dart:async'; -import 'dart:io'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/models/auth/login_response.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; @@ -64,27 +64,16 @@ class AuthService { } Future validateAuxilaryServerUrl(String url) async { - final httpclient = HttpClient(); bool isValid = false; try { final uri = Uri.parse('$url/users/me'); - final request = await httpclient.getUrl(uri); - - // add auth token + any configured custom headers - final customHeaders = ApiService.getRequestHeaders(); - customHeaders.forEach((key, value) { - request.headers.add(key, value); - }); - - final response = await request.close(); + final response = await NetworkRepository.client.get(uri); if (response.statusCode == 200) { isValid = true; } } catch (error) { _log.severe("Error validating auxiliary endpoint", error); - } finally { - httpclient.close(); } return isValid; diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index b69aa53014..d022d9a5cf 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -4,7 +4,6 @@ import 'dart:io'; import 'dart:isolate'; import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities; -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; @@ -30,7 +29,6 @@ import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:path_provider_foundation/path_provider_foundation.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; @@ -43,7 +41,7 @@ class BackgroundService { static const MethodChannel _backgroundChannel = MethodChannel('immich/backgroundChannel'); static const notifyInterval = Duration(milliseconds: 400); bool _isBackgroundInitialized = false; - CancellationToken? _cancellationToken; + Completer? _cancellationToken; bool _canceledBySystem = false; int _wantsLockTime = 0; bool _hasLock = false; @@ -321,7 +319,8 @@ class BackgroundService { } case "systemStop": _canceledBySystem = true; - _cancellationToken?.cancel(); + _cancellationToken?.complete(); + _cancellationToken = null; return true; default: dPrint(() => "Unknown method ${call.method}"); @@ -341,7 +340,6 @@ class BackgroundService { ], ); - HttpSSLOptions.apply(); await ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken)); await ref.read(authServiceProvider).setOpenApiServiceEndpoint(); dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}"); @@ -441,7 +439,8 @@ class BackgroundService { ), ); - _cancellationToken = CancellationToken(); + _cancellationToken?.complete(); + _cancellationToken = Completer(); final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; final bool ok = await backupService.backupAsset( @@ -455,7 +454,7 @@ class BackgroundService { isBackground: true, ); - if (!ok && !_cancellationToken!.isCancelled) { + if (!ok && !_cancellationToken!.isCompleted) { unawaited( _showErrorNotification( title: "backup_background_service_error_title".tr(), @@ -467,7 +466,7 @@ class BackgroundService { return ok; } - void _onAssetUploaded({bool shouldNotify = false}) async { + void _onAssetUploaded({bool shouldNotify = false}) { if (!shouldNotify) { return; } diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 539fd1fbd9..9b6a26be03 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -2,14 +2,16 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:cancellation_token_http/http.dart' as http; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; +import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; @@ -43,7 +45,6 @@ final backupServiceProvider = Provider( ); class BackupService { - final httpClient = http.Client(); final ApiService _apiService; final Logger _log = Logger("BackupService"); final AppSettingsService _appSetting; @@ -233,7 +234,7 @@ class BackupService { Future backupAsset( Iterable assets, - http.CancellationToken cancelToken, { + Completer cancelToken, { bool isBackground = false, PMProgressHandler? pmProgressHandler, required void Function(SuccessUploadAsset result) onSuccess, @@ -306,20 +307,20 @@ class BackupService { } final fileStream = file.openRead(); - final assetRawUploadData = http.MultipartFile( + final assetRawUploadData = MultipartFile( "assetData", fileStream, file.lengthSync(), filename: originalFileName, ); - final baseRequest = MultipartRequest( + final baseRequest = ProgressMultipartRequest( 'POST', Uri.parse('$savedEndpoint/assets'), + abortTrigger: cancelToken.future, onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)), ); - baseRequest.headers.addAll(ApiService.getRequestHeaders()); baseRequest.fields['deviceAssetId'] = asset.localId!; baseRequest.fields['deviceId'] = deviceId; baseRequest.fields['fileCreatedAt'] = asset.fileCreatedAt.toUtc().toIso8601String(); @@ -348,7 +349,7 @@ class BackupService { baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId; } - final response = await httpClient.send(baseRequest, cancellationToken: cancelToken); + final response = await NetworkRepository.client.send(baseRequest); final responseBody = jsonDecode(await response.stream.bytesToString()); @@ -398,7 +399,7 @@ class BackupService { await _albumService.syncUploadAlbums(candidate.albumNames, [responseBody['id'] as String]); } } - } on http.CancelledException { + } on RequestAbortedException { dPrint(() => "Backup was cancelled by the user"); anyErrors = true; break; @@ -429,26 +430,26 @@ class BackupService { String originalFileName, File? livePhotoVideoFile, MultipartRequest baseRequest, - http.CancellationToken cancelToken, + Completer cancelToken, ) async { if (livePhotoVideoFile == null) { return null; } final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoVideoFile.path)); final fileStream = livePhotoVideoFile.openRead(); - final livePhotoRawUploadData = http.MultipartFile( + final livePhotoRawUploadData = MultipartFile( "assetData", fileStream, livePhotoVideoFile.lengthSync(), filename: livePhotoTitle, ); - final livePhotoReq = MultipartRequest(baseRequest.method, baseRequest.url, onProgress: baseRequest.onProgress) + final livePhotoReq = ProgressMultipartRequest(baseRequest.method, baseRequest.url, abortTrigger: cancelToken.future) ..headers.addAll(baseRequest.headers) ..fields.addAll(baseRequest.fields); livePhotoReq.files.add(livePhotoRawUploadData); - var response = await httpClient.send(livePhotoReq, cancellationToken: cancelToken); + var response = await NetworkRepository.client.send(livePhotoReq); var responseBody = jsonDecode(await response.stream.bytesToString()); @@ -470,31 +471,3 @@ class BackupService { AssetType.other => "OTHER", }; } - -class MultipartRequest extends http.MultipartRequest { - /// Creates a new [MultipartRequest]. - MultipartRequest(super.method, super.url, {required this.onProgress}); - - final void Function(int bytes, int totalBytes) onProgress; - - /// Freezes all mutable fields and returns a - /// single-subscription [http.ByteStream] - /// that will emit the request body. - @override - http.ByteStream finalize() { - final byteStream = super.finalize(); - - final total = contentLength; - var bytes = 0; - - final t = StreamTransformer.fromHandlers( - handleData: (List data, EventSink> sink) { - bytes += data.length; - onProgress.call(bytes, total); - sink.add(data); - }, - ); - final stream = byteStream.transform(t); - return http.ByteStream(stream); - } -} diff --git a/mobile/lib/services/foreground_upload.service.dart b/mobile/lib/services/foreground_upload.service.dart index cd28942bd2..ce02c9c56b 100644 --- a/mobile/lib/services/foreground_upload.service.dart +++ b/mobile/lib/services/foreground_upload.service.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:cancellation_token_http/http.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -19,7 +18,6 @@ import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; @@ -82,7 +80,7 @@ class ForegroundUploadService { /// Bulk upload of backup candidates from selected albums Future uploadCandidates( String userId, - CancellationToken cancelToken, { + Completer cancelToken, { UploadCallbacks callbacks = const UploadCallbacks(), bool useSequentialUpload = false, }) async { @@ -105,7 +103,7 @@ class ForegroundUploadService { final requireWifi = _shouldRequireWiFi(asset); return requireWifi && !hasWifi; }, - processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks), + processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks), ); } } @@ -113,37 +111,32 @@ class ForegroundUploadService { /// Sequential upload - used for background isolate where concurrent HTTP clients may cause issues Future _uploadSequentially({ required List items, - required CancellationToken cancelToken, + required Completer cancelToken, required bool hasWifi, required UploadCallbacks callbacks, }) async { - final httpClient = Client(); await _storageRepository.clearCache(); shouldAbortUpload = false; - try { - for (final asset in items) { - if (shouldAbortUpload || cancelToken.isCancelled) { - break; - } - - final requireWifi = _shouldRequireWiFi(asset); - if (requireWifi && !hasWifi) { - _logger.warning('Skipping upload for ${asset.id} because it requires WiFi'); - continue; - } - - await _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks); + for (final asset in items) { + if (shouldAbortUpload || cancelToken.isCompleted) { + break; } - } finally { - httpClient.close(); + + final requireWifi = _shouldRequireWiFi(asset); + if (requireWifi && !hasWifi) { + _logger.warning('Skipping upload for ${asset.id} because it requires WiFi'); + continue; + } + + await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks); } } /// Manually upload picked local assets Future uploadManual( - List localAssets, - CancellationToken cancelToken, { + List localAssets, { + Completer? cancelToken, UploadCallbacks callbacks = const UploadCallbacks(), }) async { if (localAssets.isEmpty) { @@ -153,14 +146,14 @@ class ForegroundUploadService { await _executeWithWorkerPool( items: localAssets, cancelToken: cancelToken, - processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks), + processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks), ); } /// Upload files from shared intent Future uploadShareIntent( List files, { - CancellationToken? cancelToken, + Completer? cancelToken, void Function(String fileId, int bytes, int totalBytes)? onProgress, void Function(String fileId)? onSuccess, void Function(String fileId, String errorMessage)? onError, @@ -168,20 +161,16 @@ class ForegroundUploadService { if (files.isEmpty) { return; } - - final effectiveCancelToken = cancelToken ?? CancellationToken(); - await _executeWithWorkerPool( items: files, - cancelToken: effectiveCancelToken, - processItem: (file, httpClient) async { + cancelToken: cancelToken, + processItem: (file) async { final fileId = p.hash(file.path).toString(); final result = await _uploadSingleFile( file, deviceAssetId: fileId, - httpClient: httpClient, - cancelToken: effectiveCancelToken, + cancelToken: cancelToken, onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes), ); @@ -207,58 +196,49 @@ class ForegroundUploadService { /// [concurrentWorkers] - Number of concurrent workers (default: 3) Future _executeWithWorkerPool({ required List items, - required CancellationToken cancelToken, - required Future Function(T item, Client httpClient) processItem, + required Completer? cancelToken, + required Future Function(T item) processItem, bool Function(T item)? shouldSkip, int concurrentWorkers = 3, }) async { - final httpClients = List.generate(concurrentWorkers, (_) => Client()); - await _storageRepository.clearCache(); shouldAbortUpload = false; - try { - int currentIndex = 0; + int currentIndex = 0; - Future worker(Client httpClient) async { - while (true) { - if (shouldAbortUpload || cancelToken.isCancelled) { - break; - } - - final index = currentIndex; - if (index >= items.length) { - break; - } - currentIndex++; - - final item = items[index]; - - if (shouldSkip?.call(item) ?? false) { - continue; - } - - await processItem(item, httpClient); + Future worker() async { + while (true) { + if (shouldAbortUpload || (cancelToken != null && cancelToken.isCompleted)) { + break; } - } - final workerFutures = >[]; - for (int i = 0; i < concurrentWorkers; i++) { - workerFutures.add(worker(httpClients[i])); - } + final index = currentIndex; + if (index >= items.length) { + break; + } + currentIndex++; - await Future.wait(workerFutures); - } finally { - for (final client in httpClients) { - client.close(); + final item = items[index]; + + if (shouldSkip?.call(item) ?? false) { + continue; + } + + await processItem(item); } } + + final workerFutures = >[]; + for (int i = 0; i < concurrentWorkers; i++) { + workerFutures.add(worker()); + } + + await Future.wait(workerFutures); } Future _uploadSingleAsset( LocalAsset asset, - Client httpClient, - CancellationToken cancelToken, { + Completer? cancelToken, { required UploadCallbacks callbacks, }) async { File? file; @@ -343,7 +323,6 @@ class ForegroundUploadService { final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName; final deviceId = Store.get(StoreKey.deviceId); - final headers = ApiService.getRequestHeaders(); final fields = { 'deviceAssetId': asset.localId!, 'deviceId': deviceId, @@ -358,15 +337,15 @@ class ForegroundUploadService { if (entity.isLivePhoto && livePhotoFile != null) { final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path)); + final onProgress = callbacks.onProgress; final livePhotoResult = await _uploadRepository.uploadFile( file: livePhotoFile, originalFileName: livePhotoTitle, - headers: headers, fields: fields, - httpClient: httpClient, cancelToken: cancelToken, - onProgress: (bytes, totalBytes) => - callbacks.onProgress?.call(asset.localId!, livePhotoTitle, bytes, totalBytes), + onProgress: onProgress != null + ? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes) + : null, logContext: 'livePhotoVideo[${asset.localId}]', ); @@ -395,15 +374,15 @@ class ForegroundUploadService { ]); } + final onProgress = callbacks.onProgress; final result = await _uploadRepository.uploadFile( file: file, originalFileName: originalFileName, - headers: headers, fields: fields, - httpClient: httpClient, cancelToken: cancelToken, - onProgress: (bytes, totalBytes) => - callbacks.onProgress?.call(asset.localId!, originalFileName, bytes, totalBytes), + onProgress: onProgress != null + ? (bytes, totalBytes) => onProgress(asset.localId!, originalFileName, bytes, totalBytes) + : null, logContext: 'asset[${asset.localId}]', ); @@ -442,8 +421,7 @@ class ForegroundUploadService { Future _uploadSingleFile( File file, { required String deviceAssetId, - required Client httpClient, - required CancellationToken cancelToken, + required Completer? cancelToken, void Function(int bytes, int totalBytes)? onProgress, }) async { try { @@ -452,12 +430,9 @@ class ForegroundUploadService { final fileModifiedAt = stats.modified; final filename = p.basename(file.path); - final headers = ApiService.getRequestHeaders(); - final deviceId = Store.get(StoreKey.deviceId); - final fields = { 'deviceAssetId': deviceAssetId, - 'deviceId': deviceId, + 'deviceId': Store.get(StoreKey.deviceId), 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(), 'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(), 'isFavorite': 'false', @@ -467,11 +442,9 @@ class ForegroundUploadService { return await _uploadRepository.uploadFile( file: file, originalFileName: filename, - headers: headers, fields: fields, - httpClient: httpClient, cancelToken: cancelToken, - onProgress: onProgress ?? (_, __) {}, + onProgress: onProgress, logContext: 'shareIntent[$deviceAssetId]', ); } catch (e) { diff --git a/mobile/lib/utils/http_ssl_cert_override.dart b/mobile/lib/utils/http_ssl_cert_override.dart deleted file mode 100644 index a4c97a532f..0000000000 --- a/mobile/lib/utils/http_ssl_cert_override.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'dart:io'; - -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:logging/logging.dart'; - -class HttpSSLCertOverride extends HttpOverrides { - static final Logger _log = Logger("HttpSSLCertOverride"); - final bool _allowSelfSignedSSLCert; - final String? _serverHost; - final SSLClientCertStoreVal? _clientCert; - late final SecurityContext? _ctxWithCert; - - HttpSSLCertOverride(this._allowSelfSignedSSLCert, this._serverHost, this._clientCert) { - if (_clientCert != null) { - _ctxWithCert = SecurityContext(withTrustedRoots: true); - if (_ctxWithCert != null) { - setClientCert(_ctxWithCert, _clientCert); - } else { - _log.severe("Failed to create security context with client cert!"); - } - } else { - _ctxWithCert = null; - } - } - - static bool setClientCert(SecurityContext ctx, SSLClientCertStoreVal cert) { - try { - _log.info("Setting client certificate"); - ctx.usePrivateKeyBytes(cert.data, password: cert.password); - ctx.useCertificateChainBytes(cert.data, password: cert.password); - } catch (e) { - _log.severe("Failed to set SSL client cert: $e"); - return false; - } - return true; - } - - @override - HttpClient createHttpClient(SecurityContext? context) { - if (context != null) { - if (_clientCert != null) { - setClientCert(context, _clientCert); - } - } else { - context = _ctxWithCert; - } - - return super.createHttpClient(context) - ..badCertificateCallback = (X509Certificate cert, String host, int port) { - if (_allowSelfSignedSSLCert) { - // Conduct server host checks if user is logged in to avoid making - // insecure SSL connections to services that are not the immich server. - if (_serverHost == null || _serverHost.contains(host)) { - return true; - } - } - _log.severe("Invalid SSL certificate for $host:$port"); - return false; - }; - } -} diff --git a/mobile/lib/utils/http_ssl_options.dart b/mobile/lib/utils/http_ssl_options.dart deleted file mode 100644 index a93387c9db..0000000000 --- a/mobile/lib/utils/http_ssl_options.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'dart:io'; - -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; - -class HttpSSLOptions { - static void apply() { - AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert; - bool allowSelfSignedSSLCert = Store.get(setting.storeKey as StoreKey, setting.defaultValue); - return _apply(allowSelfSignedSSLCert); - } - - static void applyFromSettings(bool newValue) => _apply(newValue); - - static void _apply(bool allowSelfSignedSSLCert) { - String? serverHost; - if (allowSelfSignedSSLCert && Store.tryGet(StoreKey.currentUser) != null) { - serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host; - } - - SSLClientCertStoreVal? clientCert = SSLClientCertStoreVal.load(); - - HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert); - } -} diff --git a/mobile/lib/utils/isolate.dart b/mobile/lib/utils/isolate.dart index 7ac120acb4..c8224b9c55 100644 --- a/mobile/lib/utils/isolate.dart +++ b/mobile/lib/utils/isolate.dart @@ -10,7 +10,6 @@ import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/wm_executor.dart'; import 'package:logging/logging.dart'; import 'package:worker_manager/worker_manager.dart'; @@ -54,7 +53,6 @@ Cancelable runInIsolateGentle({ Logger log = Logger("IsolateLogger"); try { - HttpSSLOptions.apply(); result = await computation(ref); } on CanceledError { log.warning("Computation cancelled ${debugLabel == null ? '' : ' for $debugLabel'}"); diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index e86d313294..d5905a246c 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -10,12 +10,10 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart'; import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart'; import 'package:immich_mobile/widgets/settings/local_storage_settings.dart'; @@ -31,15 +29,12 @@ class AdvancedSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - bool isLoggedIn = ref.read(currentUserProvider) != null; - final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting); final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid); final isManageMediaSupported = useState(false); final manageMediaAndroidPermission = useState(false); final levelId = useAppSettingsState(AppSettingsEnum.logLevel); final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage); - final allowSelfSignedSSLCert = useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert); final useAlternatePMFilter = useAppSettingsState(AppSettingsEnum.photoManagerCustomFilter); final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled); @@ -120,15 +115,8 @@ class AdvancedSettings extends HookConsumerWidget { subtitle: "advanced_settings_prefer_remote_subtitle".tr(), ), if (!Store.isBetaTimelineEnabled) const LocalStorageSettings(), - SettingsSwitchListTile( - enabled: !isLoggedIn, - valueNotifier: allowSelfSignedSSLCert, - title: "advanced_settings_self_signed_ssl_title".tr(), - subtitle: "advanced_settings_self_signed_ssl_subtitle".tr(), - onChanged: HttpSSLOptions.applyFromSettings, - ), const CustomProxyHeaderSettings(), - SslClientCertSettings(isLoggedIn: ref.read(currentUserProvider) != null), + const SslClientCertSettings(), if (!Store.isBetaTimelineEnabled) SettingsSwitchListTile( valueNotifier: useAlternatePMFilter, diff --git a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart index fa210ee720..77ad7ee179 100644 --- a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart +++ b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart @@ -1,18 +1,16 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/platform/network_api.g.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:logging/logging.dart'; class SslClientCertSettings extends StatefulWidget { - const SslClientCertSettings({super.key, required this.isLoggedIn}); - - final bool isLoggedIn; + const SslClientCertSettings({super.key}); @override State createState() => _SslClientCertSettingsState(); @@ -21,9 +19,24 @@ class SslClientCertSettings extends StatefulWidget { class _SslClientCertSettingsState extends State { final _log = Logger("SslClientCertSettings"); - bool isCertExist; + bool isCertExist = false; - _SslClientCertSettingsState() : isCertExist = SSLClientCertStoreVal.load() != null; + @override + void initState() { + super.initState(); + unawaited(_checkCertificate()); + } + + Future _checkCertificate() async { + try { + final exists = await networkApi.hasCertificate(); + if (mounted && exists != isCertExist) { + setState(() => isCertExist = exists); + } + } catch (e) { + _log.warning("Failed to check certificate existence", e); + } + } @override Widget build(BuildContext context) { @@ -45,11 +58,8 @@ class _SslClientCertSettingsState extends State { mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.center, children: [ - ElevatedButton(onPressed: widget.isLoggedIn ? null : importCert, child: Text("client_cert_import".tr())), - ElevatedButton( - onPressed: widget.isLoggedIn || !isCertExist ? null : removeCert, - child: Text("remove".tr()), - ), + ElevatedButton(onPressed: importCert, child: Text("client_cert_import".tr())), + ElevatedButton(onPressed: !isCertExist ? null : removeCert, child: Text("remove".tr())), ], ), ], @@ -74,9 +84,7 @@ class _SslClientCertSettingsState extends State { cancel: "cancel".tr(), confirm: "confirm".tr(), ); - final cert = await networkApi.selectCertificate(styling); - await SSLClientCertStoreVal(cert.data, cert.password).save(); - HttpSSLOptions.apply(); + await networkApi.selectCertificate(styling); setState(() => isCertExist = true); showMessage("client_cert_import_success_msg".tr()); } catch (e) { @@ -91,8 +99,6 @@ class _SslClientCertSettingsState extends State { Future removeCert() async { try { await networkApi.removeCertificate(); - await SSLClientCertStoreVal.delete(); - HttpSSLOptions.apply(); setState(() => isCertExist = false); showMessage("client_cert_remove_msg".tr()); } catch (e) { diff --git a/mobile/pigeon/network_api.dart b/mobile/pigeon/network_api.dart index 68d2f7d8fc..3ea29052d9 100644 --- a/mobile/pigeon/network_api.dart +++ b/mobile/pigeon/network_api.dart @@ -34,8 +34,14 @@ abstract class NetworkApi { void addCertificate(ClientCertData clientData); @async - ClientCertData selectCertificate(ClientCertPrompt promptText); + void selectCertificate(ClientCertPrompt promptText); @async void removeCertificate(); + + bool hasCertificate(); + + int getClientPointer(); + + void setRequestHeaders(Map headers, List serverUrls); } diff --git a/mobile/pigeon/remote_image_api.dart b/mobile/pigeon/remote_image_api.dart index 333f65a225..7f0135acb8 100644 --- a/mobile/pigeon/remote_image_api.dart +++ b/mobile/pigeon/remote_image_api.dart @@ -5,8 +5,7 @@ import 'package:pigeon/pigeon.dart'; dartOut: 'lib/platform/remote_image_api.g.dart', swiftOut: 'ios/Runner/Images/RemoteImages.g.swift', swiftOptions: SwiftOptions(includeErrorClass: false), - kotlinOut: - 'android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt', + kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt', kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images', includeErrorClass: false), dartOptions: DartOptions(), dartPackageName: 'immich_mobile', @@ -15,12 +14,7 @@ import 'package:pigeon/pigeon.dart'; @HostApi() abstract class RemoteImageApi { @async - Map? requestImage( - String url, { - required Map headers, - required int requestId, - required bool preferEncoded, - }); + Map? requestImage(String url, {required int requestId, required bool preferEncoded}); void cancelRequest(int requestId); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 28adfc2ab7..de116abb7e 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -201,22 +201,6 @@ packages: url: "https://pub.dev" source: hosted version: "8.9.5" - cancellation_token: - dependency: transitive - description: - name: cancellation_token - sha256: ad95acf9d4b2f3563e25dc937f63587e46a70ce534e910b65d10e115490f1027 - url: "https://pub.dev" - source: hosted - version: "2.0.1" - cancellation_token_http: - dependency: "direct main" - description: - name: cancellation_token_http - sha256: "0fff478fe5153700396b3472ddf93303c219f1cb8d8e779e65b014cb9c7f0213" - url: "https://pub.dev" - source: hosted - version: "2.1.0" cast: dependency: "direct main" description: @@ -313,14 +297,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" - cronet_http: - dependency: "direct main" - description: - name: cronet_http - sha256: "1fff7f26ac0c4cda97fe2a9aa082494baee4775f167c27ba45f6c8e88571e3ab" - url: "https://pub.dev" - source: hosted - version: "1.7.0" crop_image: dependency: "direct main" description: @@ -356,11 +332,12 @@ packages: cupertino_http: dependency: "direct main" description: - name: cupertino_http - sha256: "82cbec60c90bf785a047a9525688b6dacac444e177e1d5a5876963d3c50369e8" - url: "https://pub.dev" - source: hosted - version: "2.4.0" + path: "pkgs/cupertino_http" + ref: a0a933358517c6d01cff37fc2a2752ee2d744a3c + resolved-ref: a0a933358517c6d01cff37fc2a2752ee2d744a3c + url: "https://github.com/mertalev/http" + source: git + version: "3.0.0-wip" custom_lint: dependency: "direct dev" description: @@ -1241,8 +1218,8 @@ packages: dependency: "direct main" description: path: "." - ref: e132bc3 - resolved-ref: e132bc3ecc6a6d8fc2089d96f849c8a13129500e + ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2" + resolved-ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2" url: "https://github.com/immich-app/native_video_player" source: git version: "1.3.1" @@ -1286,6 +1263,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + ok_http: + dependency: "direct main" + description: + path: "pkgs/ok_http" + ref: "549c24b0a4d3881a9a44b70f4873450d43c1c4af" + resolved-ref: "549c24b0a4d3881a9a44b70f4873450d43c1c4af" + url: "https://github.com/mertalev/http" + source: git + version: "0.1.1-wip" openapi: dependency: "direct main" description: @@ -1741,19 +1727,20 @@ packages: socket_io_client: dependency: "direct main" description: - name: socket_io_client - sha256: ede469f3e4c55e8528b4e023bdedbc20832e8811ab9b61679d1ba3ed5f01f23b - url: "https://pub.dev" - source: hosted - version: "2.0.3+1" + path: "." + ref: e1d813a240b5d5b7e2f141b2b605c5429b7cd006 + resolved-ref: e1d813a240b5d5b7e2f141b2b605c5429b7cd006 + url: "https://github.com/mertalev/socket.io-client-dart" + source: git + version: "3.1.4" socket_io_common: dependency: transitive description: name: socket_io_common - sha256: "2ab92f8ff3ebbd4b353bf4a98bee45cc157e3255464b2f90f66e09c4472047eb" + sha256: "162fbaecbf4bf9a9372a62a341b3550b51dcef2f02f3e5830a297fd48203d45b" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "3.1.1" source_gen: dependency: transitive description: @@ -2115,21 +2102,21 @@ packages: source: hosted version: "1.1.1" web_socket: - dependency: transitive + dependency: "direct main" description: name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" url: "https://pub.dev" source: hosted - version: "0.1.6" + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" webdriver: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 0b54dfc53e..3a075d67ff 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -12,7 +12,6 @@ dependencies: async: ^2.13.0 auto_route: ^9.2.0 background_downloader: ^9.3.0 - cancellation_token_http: ^2.1.0 cast: ^2.1.0 collection: ^1.19.1 connectivity_plus: ^6.1.3 @@ -57,7 +56,7 @@ dependencies: native_video_player: git: url: https://github.com/immich-app/native_video_player - ref: 'e132bc3' + ref: '0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2' network_info_plus: ^6.1.3 octo_image: ^2.1.0 openapi: @@ -76,7 +75,6 @@ dependencies: share_handler: ^0.0.25 share_plus: ^10.1.4 sliver_tools: ^0.2.12 - socket_io_client: ^2.0.3+1 stream_transform: ^2.1.1 thumbhash: 0.1.0+1 timezone: ^0.9.4 @@ -84,8 +82,21 @@ dependencies: uuid: ^4.5.1 wakelock_plus: ^1.3.0 worker_manager: ^7.2.7 - cronet_http: ^1.7.0 - cupertino_http: ^2.4.0 + web_socket: ^1.0.1 + socket_io_client: + git: + url: https://github.com/mertalev/socket.io-client-dart + ref: 'e1d813a240b5d5b7e2f141b2b605c5429b7cd006' # https://github.com/rikulo/socket.io-client-dart/pull/435 + cupertino_http: + git: + url: https://github.com/mertalev/http + ref: 'a0a933358517c6d01cff37fc2a2752ee2d744a3c' # https://github.com/dart-lang/http/pull/1876 + path: pkgs/cupertino_http/ + ok_http: + git: + url: https://github.com/mertalev/http + ref: '549c24b0a4d3881a9a44b70f4873450d43c1c4af' # https://github.com/dart-lang/http/pull/1877 + path: pkgs/ok_http/ dev_dependencies: auto_route_generator: ^9.0.0 diff --git a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart index 62aae4c0da..85eebacb14 100644 --- a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart +++ b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart @@ -54,13 +54,10 @@ void main() { when(() => mockApiService.apiClient).thenReturn(mockApiClient); when(() => mockApiService.syncApi).thenReturn(mockSyncApi); when(() => mockApiClient.basePath).thenReturn('http://demo.immich.app/api'); - when(() => mockApiService.applyToParams(any(), any())).thenAnswer((_) async => {}); - // Mock HTTP client behavior when(() => mockHttpClient.send(any())).thenAnswer((_) async => mockStreamedResponse); when(() => mockStreamedResponse.statusCode).thenReturn(200); when(() => mockStreamedResponse.stream).thenAnswer((_) => http.ByteStream(responseStreamController.stream)); - when(() => mockHttpClient.close()).thenAnswer((_) => {}); sut = SyncApiRepository(mockApiService); }); @@ -133,7 +130,6 @@ void main() { expect(onDataCallCount, 1); expect(abortWasCalledInCallback, isTrue); expect(receivedEventsBatch1.length, testBatchSize); - verify(() => mockHttpClient.close()).called(1); }); test('streamChanges does not process remaining lines in finally block if aborted', () async { @@ -181,7 +177,6 @@ void main() { expect(onDataCallCount, 1); expect(abortWasCalledInCallback, isTrue); - verify(() => mockHttpClient.close()).called(1); }); test('streamChanges processes remaining lines in finally block if not aborted', () async { @@ -240,7 +235,6 @@ void main() { expect(onDataCallCount, 2); expect(receivedEventsBatch1.length, testBatchSize); expect(receivedEventsBatch2.length, 1); - verify(() => mockHttpClient.close()).called(1); }); test('streamChanges handles stream error gracefully', () async { @@ -265,7 +259,6 @@ void main() { await expectLater(streamChangesFuture, throwsA(streamError)); expect(onDataCallCount, 0); - verify(() => mockHttpClient.close()).called(1); }); test('streamChanges throws ApiException on non-200 status code', () async { @@ -293,6 +286,5 @@ void main() { ); expect(onDataCallCount, 0); - verify(() => mockHttpClient.close()).called(1); }); } From 9b642633c1142e40c0fc41f921ee0d0894444a3c Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:13:29 +0100 Subject: [PATCH 083/150] fix(server): clarify transcoding bitrate policy (#26711) --- i18n/en.json | 2 +- server/src/services/media.service.spec.ts | 26 ++++++++++++++--------- server/src/services/media.service.ts | 4 +++- server/test/fixtures/media.stub.ts | 2 +- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 43f325a34a..4de12c5cc7 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -411,7 +411,7 @@ "transcoding_tone_mapping": "Tone-mapping", "transcoding_tone_mapping_description": "Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness.", "transcoding_transcode_policy": "Transcode policy", - "transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos will always be transcoded (except if transcoding is disabled).", + "transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos and videos with a pixel format other than YUV 4:2:0 will always be transcoded (except if transcoding is disabled).", "transcoding_two_pass_encoding": "Two-pass encoding", "transcoding_two_pass_encoding_setting_description": "Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled.", "transcoding_video_codec": "Video codec", diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index fc825fb273..12440fb263 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -2015,6 +2015,13 @@ describe(MediaService.name, () => { ); }); + it('should not transcode when policy bitrate and bitrate lower than max bitrate', async () => { + mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: '50M' } }); + await sut.handleVideoConversion({ id: 'video-id' }); + expect(mocks.media.transcode).not.toHaveBeenCalled(); + }); + it('should transcode when policy bitrate and bitrate higher than max bitrate', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: '30M' } }); @@ -2030,19 +2037,18 @@ describe(MediaService.name, () => { ); }); - it('should transcode when max bitrate is not a number', async () => { + it('should not transcode when max bitrate is not a number', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: 'foo' } }); await sut.handleVideoConversion({ id: 'video-id' }); - expect(mocks.media.transcode).toHaveBeenCalledWith( - '/original/path.ext', - expect.any(String), - expect.objectContaining({ - inputOptions: expect.any(Array), - outputOptions: expect.any(Array), - twoPass: false, - }), - ); + expect(mocks.media.transcode).not.toHaveBeenCalled(); + }); + + it('should not transcode when max bitrate is 0', async () => { + mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: '0' } }); + await sut.handleVideoConversion({ id: 'video-id' }); + expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should not scale resolution if no target resolution', async () => { diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 3555d7d108..e49b8c10af 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -717,7 +717,8 @@ export class MediaService extends BaseService { const scalingEnabled = ffmpegConfig.targetResolution !== 'original'; const targetRes = Number.parseInt(ffmpegConfig.targetResolution); const isLargerThanTargetRes = scalingEnabled && Math.min(stream.height, stream.width) > targetRes; - const isLargerThanTargetBitrate = stream.bitrate > this.parseBitrateToBps(ffmpegConfig.maxBitrate); + const maxBitrate = this.parseBitrateToBps(ffmpegConfig.maxBitrate); + const isLargerThanTargetBitrate = maxBitrate > 0 && stream.bitrate > maxBitrate; const isTargetVideoCodec = ffmpegConfig.acceptedVideoCodecs.includes(stream.codecName as VideoCodec); const isRequired = !isTargetVideoCodec || !stream.pixelFormat.endsWith('420p'); @@ -769,6 +770,7 @@ export class MediaService extends BaseService { const bitrateValue = Number.parseInt(bitrateString); if (Number.isNaN(bitrateValue)) { + this.logger.log(`Maximum bitrate '${bitrateString} is not a number and will be ignored.`); return 0; } diff --git a/server/test/fixtures/media.stub.ts b/server/test/fixtures/media.stub.ts index 727f5ae7cf..f80ad70c8f 100644 --- a/server/test/fixtures/media.stub.ts +++ b/server/test/fixtures/media.stub.ts @@ -112,7 +112,7 @@ export const probeStub = { }), videoStream40Mbps: Object.freeze({ ...probeStubDefault, - videoStreams: [{ ...probeStubDefaultVideoStream[0], bitrate: 40_000_000 }], + videoStreams: [{ ...probeStubDefaultVideoStream[0], bitrate: 40_000_000, codecName: 'h264' }], }), videoStreamMTS: Object.freeze({ ...probeStubDefault, From ba3f1146255f9626468e7fc9cb601ae121542f0a Mon Sep 17 00:00:00 2001 From: Marvin M <39344769+M123-dev@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:18:01 +0100 Subject: [PATCH 084/150] chore: always use Package Imports (#26630) * chore: always_use_package_imports * fix: lint --------- Co-authored-by: Alex --- mobile/analysis_options.yaml | 3 ++- .../lib/infrastructure/repositories/db.repository.dart | 3 +-- .../repositories/logger_db.repository.dart | 3 +-- mobile/lib/pages/editing/crop.page.dart | 3 +-- mobile/lib/providers/infrastructure/memory.provider.dart | 3 +-- .../providers/infrastructure/remote_album.provider.dart | 3 +-- mobile/lib/services/share.service.dart | 3 +-- mobile/lib/utils/cache/widgets_binding.dart | 3 +-- .../lib/widgets/asset_grid/immich_asset_grid_view.dart | 9 ++++----- .../photo_view/src/core/photo_view_gesture_detector.dart | 3 +-- .../lib/widgets/photo_view/src/photo_view_wrappers.dart | 9 ++++----- .../asset_list_settings/asset_list_settings.dart | 3 +-- .../asset_viewer_settings/asset_viewer_settings.dart | 2 +- mobile/packages/ui/lib/src/components/close_button.dart | 3 +-- 14 files changed, 21 insertions(+), 32 deletions(-) diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 275a38a970..895203fb98 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -9,7 +9,7 @@ # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml -formatter: +formatter: page_width: 120 linter: @@ -33,6 +33,7 @@ linter: require_trailing_commas: true unrelated_type_equality_checks: true prefer_const_constructors: true + always_use_package_imports: true # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 84fcc55cfd..80b6712703 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -24,11 +24,10 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart'; import 'package:isar/isar.dart' hide Index; -import 'db.repository.drift.dart'; - // #zoneTxn is the symbol used by Isar to mark a transaction within the current zone // ref: isar/isar_common.dart const Symbol _kzoneTxn = #zoneTxn; diff --git a/mobile/lib/infrastructure/repositories/logger_db.repository.dart b/mobile/lib/infrastructure/repositories/logger_db.repository.dart index 0037f4a1e3..e494782fa6 100644 --- a/mobile/lib/infrastructure/repositories/logger_db.repository.dart +++ b/mobile/lib/infrastructure/repositories/logger_db.repository.dart @@ -2,8 +2,7 @@ import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'package:immich_mobile/domain/interfaces/db.interface.dart'; import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; - -import 'logger_db.repository.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart'; @DriftDatabase(tables: [LogMessageEntity]) class DriftLogger extends $DriftLogger implements IDatabaseRepository { diff --git a/mobile/lib/pages/editing/crop.page.dart b/mobile/lib/pages/editing/crop.page.dart index 8cd13fed64..a6a66c1358 100644 --- a/mobile/lib/pages/editing/crop.page.dart +++ b/mobile/lib/pages/editing/crop.page.dart @@ -7,11 +7,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/pages/editing/edit.page.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart'; -import 'edit.page.dart'; - /// A widget for cropping an image. /// This widget uses [HookWidget] to manage its lifecycle and state. It allows /// users to crop an image and then navigate to the [EditImagePage] with the diff --git a/mobile/lib/providers/infrastructure/memory.provider.dart b/mobile/lib/providers/infrastructure/memory.provider.dart index 0965f4349b..6fc75b8e6a 100644 --- a/mobile/lib/providers/infrastructure/memory.provider.dart +++ b/mobile/lib/providers/infrastructure/memory.provider.dart @@ -1,11 +1,10 @@ import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/services/memory.service.dart'; import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'db.provider.dart'; - final driftMemoryRepositoryProvider = Provider( (ref) => DriftMemoryRepository(ref.watch(driftProvider)), ); diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart index e3cffeb093..606ce3f129 100644 --- a/mobile/lib/providers/infrastructure/remote_album.provider.dart +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -6,11 +6,10 @@ import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'album.provider.dart'; - class RemoteAlbumState { final List albums; diff --git a/mobile/lib/services/share.service.dart b/mobile/lib/services/share.service.dart index 06a4a192d4..a0998d6d3d 100644 --- a/mobile/lib/services/share.service.dart +++ b/mobile/lib/services/share.service.dart @@ -6,12 +6,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/response_extensions.dart'; import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/services/api.service.dart'; import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; -import 'api.service.dart'; - final shareServiceProvider = Provider((ref) => ShareService(ref.watch(apiServiceProvider))); class ShareService { diff --git a/mobile/lib/utils/cache/widgets_binding.dart b/mobile/lib/utils/cache/widgets_binding.dart index 2749a54d97..9f583d3220 100644 --- a/mobile/lib/utils/cache/widgets_binding.dart +++ b/mobile/lib/utils/cache/widgets_binding.dart @@ -1,6 +1,5 @@ import 'package:flutter/widgets.dart'; - -import 'custom_image_cache.dart'; +import 'package:immich_mobile/utils/cache/custom_image_cache.dart'; final class ImmichWidgetsBinding extends WidgetsFlutterBinding { @override diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index 7db03a33aa..c323c573b4 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -23,17 +23,16 @@ import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart'; +import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart'; +import 'package:immich_mobile/widgets/asset_grid/disable_multi_select_button.dart'; +import 'package:immich_mobile/widgets/asset_grid/draggable_scrollbar_custom.dart'; +import 'package:immich_mobile/widgets/asset_grid/group_divider_title.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; -import 'asset_grid_data_structure.dart'; -import 'disable_multi_select_button.dart'; -import 'draggable_scrollbar_custom.dart'; -import 'group_divider_title.dart'; - typedef ImmichAssetGridSelectionListener = void Function(bool, Set); class ImmichAssetGridView extends ConsumerStatefulWidget { diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart index 6cbcec8d82..7b55e0e37e 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart @@ -1,7 +1,6 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; - -import 'photo_view_hit_corners.dart'; +import 'package:immich_mobile/widgets/photo_view/src/core/photo_view_hit_corners.dart'; /// Credit to [eduribas](https://github.com/eduribas/photo_view/commit/508d9b77dafbcf88045b4a7fee737eed4064ea2c) /// for the gist diff --git a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart index ee18668f52..d8d2ae7ee5 100644 --- a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart +++ b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart @@ -1,9 +1,8 @@ import 'package:flutter/widgets.dart'; - -import '../photo_view.dart'; -import 'core/photo_view_core.dart'; -import 'photo_view_default_widgets.dart'; -import 'utils/photo_view_utils.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; +import 'package:immich_mobile/widgets/photo_view/src/core/photo_view_core.dart'; +import 'package:immich_mobile/widgets/photo_view/src/photo_view_default_widgets.dart'; +import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_utils.dart'; class ImageWrapper extends StatefulWidget { const ImageWrapper({ diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart index 907cd19843..82394bdc07 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart @@ -6,11 +6,10 @@ import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_group_settings.dart'; +import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_layout_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'asset_list_layout_settings.dart'; - class AssetListSettings extends HookConsumerWidget { const AssetListSettings({super.key}); diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart index 1555790ff9..a2bca2745f 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart'; +import 'package:immich_mobile/widgets/settings/asset_viewer_settings/video_viewer_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; -import 'video_viewer_settings.dart'; class AssetViewerSettings extends StatelessWidget { const AssetViewerSettings({super.key}); diff --git a/mobile/packages/ui/lib/src/components/close_button.dart b/mobile/packages/ui/lib/src/components/close_button.dart index 9308fdaadb..d23c309c91 100644 --- a/mobile/packages/ui/lib/src/components/close_button.dart +++ b/mobile/packages/ui/lib/src/components/close_button.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:immich_ui/src/components/icon_button.dart'; import 'package:immich_ui/src/types.dart'; -import 'icon_button.dart'; - class ImmichCloseButton extends StatelessWidget { final VoidCallback? onPressed; final ImmichVariant variant; From 5ab05e57fae2e3b8ae111c9fa60163ced0bb69f3 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:45:21 +0000 Subject: [PATCH 085/150] fix(mobile): inconsistent asset details background (#26634) The background of the photo view does not extend below the height of the viewport, and so the asset details fade in over black with the photo view, and the standard surface colour scheme of the scaffold for the rest. This leads to a janky animation. We can't change the background of the scaffold to black, as it in turn makes the iOS bouncing scroll physics cut off incorrectly. The best fix is to remove background decoration from the photo view, and defer to the parent to colour the background. Co-authored-by: Alex --- .../asset_viewer/asset_page.widget.dart | 69 +++++++++---------- 1 file changed, 34 insertions(+), 35 deletions(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index ea7ff51fa6..abb7b779fe 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -292,7 +292,6 @@ class _AssetPageState extends ConsumerState { required PhotoViewHeroAttributes? heroAttributes, required bool isCurrent, required bool isPlayingMotionVideo, - required BoxDecoration backgroundDecoration, }) { final size = context.sizeData; @@ -303,7 +302,6 @@ class _AssetPageState extends ConsumerState { imageProvider: getFullImageProvider(asset, size: size), heroAttributes: heroAttributes, loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()), - backgroundDecoration: backgroundDecoration, gaplessPlayback: true, filterQuality: FilterQuality.high, tightMode: true, @@ -345,7 +343,6 @@ class _AssetPageState extends ConsumerState { tightMode: true, onPageBuild: _onPageBuild, enablePanAlways: true, - backgroundDecoration: backgroundDecoration, child: NativeVideoViewer( key: _NativeVideoViewerKey(asset.heroTag), asset: asset, @@ -397,41 +394,43 @@ class _AssetPageState extends ConsumerState { SingleChildScrollView( controller: _scrollController, physics: const SnapScrollPhysics(), - child: Stack( - children: [ - SizedBox( - width: viewportWidth, - height: viewportHeight, - child: _buildPhotoView( - asset: displayAsset, - heroAttributes: isCurrent - ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') - : null, - isCurrent: isCurrent, - isPlayingMotionVideo: isPlayingMotionVideo, - backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent), + child: ColoredBox( + color: _showingDetails ? Colors.black : Colors.transparent, + child: Stack( + children: [ + SizedBox( + width: viewportWidth, + height: viewportHeight, + child: _buildPhotoView( + asset: displayAsset, + heroAttributes: isCurrent + ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') + : null, + isCurrent: isCurrent, + isPlayingMotionVideo: isPlayingMotionVideo, + ), ), - ), - IgnorePointer( - ignoring: !_showingDetails, - child: Column( - children: [ - SizedBox(height: detailsOffset), - GestureDetector( - onVerticalDragStart: _beginDrag, - onVerticalDragUpdate: _updateDrag, - onVerticalDragEnd: _endDrag, - onVerticalDragCancel: _onDragCancel, - child: AnimatedOpacity( - opacity: _showingDetails ? 1.0 : 0.0, - duration: Durations.short2, - child: AssetDetails(asset: displayAsset, minHeight: viewportHeight - snapTarget), + IgnorePointer( + ignoring: !_showingDetails, + child: Column( + children: [ + SizedBox(height: detailsOffset), + GestureDetector( + onVerticalDragStart: _beginDrag, + onVerticalDragUpdate: _updateDrag, + onVerticalDragEnd: _endDrag, + onVerticalDragCancel: _onDragCancel, + child: AnimatedOpacity( + opacity: _showingDetails ? 1.0 : 0.0, + duration: Durations.short2, + child: AssetDetails(asset: displayAsset, minHeight: viewportHeight - snapTarget), + ), ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ), if (stackChildren != null && stackChildren.isNotEmpty) From 7b0deb1fd3c2cf3a6f3109a2246eb7488f0896b4 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 5 Mar 2026 12:51:58 -0600 Subject: [PATCH 086/150] fix: playback style migration (#26718) --- mobile/lib/utils/migration.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 5a212e25ad..aeed9f616e 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -417,7 +417,8 @@ Future _populateLocalAssetPlaybackStyle(Drift db) async { } final trashedAssetMap = await nativeApi.getTrashedAssets(); - for (final assets in trashedAssetMap.values) { + for (final entry in trashedAssetMap.cast>().entries) { + final assets = entry.value.cast(); await db.batch((batch) { for (final asset in assets) { batch.update( From 9597f8c37fa6d2602fadab105da5468df40001be Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Thu, 5 Mar 2026 13:03:59 -0600 Subject: [PATCH 087/150] feat(mobile): SyncAssetEditV1 (#26518) * feat(mobile): SyncAssetEditV1 * fix: websocket handling * fix: server version requirement * fix: revert pubspec changes --- .../drift_schemas/main/drift_schema_v22.json | 1 + .../lib/domain/models/asset_edit.model.dart | 21 + .../domain/services/sync_stream.service.dart | 62 +- mobile/lib/domain/utils/background_sync.dart | 8 +- .../entities/asset_edit.entity.dart | 33 + .../entities/asset_edit.entity.drift.dart | 752 ++ .../repositories/db.repository.dart | 8 +- .../repositories/db.repository.drift.dart | 23 +- .../repositories/db.repository.steps.dart | 567 ++ .../repositories/sync_api.repository.dart | 3 + .../repositories/sync_stream.repository.dart | 73 +- mobile/lib/providers/websocket.provider.dart | 2 +- mobile/test/drift/main/generated/schema.dart | 4 + .../test/drift/main/generated/schema_v22.dart | 8845 +++++++++++++++++ 14 files changed, 10363 insertions(+), 39 deletions(-) create mode 100644 mobile/drift_schemas/main/drift_schema_v22.json create mode 100644 mobile/lib/domain/models/asset_edit.model.dart create mode 100644 mobile/lib/infrastructure/entities/asset_edit.entity.dart create mode 100644 mobile/lib/infrastructure/entities/asset_edit.entity.drift.dart create mode 100644 mobile/test/drift/main/generated/schema_v22.dart diff --git a/mobile/drift_schemas/main/drift_schema_v22.json b/mobile/drift_schemas/main/drift_schema_v22.json new file mode 100644 index 0000000000..ff8856addd --- /dev/null +++ b/mobile/drift_schemas/main/drift_schema_v22.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"live_photo_video_id","getter_name":"livePhotoVideoId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}},{"name":"stack_id","getter_name":"stackId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"library_id","getter_name":"libraryId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_edited","getter_name":"isEdited","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_edited\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_edited\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[0],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"i_cloud_id","getter_name":"iCloudId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"adjustment_time","getter_name":"adjustmentTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"playback_style","getter_name":"playbackStyle","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetPlaybackStyle.values)","dart_type_name":"AssetPlaybackStyle"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":5,"references":[4],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"linked_remote_album_id","getter_name":"linkedRemoteAlbumId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":6,"references":[3,5],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":7,"references":[6],"type":"index","data":{"on":6,"name":"idx_local_album_asset_album_asset","sql":"CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)","unique":false,"columns":[]}},{"id":8,"references":[4],"type":"index","data":{"on":4,"name":"idx_remote_album_owner_id","sql":"CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)","unique":false,"columns":[]}},{"id":9,"references":[3],"type":"index","data":{"on":3,"name":"idx_local_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)","unique":false,"columns":[]}},{"id":10,"references":[3],"type":"index","data":{"on":3,"name":"idx_local_asset_cloud_id","sql":"CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)","unique":false,"columns":[]}},{"id":11,"references":[2],"type":"index","data":{"on":2,"name":"idx_stack_primary_asset_id","sql":"CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)","unique":false,"columns":[]}},{"id":12,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_owner_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)","unique":false,"columns":[]}},{"id":13,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum\nON remote_asset_entity (owner_id, checksum)\nWHERE (library_id IS NULL);\n","unique":true,"columns":[]}},{"id":14,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_assets_owner_library_checksum","sql":"CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum\nON remote_asset_entity (owner_id, library_id, checksum)\nWHERE (library_id IS NOT NULL);\n","unique":true,"columns":[]}},{"id":15,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)","unique":false,"columns":[]}},{"id":16,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_stack_id","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)","unique":false,"columns":[]}},{"id":17,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_local_date_time_day","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME('%Y-%m-%d', local_date_time))","unique":false,"columns":[]}},{"id":18,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_local_date_time_month","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME('%Y-%m', local_date_time))","unique":false,"columns":[]}},{"id":19,"references":[],"type":"table","data":{"name":"auth_user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"has_profile_image","getter_name":"hasProfileImage","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"has_profile_image\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"has_profile_image\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"profile_changed_at","getter_name":"profileChangedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"avatar_color","getter_name":"avatarColor","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AvatarColor.values)","dart_type_name":"AvatarColor"}},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"pin_code","getter_name":"pinCode","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":20,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":21,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":22,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":23,"references":[1,4],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":24,"references":[4,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":25,"references":[1],"type":"table","data":{"name":"remote_asset_cloud_id_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"cloud_id","getter_name":"cloudId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"adjustment_time","getter_name":"adjustmentTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":26,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":27,"references":[1,26],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}},{"id":28,"references":[0],"type":"table","data":{"name":"person_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"face_asset_id","getter_name":"faceAssetId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_hidden","getter_name":"isHidden","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_hidden\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_hidden\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"color","getter_name":"color","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"birth_date","getter_name":"birthDate","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":29,"references":[1,28],"type":"table","data":{"name":"asset_face_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"person_id","getter_name":"personId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES person_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES person_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"image_width","getter_name":"imageWidth","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"image_height","getter_name":"imageHeight","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x1","getter_name":"boundingBoxX1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y1","getter_name":"boundingBoxY1","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_x2","getter_name":"boundingBoxX2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"bounding_box_y2","getter_name":"boundingBoxY2","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"source_type","getter_name":"sourceType","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_visible","getter_name":"isVisible","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_visible\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_visible\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":30,"references":[],"type":"table","data":{"name":"store_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"string_value","getter_name":"stringValue","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"int_value","getter_name":"intValue","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":31,"references":[],"type":"table","data":{"name":"trashed_local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"source","getter_name":"source","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(TrashOrigin.values)","dart_type_name":"TrashOrigin"}},{"name":"playback_style","getter_name":"playbackStyle","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetPlaybackStyle.values)","dart_type_name":"AssetPlaybackStyle"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id","album_id"]}},{"id":32,"references":[1],"type":"table","data":{"name":"asset_edit_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"action","getter_name":"action","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetEditAction.values)","dart_type_name":"AssetEditAction"}},{"name":"parameters","getter_name":"parameters","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"editParameterConverter","dart_type_name":"Map"}},{"name":"sequence","getter_name":"sequence","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":33,"references":[21],"type":"index","data":{"on":21,"name":"idx_partner_shared_with_id","sql":"CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)","unique":false,"columns":[]}},{"id":34,"references":[22],"type":"index","data":{"on":22,"name":"idx_lat_lng","sql":"CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)","unique":false,"columns":[]}},{"id":35,"references":[23],"type":"index","data":{"on":23,"name":"idx_remote_album_asset_album_asset","sql":"CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)","unique":false,"columns":[]}},{"id":36,"references":[25],"type":"index","data":{"on":25,"name":"idx_remote_asset_cloud_id","sql":"CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)","unique":false,"columns":[]}},{"id":37,"references":[28],"type":"index","data":{"on":28,"name":"idx_person_owner_id","sql":"CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)","unique":false,"columns":[]}},{"id":38,"references":[29],"type":"index","data":{"on":29,"name":"idx_asset_face_person_id","sql":"CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)","unique":false,"columns":[]}},{"id":39,"references":[29],"type":"index","data":{"on":29,"name":"idx_asset_face_asset_id","sql":"CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)","unique":false,"columns":[]}},{"id":40,"references":[31],"type":"index","data":{"on":31,"name":"idx_trashed_local_asset_checksum","sql":"CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)","unique":false,"columns":[]}},{"id":41,"references":[31],"type":"index","data":{"on":31,"name":"idx_trashed_local_asset_album","sql":"CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)","unique":false,"columns":[]}},{"id":42,"references":[32],"type":"index","data":{"on":32,"name":"idx_asset_edit_asset_id","sql":"CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)","unique":false,"columns":[]}}]} \ No newline at end of file diff --git a/mobile/lib/domain/models/asset_edit.model.dart b/mobile/lib/domain/models/asset_edit.model.dart new file mode 100644 index 0000000000..b3266dba46 --- /dev/null +++ b/mobile/lib/domain/models/asset_edit.model.dart @@ -0,0 +1,21 @@ +import "package:openapi/api.dart" as api show AssetEditAction; + +enum AssetEditAction { rotate, crop, mirror, other } + +extension AssetEditActionExtension on AssetEditAction { + api.AssetEditAction? toDto() { + return switch (this) { + AssetEditAction.rotate => api.AssetEditAction.rotate, + AssetEditAction.crop => api.AssetEditAction.crop, + AssetEditAction.mirror => api.AssetEditAction.mirror, + AssetEditAction.other => null, + }; + } +} + +class AssetEdit { + final AssetEditAction action; + final Map parameters; + + const AssetEdit({required this.action, required this.parameters}); +} diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index 2bda6cd683..9769c2eeec 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -205,6 +205,10 @@ class SyncStreamService { return _syncStreamRepository.deleteAssetsV1(data.cast()); case SyncEntityType.assetExifV1: return _syncStreamRepository.updateAssetsExifV1(data.cast()); + case SyncEntityType.assetEditV1: + return _syncStreamRepository.updateAssetEditsV1(data.cast()); + case SyncEntityType.assetEditDeleteV1: + return _syncStreamRepository.deleteAssetEditsV1(data.cast()); case SyncEntityType.assetMetadataV1: return _syncStreamRepository.updateAssetsMetadataV1(data.cast()); case SyncEntityType.assetMetadataDeleteV1: @@ -336,39 +340,43 @@ class SyncStreamService { } } - Future handleWsAssetEditReadyV1Batch(List batchData) async { - if (batchData.isEmpty) return; - - _logger.info('Processing batch of ${batchData.length} AssetEditReadyV1 events'); - - final List assets = []; + Future handleWsAssetEditReadyV1(dynamic data) async { + _logger.info('Processing AssetEditReadyV1 event'); try { - for (final data in batchData) { - if (data is! Map) { - continue; - } - - final payload = data; - final assetData = payload['asset']; - - if (assetData == null) { - continue; - } - - final asset = SyncAssetV1.fromJson(assetData); - - if (asset != null) { - assets.add(asset); - } + if (data is! Map) { + throw ArgumentError("Invalid data format for AssetEditReadyV1 event"); } - if (assets.isNotEmpty) { - await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-edit'); - _logger.info('Successfully processed ${assets.length} edited assets'); + final payload = data; + + if (payload['asset'] == null) { + throw ArgumentError("Missing 'asset' field in AssetEditReadyV1 event data"); } + + final asset = SyncAssetV1.fromJson(payload['asset']); + if (asset == null) { + throw ArgumentError("Failed to parse 'asset' field in AssetEditReadyV1 event data"); + } + + List assetEdits = []; + + // Edits are only send on v2.6.0+ + if (payload['edit'] != null && payload['edit'] is List) { + assetEdits = (payload['edit'] as List) + .map((e) => SyncAssetEditV1.fromJson(e)) + .whereType() + .toList(); + } + + await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit'); + await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit'); + + _logger.info( + 'Successfully processed AssetEditReadyV1 event for asset ${asset.id} with ${assetEdits.length} edits', + ); } catch (error, stackTrace) { - _logger.severe("Error processing AssetEditReadyV1 websocket batch events", error, stackTrace); + _logger.severe("Error processing AssetEditReadyV1 websocket event", error, stackTrace); } } diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index 6840bae595..7c9b6ae061 100644 --- a/mobile/lib/domain/utils/background_sync.dart +++ b/mobile/lib/domain/utils/background_sync.dart @@ -196,11 +196,11 @@ class BackgroundSyncManager { }); } - Future syncWebsocketEditBatch(List batchData) { + Future syncWebsocketEdit(dynamic data) { if (_syncWebsocketTask != null) { return _syncWebsocketTask!.future; } - _syncWebsocketTask = _handleWsAssetEditReadyV1Batch(batchData); + _syncWebsocketTask = _handleWsAssetEditReadyV1(data); return _syncWebsocketTask!.whenComplete(() { _syncWebsocketTask = null; }); @@ -242,7 +242,7 @@ Cancelable _handleWsAssetUploadReadyV1Batch(List batchData) => ru debugLabel: 'websocket-batch', ); -Cancelable _handleWsAssetEditReadyV1Batch(List batchData) => runInIsolateGentle( - computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1Batch(batchData), +Cancelable _handleWsAssetEditReadyV1(dynamic data) => runInIsolateGentle( + computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1(data), debugLabel: 'websocket-edit', ); diff --git a/mobile/lib/infrastructure/entities/asset_edit.entity.dart b/mobile/lib/infrastructure/entities/asset_edit.entity.dart new file mode 100644 index 0000000000..22d059bdb4 --- /dev/null +++ b/mobile/lib/infrastructure/entities/asset_edit.entity.dart @@ -0,0 +1,33 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.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_asset_edit_asset_id ON asset_edit_entity (asset_id)') +class AssetEditEntity extends Table with DriftDefaultsMixin { + const AssetEditEntity(); + + TextColumn get id => text()(); + + TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)(); + + IntColumn get action => intEnum()(); + + BlobColumn get parameters => blob().map(editParameterConverter)(); + + IntColumn get sequence => integer()(); + + @override + Set get primaryKey => {id}; +} + +final JsonTypeConverter2, Uint8List, Object?> editParameterConverter = TypeConverter.jsonb( + fromJson: (json) => json as Map, +); + +extension AssetEditEntityDataDomainEx on AssetEditEntityData { + AssetEdit toDto() { + return AssetEdit(action: action, parameters: parameters); + } +} diff --git a/mobile/lib/infrastructure/entities/asset_edit.entity.drift.dart b/mobile/lib/infrastructure/entities/asset_edit.entity.drift.dart new file mode 100644 index 0000000000..fc40bf9030 --- /dev/null +++ b/mobile/lib/infrastructure/entities/asset_edit.entity.drift.dart @@ -0,0 +1,752 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart' + as i1; +import 'package:immich_mobile/domain/models/asset_edit.model.dart' as i2; +import 'dart:typed_data' as i3; +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart' + as i4; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart' + as i5; +import 'package:drift/internal/modular.dart' as i6; + +typedef $$AssetEditEntityTableCreateCompanionBuilder = + i1.AssetEditEntityCompanion Function({ + required String id, + required String assetId, + required i2.AssetEditAction action, + required Map parameters, + required int sequence, + }); +typedef $$AssetEditEntityTableUpdateCompanionBuilder = + i1.AssetEditEntityCompanion Function({ + i0.Value id, + i0.Value assetId, + i0.Value action, + i0.Value> parameters, + i0.Value sequence, + }); + +final class $$AssetEditEntityTableReferences + extends + i0.BaseReferences< + i0.GeneratedDatabase, + i1.$AssetEditEntityTable, + i1.AssetEditEntityData + > { + $$AssetEditEntityTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static i5.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) => + i6.ReadDatabaseContainer(db) + .resultSet('remote_asset_entity') + .createAlias( + i0.$_aliasNameGenerator( + i6.ReadDatabaseContainer(db) + .resultSet('asset_edit_entity') + .assetId, + i6.ReadDatabaseContainer( + db, + ).resultSet('remote_asset_entity').id, + ), + ); + + i5.$$RemoteAssetEntityTableProcessedTableManager get assetId { + final $_column = $_itemColumn('asset_id')!; + + final manager = i5 + .$$RemoteAssetEntityTableTableManager( + $_db, + i6.ReadDatabaseContainer( + $_db, + ).resultSet('remote_asset_entity'), + ) + .filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_assetIdTable($_db)); + if (item == null) return manager; + return i0.ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$AssetEditEntityTableFilterComposer + extends i0.Composer { + $$AssetEditEntityTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnWithTypeConverterFilters + get action => $composableBuilder( + column: $table.action, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnWithTypeConverterFilters< + Map, + Map, + i3.Uint8List + > + get parameters => $composableBuilder( + column: $table.parameters, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnFilters get sequence => $composableBuilder( + column: $table.sequence, + builder: (column) => i0.ColumnFilters(column), + ); + + i5.$$RemoteAssetEntityTableFilterComposer get assetId { + final i5.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.assetId, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i5.$$RemoteAssetEntityTableFilterComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$AssetEditEntityTableOrderingComposer + extends i0.Composer { + $$AssetEditEntityTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get action => $composableBuilder( + column: $table.action, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get parameters => $composableBuilder( + column: $table.parameters, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get sequence => $composableBuilder( + column: $table.sequence, + builder: (column) => i0.ColumnOrderings(column), + ); + + i5.$$RemoteAssetEntityTableOrderingComposer get assetId { + final i5.$$RemoteAssetEntityTableOrderingComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.assetId, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i5.$$RemoteAssetEntityTableOrderingComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$AssetEditEntityTableAnnotationComposer + extends i0.Composer { + $$AssetEditEntityTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter get action => + $composableBuilder(column: $table.action, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter, i3.Uint8List> + get parameters => $composableBuilder( + column: $table.parameters, + builder: (column) => column, + ); + + i0.GeneratedColumn get sequence => + $composableBuilder(column: $table.sequence, builder: (column) => column); + + i5.$$RemoteAssetEntityTableAnnotationComposer get assetId { + final i5.$$RemoteAssetEntityTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.assetId, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i5.$$RemoteAssetEntityTableAnnotationComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$AssetEditEntityTableTableManager + extends + i0.RootTableManager< + i0.GeneratedDatabase, + i1.$AssetEditEntityTable, + i1.AssetEditEntityData, + i1.$$AssetEditEntityTableFilterComposer, + i1.$$AssetEditEntityTableOrderingComposer, + i1.$$AssetEditEntityTableAnnotationComposer, + $$AssetEditEntityTableCreateCompanionBuilder, + $$AssetEditEntityTableUpdateCompanionBuilder, + (i1.AssetEditEntityData, i1.$$AssetEditEntityTableReferences), + i1.AssetEditEntityData, + i0.PrefetchHooks Function({bool assetId}) + > { + $$AssetEditEntityTableTableManager( + i0.GeneratedDatabase db, + i1.$AssetEditEntityTable table, + ) : super( + i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$$AssetEditEntityTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + i1.$$AssetEditEntityTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => i1 + .$$AssetEditEntityTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + i0.Value id = const i0.Value.absent(), + i0.Value assetId = const i0.Value.absent(), + i0.Value action = const i0.Value.absent(), + i0.Value> parameters = + const i0.Value.absent(), + i0.Value sequence = const i0.Value.absent(), + }) => i1.AssetEditEntityCompanion( + id: id, + assetId: assetId, + action: action, + parameters: parameters, + sequence: sequence, + ), + createCompanionCallback: + ({ + required String id, + required String assetId, + required i2.AssetEditAction action, + required Map parameters, + required int sequence, + }) => i1.AssetEditEntityCompanion.insert( + id: id, + assetId: assetId, + action: action, + parameters: parameters, + sequence: sequence, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + i1.$$AssetEditEntityTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({assetId = false}) { + return i0.PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends i0.TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (assetId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.assetId, + referencedTable: i1 + .$$AssetEditEntityTableReferences + ._assetIdTable(db), + referencedColumn: i1 + .$$AssetEditEntityTableReferences + ._assetIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$AssetEditEntityTableProcessedTableManager = + i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.$AssetEditEntityTable, + i1.AssetEditEntityData, + i1.$$AssetEditEntityTableFilterComposer, + i1.$$AssetEditEntityTableOrderingComposer, + i1.$$AssetEditEntityTableAnnotationComposer, + $$AssetEditEntityTableCreateCompanionBuilder, + $$AssetEditEntityTableUpdateCompanionBuilder, + (i1.AssetEditEntityData, i1.$$AssetEditEntityTableReferences), + i1.AssetEditEntityData, + i0.PrefetchHooks Function({bool assetId}) + >; +i0.Index get idxAssetEditAssetId => i0.Index( + 'idx_asset_edit_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)', +); + +class $AssetEditEntityTable extends i4.AssetEditEntity + with i0.TableInfo<$AssetEditEntityTable, i1.AssetEditEntityData> { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + $AssetEditEntityTable(this.attachedDatabase, [this._alias]); + static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id'); + @override + late final i0.GeneratedColumn id = i0.GeneratedColumn( + 'id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + ); + static const i0.VerificationMeta _assetIdMeta = const i0.VerificationMeta( + 'assetId', + ); + @override + late final i0.GeneratedColumn assetId = i0.GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: i0.GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + @override + late final i0.GeneratedColumnWithTypeConverter + action = + i0.GeneratedColumn( + 'action', + aliasedName, + false, + type: i0.DriftSqlType.int, + requiredDuringInsert: true, + ).withConverter( + i1.$AssetEditEntityTable.$converteraction, + ); + @override + late final i0.GeneratedColumnWithTypeConverter< + Map, + i3.Uint8List + > + parameters = + i0.GeneratedColumn( + 'parameters', + aliasedName, + false, + type: i0.DriftSqlType.blob, + requiredDuringInsert: true, + ).withConverter>( + i1.$AssetEditEntityTable.$converterparameters, + ); + static const i0.VerificationMeta _sequenceMeta = const i0.VerificationMeta( + 'sequence', + ); + @override + late final i0.GeneratedColumn sequence = i0.GeneratedColumn( + 'sequence', + aliasedName, + false, + type: i0.DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + assetId, + action, + parameters, + sequence, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'asset_edit_entity'; + @override + i0.VerificationContext validateIntegrity( + i0.Insertable instance, { + bool isInserting = false, + }) { + final context = i0.VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('asset_id')) { + context.handle( + _assetIdMeta, + assetId.isAcceptableOrUnknown(data['asset_id']!, _assetIdMeta), + ); + } else if (isInserting) { + context.missing(_assetIdMeta); + } + if (data.containsKey('sequence')) { + context.handle( + _sequenceMeta, + sequence.isAcceptableOrUnknown(data['sequence']!, _sequenceMeta), + ); + } else if (isInserting) { + context.missing(_sequenceMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + i1.AssetEditEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.AssetEditEntityData( + id: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + assetId: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + action: i1.$AssetEditEntityTable.$converteraction.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}action'], + )!, + ), + parameters: i1.$AssetEditEntityTable.$converterparameters.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.blob, + data['${effectivePrefix}parameters'], + )!, + ), + sequence: attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}sequence'], + )!, + ); + } + + @override + $AssetEditEntityTable createAlias(String alias) { + return $AssetEditEntityTable(attachedDatabase, alias); + } + + static i0.JsonTypeConverter2 $converteraction = + const i0.EnumIndexConverter( + i2.AssetEditAction.values, + ); + static i0.JsonTypeConverter2, i3.Uint8List, Object?> + $converterparameters = i4.editParameterConverter; + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class AssetEditEntityData extends i0.DataClass + implements i0.Insertable { + final String id; + final String assetId; + final i2.AssetEditAction action; + final Map parameters; + final int sequence; + const AssetEditEntityData({ + required this.id, + required this.assetId, + required this.action, + required this.parameters, + required this.sequence, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = i0.Variable(id); + map['asset_id'] = i0.Variable(assetId); + { + map['action'] = i0.Variable( + i1.$AssetEditEntityTable.$converteraction.toSql(action), + ); + } + { + map['parameters'] = i0.Variable( + i1.$AssetEditEntityTable.$converterparameters.toSql(parameters), + ); + } + map['sequence'] = i0.Variable(sequence); + return map; + } + + factory AssetEditEntityData.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return AssetEditEntityData( + id: serializer.fromJson(json['id']), + assetId: serializer.fromJson(json['assetId']), + action: i1.$AssetEditEntityTable.$converteraction.fromJson( + serializer.fromJson(json['action']), + ), + parameters: i1.$AssetEditEntityTable.$converterparameters.fromJson( + serializer.fromJson(json['parameters']), + ), + sequence: serializer.fromJson(json['sequence']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'assetId': serializer.toJson(assetId), + 'action': serializer.toJson( + i1.$AssetEditEntityTable.$converteraction.toJson(action), + ), + 'parameters': serializer.toJson( + i1.$AssetEditEntityTable.$converterparameters.toJson(parameters), + ), + 'sequence': serializer.toJson(sequence), + }; + } + + i1.AssetEditEntityData copyWith({ + String? id, + String? assetId, + i2.AssetEditAction? action, + Map? parameters, + int? sequence, + }) => i1.AssetEditEntityData( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + action: action ?? this.action, + parameters: parameters ?? this.parameters, + sequence: sequence ?? this.sequence, + ); + AssetEditEntityData copyWithCompanion(i1.AssetEditEntityCompanion data) { + return AssetEditEntityData( + id: data.id.present ? data.id.value : this.id, + assetId: data.assetId.present ? data.assetId.value : this.assetId, + action: data.action.present ? data.action.value : this.action, + parameters: data.parameters.present + ? data.parameters.value + : this.parameters, + sequence: data.sequence.present ? data.sequence.value : this.sequence, + ); + } + + @override + String toString() { + return (StringBuffer('AssetEditEntityData(') + ..write('id: $id, ') + ..write('assetId: $assetId, ') + ..write('action: $action, ') + ..write('parameters: $parameters, ') + ..write('sequence: $sequence') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, assetId, action, parameters, sequence); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.AssetEditEntityData && + other.id == this.id && + other.assetId == this.assetId && + other.action == this.action && + other.parameters == this.parameters && + other.sequence == this.sequence); +} + +class AssetEditEntityCompanion + extends i0.UpdateCompanion { + final i0.Value id; + final i0.Value assetId; + final i0.Value action; + final i0.Value> parameters; + final i0.Value sequence; + const AssetEditEntityCompanion({ + this.id = const i0.Value.absent(), + this.assetId = const i0.Value.absent(), + this.action = const i0.Value.absent(), + this.parameters = const i0.Value.absent(), + this.sequence = const i0.Value.absent(), + }); + AssetEditEntityCompanion.insert({ + required String id, + required String assetId, + required i2.AssetEditAction action, + required Map parameters, + required int sequence, + }) : id = i0.Value(id), + assetId = i0.Value(assetId), + action = i0.Value(action), + parameters = i0.Value(parameters), + sequence = i0.Value(sequence); + static i0.Insertable custom({ + i0.Expression? id, + i0.Expression? assetId, + i0.Expression? action, + i0.Expression? parameters, + i0.Expression? sequence, + }) { + return i0.RawValuesInsertable({ + if (id != null) 'id': id, + if (assetId != null) 'asset_id': assetId, + if (action != null) 'action': action, + if (parameters != null) 'parameters': parameters, + if (sequence != null) 'sequence': sequence, + }); + } + + i1.AssetEditEntityCompanion copyWith({ + i0.Value? id, + i0.Value? assetId, + i0.Value? action, + i0.Value>? parameters, + i0.Value? sequence, + }) { + return i1.AssetEditEntityCompanion( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + action: action ?? this.action, + parameters: parameters ?? this.parameters, + sequence: sequence ?? this.sequence, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = i0.Variable(id.value); + } + if (assetId.present) { + map['asset_id'] = i0.Variable(assetId.value); + } + if (action.present) { + map['action'] = i0.Variable( + i1.$AssetEditEntityTable.$converteraction.toSql(action.value), + ); + } + if (parameters.present) { + map['parameters'] = i0.Variable( + i1.$AssetEditEntityTable.$converterparameters.toSql(parameters.value), + ); + } + if (sequence.present) { + map['sequence'] = i0.Variable(sequence.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AssetEditEntityCompanion(') + ..write('id: $id, ') + ..write('assetId: $assetId, ') + ..write('action: $action, ') + ..write('parameters: $parameters, ') + ..write('sequence: $sequence') + ..write(')')) + .toString(); + } +} diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 80b6712703..d41891e2ea 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -4,6 +4,7 @@ import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'package:flutter/foundation.dart'; import 'package:immich_mobile/domain/interfaces/db.interface.dart'; +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart'; import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart'; import 'package:immich_mobile/infrastructure/entities/auth_user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; @@ -65,6 +66,7 @@ class IsarDatabaseRepository implements IDatabaseRepository { AssetFaceEntity, StoreEntity, TrashedLocalAssetEntity, + AssetEditEntity, ], include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'}, ) @@ -96,7 +98,7 @@ class Drift extends $Drift implements IDatabaseRepository { } @override - int get schemaVersion => 21; + int get schemaVersion => 22; @override MigrationStrategy get migration => MigrationStrategy( @@ -233,6 +235,10 @@ class Drift extends $Drift implements IDatabaseRepository { await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle); await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle); }, + from21To22: (m, v22) async { + await m.createTable(v22.assetEditEntity); + await m.createIndex(v22.idxAssetEditAssetId); + }, ), ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index ae805ad25e..c898b7ce65 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.drift.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.drift.dart @@ -41,9 +41,11 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart' as i19; import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart' as i20; -import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart' +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart' as i21; -import 'package:drift/internal/modular.dart' as i22; +import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart' + as i22; +import 'package:drift/internal/modular.dart' as i23; abstract class $Drift extends i0.GeneratedDatabase { $Drift(i0.QueryExecutor e) : super(e); @@ -85,9 +87,11 @@ abstract class $Drift extends i0.GeneratedDatabase { late final i19.$StoreEntityTable storeEntity = i19.$StoreEntityTable(this); late final i20.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i20 .$TrashedLocalAssetEntityTable(this); - i21.MergedAssetDrift get mergedAssetDrift => i22.ReadDatabaseContainer( + late final i21.$AssetEditEntityTable assetEditEntity = i21 + .$AssetEditEntityTable(this); + i22.MergedAssetDrift get mergedAssetDrift => i23.ReadDatabaseContainer( this, - ).accessor(i21.MergedAssetDrift.new); + ).accessor(i22.MergedAssetDrift.new); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -125,6 +129,7 @@ abstract class $Drift extends i0.GeneratedDatabase { assetFaceEntity, storeEntity, trashedLocalAssetEntity, + assetEditEntity, i10.idxPartnerSharedWithId, i11.idxLatLng, i12.idxRemoteAlbumAssetAlbumAsset, @@ -134,6 +139,7 @@ abstract class $Drift extends i0.GeneratedDatabase { i18.idxAssetFaceAssetId, i20.idxTrashedLocalAssetChecksum, i20.idxTrashedLocalAssetAlbum, + i21.idxAssetEditAssetId, ]; @override i0.StreamQueryUpdateRules @@ -325,6 +331,13 @@ abstract class $Drift extends i0.GeneratedDatabase { ), result: [i0.TableUpdate('asset_face_entity', kind: i0.UpdateKind.update)], ), + i0.WritePropagation( + on: i0.TableUpdateQuery.onTableName( + 'remote_asset_entity', + limitUpdateKind: i0.UpdateKind.delete, + ), + result: [i0.TableUpdate('asset_edit_entity', kind: i0.UpdateKind.delete)], + ), ]); @override i0.DriftDatabaseOptions get options => @@ -384,4 +397,6 @@ class $DriftManager { _db, _db.trashedLocalAssetEntity, ); + i21.$$AssetEditEntityTableTableManager get assetEditEntity => + i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity); } diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index f83fd29cdc..379f37169d 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.steps.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.steps.dart @@ -9489,6 +9489,565 @@ class Shape31 extends i0.VersionedTable { columnsByName['playback_style']! as i1.GeneratedColumn; } +final class Schema22 extends i0.VersionedSchema { + Schema22({required super.database}) : super(version: 22); + @override + late final List entities = [ + userEntity, + remoteAssetEntity, + stackEntity, + localAssetEntity, + remoteAlbumEntity, + localAlbumEntity, + localAlbumAssetEntity, + idxLocalAlbumAssetAlbumAsset, + idxRemoteAlbumOwnerId, + idxLocalAssetChecksum, + idxLocalAssetCloudId, + idxStackPrimaryAssetId, + idxRemoteAssetOwnerChecksum, + uQRemoteAssetsOwnerChecksum, + uQRemoteAssetsOwnerLibraryChecksum, + idxRemoteAssetChecksum, + idxRemoteAssetStackId, + idxRemoteAssetLocalDateTimeDay, + idxRemoteAssetLocalDateTimeMonth, + authUserEntity, + userMetadataEntity, + partnerEntity, + remoteExifEntity, + remoteAlbumAssetEntity, + remoteAlbumUserEntity, + remoteAssetCloudIdEntity, + memoryEntity, + memoryAssetEntity, + personEntity, + assetFaceEntity, + storeEntity, + trashedLocalAssetEntity, + assetEditEntity, + idxPartnerSharedWithId, + idxLatLng, + idxRemoteAlbumAssetAlbumAsset, + idxRemoteAssetCloudId, + idxPersonOwnerId, + idxAssetFacePersonId, + idxAssetFaceAssetId, + idxTrashedLocalAssetChecksum, + idxTrashedLocalAssetAlbum, + idxAssetEditAssetId, + ]; + 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 Shape30 localAssetEntity = Shape30( + 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, + _column_103, + ], + 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 idxLocalAlbumAssetAlbumAsset = i1.Index( + 'idx_local_album_asset_album_asset', + 'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)', + ); + final i1.Index idxRemoteAlbumOwnerId = i1.Index( + 'idx_remote_album_owner_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)', + ); + 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 idxStackPrimaryAssetId = i1.Index( + 'idx_stack_primary_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_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)', + ); + final i1.Index idxRemoteAssetStackId = i1.Index( + 'idx_remote_asset_stack_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)', + ); + final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index( + 'idx_remote_asset_local_date_time_day', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))', + ); + final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index( + 'idx_remote_asset_local_date_time_month', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))', + ); + 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 Shape29 assetFaceEntity = Shape29( + 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, + _column_102, + _column_18, + ], + 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 Shape31 trashedLocalAssetEntity = Shape31( + 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, + _column_103, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape32 assetEditEntity = Shape32( + source: i0.VersionedTable( + entityName: 'asset_edit_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: ['PRIMARY KEY(id)'], + columns: [_column_0, _column_36, _column_104, _column_105, _column_106], + attachedDatabase: database, + ), + alias: null, + ); + final i1.Index idxPartnerSharedWithId = i1.Index( + 'idx_partner_shared_with_id', + 'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)', + ); + 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 idxRemoteAlbumAssetAlbumAsset = i1.Index( + 'idx_remote_album_asset_album_asset', + 'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)', + ); + 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 idxPersonOwnerId = i1.Index( + 'idx_person_owner_id', + 'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)', + ); + final i1.Index idxAssetFacePersonId = i1.Index( + 'idx_asset_face_person_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)', + ); + final i1.Index idxAssetFaceAssetId = i1.Index( + 'idx_asset_face_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_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)', + ); + final i1.Index idxAssetEditAssetId = i1.Index( + 'idx_asset_edit_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)', + ); +} + +class Shape32 extends i0.VersionedTable { + Shape32({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get assetId => + columnsByName['asset_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get action => + columnsByName['action']! as i1.GeneratedColumn; + i1.GeneratedColumn get parameters => + columnsByName['parameters']! as i1.GeneratedColumn; + i1.GeneratedColumn get sequence => + columnsByName['sequence']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_104(String aliasedName) => + i1.GeneratedColumn( + 'action', + aliasedName, + false, + type: i1.DriftSqlType.int, + ); +i1.GeneratedColumn _column_105(String aliasedName) => + i1.GeneratedColumn( + 'parameters', + aliasedName, + false, + type: i1.DriftSqlType.blob, + ); +i1.GeneratedColumn _column_106(String aliasedName) => + i1.GeneratedColumn( + 'sequence', + aliasedName, + false, + type: i1.DriftSqlType.int, + ); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, @@ -9510,6 +10069,7 @@ i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema19 schema) from18To19, required Future Function(i1.Migrator m, Schema20 schema) from19To20, required Future Function(i1.Migrator m, Schema21 schema) from20To21, + required Future Function(i1.Migrator m, Schema22 schema) from21To22, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -9613,6 +10173,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from20To21(migrator, schema); return 21; + case 21: + final schema = Schema22(database: database); + final migrator = i1.Migrator(database, schema); + await from21To22(migrator, schema); + return 22; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -9640,6 +10205,7 @@ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema19 schema) from18To19, required Future Function(i1.Migrator m, Schema20 schema) from19To20, required Future Function(i1.Migrator m, Schema21 schema) from20To21, + required Future Function(i1.Migrator m, Schema22 schema) from21To22, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from1To2: from1To2, @@ -9662,5 +10228,6 @@ i1.OnUpgrade stepByStep({ from18To19: from18To19, from19To20: from19To20, from20To21: from20To21, + from21To22: from21To22, ), ); diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index 12e817f706..624759b2e6 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -48,6 +48,7 @@ class SyncApiRepository { SyncRequestType.usersV1, SyncRequestType.assetsV1, SyncRequestType.assetExifsV1, + if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetEditsV1, SyncRequestType.assetMetadataV1, SyncRequestType.partnersV1, SyncRequestType.partnerAssetsV1, @@ -151,6 +152,8 @@ const _kResponseMap = { SyncEntityType.assetV1: SyncAssetV1.fromJson, SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson, SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson, + SyncEntityType.assetEditV1: SyncAssetEditV1.fromJson, + SyncEntityType.assetEditDeleteV1: SyncAssetEditDeleteV1.fromJson, SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson, SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson, SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson, diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 8ff1c2d59c..4319ee63cf 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -5,9 +5,11 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user_metadata.model.dart'; +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; @@ -26,8 +28,8 @@ import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey; -import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey; +import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey, AssetEditAction; +import 'package:openapi/api.dart' hide AlbumUserRole, UserMetadataKey, AssetEditAction, AssetVisibility; class SyncStreamRepository extends DriftDatabaseRepository { final Logger _logger = Logger('DriftSyncStreamRepository'); @@ -58,6 +60,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { await _db.userEntity.deleteAll(); await _db.userMetadataEntity.deleteAll(); await _db.remoteAssetCloudIdEntity.deleteAll(); + await _db.assetEditEntity.deleteAll(); }); await _db.customStatement('PRAGMA foreign_keys = ON'); }); @@ -322,6 +325,63 @@ class SyncStreamRepository extends DriftDatabaseRepository { } } + Future updateAssetEditsV1(Iterable data, {String debugLabel = 'user'}) async { + try { + await _db.batch((batch) { + for (final edit in data) { + final companion = AssetEditEntityCompanion( + id: Value(edit.id), + assetId: Value(edit.assetId), + action: Value(edit.action.toAssetEditAction()), + parameters: Value(edit.parameters as Map), + sequence: Value(edit.sequence), + ); + + batch.insert(_db.assetEditEntity, companion, onConflict: DoUpdate((_) => companion)); + } + }); + } catch (error, stack) { + _logger.severe('Error: updateAssetEditsV1 - $debugLabel', error, stack); + rethrow; + } + } + + Future replaceAssetEditsV1(String assetId, Iterable data, {String debugLabel = 'user'}) async { + try { + await _db.batch((batch) { + batch.deleteWhere(_db.assetEditEntity, (row) => row.assetId.equals(assetId)); + + for (final edit in data) { + final companion = AssetEditEntityCompanion( + id: Value(edit.id), + assetId: Value(edit.assetId), + action: Value(edit.action.toAssetEditAction()), + parameters: Value(edit.parameters as Map), + sequence: Value(edit.sequence), + ); + + batch.insert(_db.assetEditEntity, companion); + } + }); + } catch (error, stack) { + _logger.severe('Error: replaceAssetEditsV1 - $debugLabel', error, stack); + rethrow; + } + } + + Future deleteAssetEditsV1(Iterable data, {String debugLabel = 'user'}) async { + try { + await _db.batch((batch) { + for (final edit in data) { + batch.deleteWhere(_db.assetEditEntity, (row) => row.id.equals(edit.editId)); + } + }); + } catch (error, stack) { + _logger.severe('Error: deleteAssetEditsV1 - $debugLabel', error, stack); + rethrow; + } + } + Future deleteAlbumsV1(Iterable data) async { try { await _db.batch((batch) { @@ -798,3 +858,12 @@ extension on String { extension on UserAvatarColor { AvatarColor? toAvatarColor() => AvatarColor.values.firstWhereOrNull((c) => c.name == value); } + +extension on api.AssetEditAction { + AssetEditAction toAssetEditAction() => switch (this) { + api.AssetEditAction.crop => AssetEditAction.crop, + api.AssetEditAction.rotate => AssetEditAction.rotate, + api.AssetEditAction.mirror => AssetEditAction.mirror, + _ => AssetEditAction.other, + }; +} diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index 09f699ba7f..6643404786 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -310,7 +310,7 @@ class WebsocketNotifier extends StateNotifier { } void _handleSyncAssetEditReady(dynamic data) { - unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditBatch([data])); + unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEdit(data)); } void _processBatchedAssetUploadReady() { diff --git a/mobile/test/drift/main/generated/schema.dart b/mobile/test/drift/main/generated/schema.dart index 153697896a..37f5ef1021 100644 --- a/mobile/test/drift/main/generated/schema.dart +++ b/mobile/test/drift/main/generated/schema.dart @@ -24,6 +24,7 @@ import 'schema_v18.dart' as v18; import 'schema_v19.dart' as v19; import 'schema_v20.dart' as v20; import 'schema_v21.dart' as v21; +import 'schema_v22.dart' as v22; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -71,6 +72,8 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v20.DatabaseAtV20(db); case 21: return v21.DatabaseAtV21(db); + case 22: + return v22.DatabaseAtV22(db); default: throw MissingSchemaException(version, versions); } @@ -98,5 +101,6 @@ class GeneratedHelper implements SchemaInstantiationHelper { 19, 20, 21, + 22, ]; } diff --git a/mobile/test/drift/main/generated/schema_v22.dart b/mobile/test/drift/main/generated/schema_v22.dart new file mode 100644 index 0000000000..b8f5971686 --- /dev/null +++ b/mobile/test/drift/main/generated/schema_v22.dart @@ -0,0 +1,8845 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class UserEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + UserEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn hasProfileImage = GeneratedColumn( + 'has_profile_image', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("has_profile_image" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn profileChangedAt = + GeneratedColumn( + 'profile_changed_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn avatarColor = GeneratedColumn( + 'avatar_color', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + id, + name, + email, + hasProfileImage, + profileChangedAt, + avatarColor, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'user_entity'; + @override + Set get $primaryKey => {id}; + @override + UserEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UserEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + hasProfileImage: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}has_profile_image'], + )!, + profileChangedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}profile_changed_at'], + )!, + avatarColor: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}avatar_color'], + )!, + ); + } + + @override + UserEntity createAlias(String alias) { + return UserEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class UserEntityData extends DataClass implements Insertable { + final String id; + final String name; + final String email; + final bool hasProfileImage; + final DateTime profileChangedAt; + final int avatarColor; + const UserEntityData({ + required this.id, + required this.name, + required this.email, + required this.hasProfileImage, + required this.profileChangedAt, + required this.avatarColor, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['email'] = Variable(email); + map['has_profile_image'] = Variable(hasProfileImage); + map['profile_changed_at'] = Variable(profileChangedAt); + map['avatar_color'] = Variable(avatarColor); + return map; + } + + factory UserEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UserEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + email: serializer.fromJson(json['email']), + hasProfileImage: serializer.fromJson(json['hasProfileImage']), + profileChangedAt: serializer.fromJson(json['profileChangedAt']), + avatarColor: serializer.fromJson(json['avatarColor']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'email': serializer.toJson(email), + 'hasProfileImage': serializer.toJson(hasProfileImage), + 'profileChangedAt': serializer.toJson(profileChangedAt), + 'avatarColor': serializer.toJson(avatarColor), + }; + } + + UserEntityData copyWith({ + String? id, + String? name, + String? email, + bool? hasProfileImage, + DateTime? profileChangedAt, + int? avatarColor, + }) => UserEntityData( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + hasProfileImage: hasProfileImage ?? this.hasProfileImage, + profileChangedAt: profileChangedAt ?? this.profileChangedAt, + avatarColor: avatarColor ?? this.avatarColor, + ); + UserEntityData copyWithCompanion(UserEntityCompanion data) { + return UserEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + email: data.email.present ? data.email.value : this.email, + hasProfileImage: data.hasProfileImage.present + ? data.hasProfileImage.value + : this.hasProfileImage, + profileChangedAt: data.profileChangedAt.present + ? data.profileChangedAt.value + : this.profileChangedAt, + avatarColor: data.avatarColor.present + ? data.avatarColor.value + : this.avatarColor, + ); + } + + @override + String toString() { + return (StringBuffer('UserEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('email: $email, ') + ..write('hasProfileImage: $hasProfileImage, ') + ..write('profileChangedAt: $profileChangedAt, ') + ..write('avatarColor: $avatarColor') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + email, + hasProfileImage, + profileChangedAt, + avatarColor, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UserEntityData && + other.id == this.id && + other.name == this.name && + other.email == this.email && + other.hasProfileImage == this.hasProfileImage && + other.profileChangedAt == this.profileChangedAt && + other.avatarColor == this.avatarColor); +} + +class UserEntityCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value email; + final Value hasProfileImage; + final Value profileChangedAt; + final Value avatarColor; + const UserEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.email = const Value.absent(), + this.hasProfileImage = const Value.absent(), + this.profileChangedAt = const Value.absent(), + this.avatarColor = const Value.absent(), + }); + UserEntityCompanion.insert({ + required String id, + required String name, + required String email, + this.hasProfileImage = const Value.absent(), + this.profileChangedAt = const Value.absent(), + this.avatarColor = const Value.absent(), + }) : id = Value(id), + name = Value(name), + email = Value(email); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? email, + Expression? hasProfileImage, + Expression? profileChangedAt, + Expression? avatarColor, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (email != null) 'email': email, + if (hasProfileImage != null) 'has_profile_image': hasProfileImage, + if (profileChangedAt != null) 'profile_changed_at': profileChangedAt, + if (avatarColor != null) 'avatar_color': avatarColor, + }); + } + + UserEntityCompanion copyWith({ + Value? id, + Value? name, + Value? email, + Value? hasProfileImage, + Value? profileChangedAt, + Value? avatarColor, + }) { + return UserEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + hasProfileImage: hasProfileImage ?? this.hasProfileImage, + profileChangedAt: profileChangedAt ?? this.profileChangedAt, + avatarColor: avatarColor ?? this.avatarColor, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (hasProfileImage.present) { + map['has_profile_image'] = Variable(hasProfileImage.value); + } + if (profileChangedAt.present) { + map['profile_changed_at'] = Variable(profileChangedAt.value); + } + if (avatarColor.present) { + map['avatar_color'] = Variable(avatarColor.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UserEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('email: $email, ') + ..write('hasProfileImage: $hasProfileImage, ') + ..write('profileChangedAt: $profileChangedAt, ') + ..write('avatarColor: $avatarColor') + ..write(')')) + .toString(); + } +} + +class RemoteAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn width = GeneratedColumn( + 'width', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn height = GeneratedColumn( + 'height', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn durationInSeconds = GeneratedColumn( + 'duration_in_seconds', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn checksum = GeneratedColumn( + 'checksum', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn localDateTime = + GeneratedColumn( + 'local_date_time', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn thumbHash = GeneratedColumn( + 'thumb_hash', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn livePhotoVideoId = GeneratedColumn( + 'live_photo_video_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn visibility = GeneratedColumn( + 'visibility', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn stackId = GeneratedColumn( + 'stack_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn libraryId = GeneratedColumn( + 'library_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn isEdited = GeneratedColumn( + 'is_edited', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_edited" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + ownerId, + localDateTime, + thumbHash, + deletedAt, + livePhotoVideoId, + visibility, + stackId, + libraryId, + isEdited, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_asset_entity'; + @override + Set get $primaryKey => {id}; + @override + RemoteAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAssetEntityData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}type'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + width: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}width'], + ), + height: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}height'], + ), + durationInSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}duration_in_seconds'], + ), + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + checksum: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}checksum'], + )!, + isFavorite: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_favorite'], + )!, + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + localDateTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}local_date_time'], + ), + thumbHash: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}thumb_hash'], + ), + deletedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}deleted_at'], + ), + livePhotoVideoId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}live_photo_video_id'], + ), + visibility: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}visibility'], + )!, + stackId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}stack_id'], + ), + libraryId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}library_id'], + ), + isEdited: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_edited'], + )!, + ); + } + + @override + RemoteAssetEntity createAlias(String alias) { + return RemoteAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAssetEntityData extends DataClass + implements Insertable { + final String name; + final int type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final String id; + final String checksum; + final bool isFavorite; + final String ownerId; + final DateTime? localDateTime; + final String? thumbHash; + final DateTime? deletedAt; + final String? livePhotoVideoId; + final int visibility; + final String? stackId; + final String? libraryId; + final bool isEdited; + const RemoteAssetEntityData({ + required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.id, + required this.checksum, + required this.isFavorite, + required this.ownerId, + this.localDateTime, + this.thumbHash, + this.deletedAt, + this.livePhotoVideoId, + required this.visibility, + this.stackId, + this.libraryId, + required this.isEdited, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = Variable(durationInSeconds); + } + map['id'] = Variable(id); + map['checksum'] = Variable(checksum); + map['is_favorite'] = Variable(isFavorite); + map['owner_id'] = Variable(ownerId); + if (!nullToAbsent || localDateTime != null) { + map['local_date_time'] = Variable(localDateTime); + } + if (!nullToAbsent || thumbHash != null) { + map['thumb_hash'] = Variable(thumbHash); + } + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + if (!nullToAbsent || livePhotoVideoId != null) { + map['live_photo_video_id'] = Variable(livePhotoVideoId); + } + map['visibility'] = Variable(visibility); + if (!nullToAbsent || stackId != null) { + map['stack_id'] = Variable(stackId); + } + if (!nullToAbsent || libraryId != null) { + map['library_id'] = Variable(libraryId); + } + map['is_edited'] = Variable(isEdited); + return map; + } + + factory RemoteAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAssetEntityData( + name: serializer.fromJson(json['name']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + ownerId: serializer.fromJson(json['ownerId']), + localDateTime: serializer.fromJson(json['localDateTime']), + thumbHash: serializer.fromJson(json['thumbHash']), + deletedAt: serializer.fromJson(json['deletedAt']), + livePhotoVideoId: serializer.fromJson(json['livePhotoVideoId']), + visibility: serializer.fromJson(json['visibility']), + stackId: serializer.fromJson(json['stackId']), + libraryId: serializer.fromJson(json['libraryId']), + isEdited: serializer.fromJson(json['isEdited']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + 'ownerId': serializer.toJson(ownerId), + 'localDateTime': serializer.toJson(localDateTime), + 'thumbHash': serializer.toJson(thumbHash), + 'deletedAt': serializer.toJson(deletedAt), + 'livePhotoVideoId': serializer.toJson(livePhotoVideoId), + 'visibility': serializer.toJson(visibility), + 'stackId': serializer.toJson(stackId), + 'libraryId': serializer.toJson(libraryId), + 'isEdited': serializer.toJson(isEdited), + }; + } + + RemoteAssetEntityData copyWith({ + String? name, + int? type, + DateTime? createdAt, + DateTime? updatedAt, + Value width = const Value.absent(), + Value height = const Value.absent(), + Value durationInSeconds = const Value.absent(), + String? id, + String? checksum, + bool? isFavorite, + String? ownerId, + Value localDateTime = const Value.absent(), + Value thumbHash = const Value.absent(), + Value deletedAt = const Value.absent(), + Value livePhotoVideoId = const Value.absent(), + int? visibility, + Value stackId = const Value.absent(), + Value libraryId = const Value.absent(), + bool? isEdited, + }) => RemoteAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + ownerId: ownerId ?? this.ownerId, + localDateTime: localDateTime.present + ? localDateTime.value + : this.localDateTime, + thumbHash: thumbHash.present ? thumbHash.value : this.thumbHash, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + livePhotoVideoId: livePhotoVideoId.present + ? livePhotoVideoId.value + : this.livePhotoVideoId, + visibility: visibility ?? this.visibility, + stackId: stackId.present ? stackId.value : this.stackId, + libraryId: libraryId.present ? libraryId.value : this.libraryId, + isEdited: isEdited ?? this.isEdited, + ); + RemoteAssetEntityData copyWithCompanion(RemoteAssetEntityCompanion data) { + return RemoteAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + width: data.width.present ? data.width.value : this.width, + height: data.height.present ? data.height.value : this.height, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: data.isFavorite.present + ? data.isFavorite.value + : this.isFavorite, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + localDateTime: data.localDateTime.present + ? data.localDateTime.value + : this.localDateTime, + thumbHash: data.thumbHash.present ? data.thumbHash.value : this.thumbHash, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + livePhotoVideoId: data.livePhotoVideoId.present + ? data.livePhotoVideoId.value + : this.livePhotoVideoId, + visibility: data.visibility.present + ? data.visibility.value + : this.visibility, + stackId: data.stackId.present ? data.stackId.value : this.stackId, + libraryId: data.libraryId.present ? data.libraryId.value : this.libraryId, + isEdited: data.isEdited.present ? data.isEdited.value : this.isEdited, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('ownerId: $ownerId, ') + ..write('localDateTime: $localDateTime, ') + ..write('thumbHash: $thumbHash, ') + ..write('deletedAt: $deletedAt, ') + ..write('livePhotoVideoId: $livePhotoVideoId, ') + ..write('visibility: $visibility, ') + ..write('stackId: $stackId, ') + ..write('libraryId: $libraryId, ') + ..write('isEdited: $isEdited') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + ownerId, + localDateTime, + thumbHash, + deletedAt, + livePhotoVideoId, + visibility, + stackId, + libraryId, + isEdited, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.width == this.width && + other.height == this.height && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite && + other.ownerId == this.ownerId && + other.localDateTime == this.localDateTime && + other.thumbHash == this.thumbHash && + other.deletedAt == this.deletedAt && + other.livePhotoVideoId == this.livePhotoVideoId && + other.visibility == this.visibility && + other.stackId == this.stackId && + other.libraryId == this.libraryId && + other.isEdited == this.isEdited); +} + +class RemoteAssetEntityCompanion + extends UpdateCompanion { + final Value name; + final Value type; + final Value createdAt; + final Value updatedAt; + final Value width; + final Value height; + final Value durationInSeconds; + final Value id; + final Value checksum; + final Value isFavorite; + final Value ownerId; + final Value localDateTime; + final Value thumbHash; + final Value deletedAt; + final Value livePhotoVideoId; + final Value visibility; + final Value stackId; + final Value libraryId; + final Value isEdited; + const RemoteAssetEntityCompanion({ + this.name = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + this.id = const Value.absent(), + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.ownerId = const Value.absent(), + this.localDateTime = const Value.absent(), + this.thumbHash = const Value.absent(), + this.deletedAt = const Value.absent(), + this.livePhotoVideoId = const Value.absent(), + this.visibility = const Value.absent(), + this.stackId = const Value.absent(), + this.libraryId = const Value.absent(), + this.isEdited = const Value.absent(), + }); + RemoteAssetEntityCompanion.insert({ + required String name, + required int type, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + required String id, + required String checksum, + this.isFavorite = const Value.absent(), + required String ownerId, + this.localDateTime = const Value.absent(), + this.thumbHash = const Value.absent(), + this.deletedAt = const Value.absent(), + this.livePhotoVideoId = const Value.absent(), + required int visibility, + this.stackId = const Value.absent(), + this.libraryId = const Value.absent(), + this.isEdited = const Value.absent(), + }) : name = Value(name), + type = Value(type), + id = Value(id), + checksum = Value(checksum), + ownerId = Value(ownerId), + visibility = Value(visibility); + static Insertable custom({ + Expression? name, + Expression? type, + Expression? createdAt, + Expression? updatedAt, + Expression? width, + Expression? height, + Expression? durationInSeconds, + Expression? id, + Expression? checksum, + Expression? isFavorite, + Expression? ownerId, + Expression? localDateTime, + Expression? thumbHash, + Expression? deletedAt, + Expression? livePhotoVideoId, + Expression? visibility, + Expression? stackId, + Expression? libraryId, + Expression? isEdited, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + if (ownerId != null) 'owner_id': ownerId, + if (localDateTime != null) 'local_date_time': localDateTime, + if (thumbHash != null) 'thumb_hash': thumbHash, + if (deletedAt != null) 'deleted_at': deletedAt, + if (livePhotoVideoId != null) 'live_photo_video_id': livePhotoVideoId, + if (visibility != null) 'visibility': visibility, + if (stackId != null) 'stack_id': stackId, + if (libraryId != null) 'library_id': libraryId, + if (isEdited != null) 'is_edited': isEdited, + }); + } + + RemoteAssetEntityCompanion copyWith({ + Value? name, + Value? type, + Value? createdAt, + Value? updatedAt, + Value? width, + Value? height, + Value? durationInSeconds, + Value? id, + Value? checksum, + Value? isFavorite, + Value? ownerId, + Value? localDateTime, + Value? thumbHash, + Value? deletedAt, + Value? livePhotoVideoId, + Value? visibility, + Value? stackId, + Value? libraryId, + Value? isEdited, + }) { + return RemoteAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + ownerId: ownerId ?? this.ownerId, + localDateTime: localDateTime ?? this.localDateTime, + thumbHash: thumbHash ?? this.thumbHash, + deletedAt: deletedAt ?? this.deletedAt, + livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, + visibility: visibility ?? this.visibility, + stackId: stackId ?? this.stackId, + libraryId: libraryId ?? this.libraryId, + isEdited: isEdited ?? this.isEdited, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = Variable(id.value); + } + if (checksum.present) { + map['checksum'] = Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (localDateTime.present) { + map['local_date_time'] = Variable(localDateTime.value); + } + if (thumbHash.present) { + map['thumb_hash'] = Variable(thumbHash.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (livePhotoVideoId.present) { + map['live_photo_video_id'] = Variable(livePhotoVideoId.value); + } + if (visibility.present) { + map['visibility'] = Variable(visibility.value); + } + if (stackId.present) { + map['stack_id'] = Variable(stackId.value); + } + if (libraryId.present) { + map['library_id'] = Variable(libraryId.value); + } + if (isEdited.present) { + map['is_edited'] = Variable(isEdited.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('ownerId: $ownerId, ') + ..write('localDateTime: $localDateTime, ') + ..write('thumbHash: $thumbHash, ') + ..write('deletedAt: $deletedAt, ') + ..write('livePhotoVideoId: $livePhotoVideoId, ') + ..write('visibility: $visibility, ') + ..write('stackId: $stackId, ') + ..write('libraryId: $libraryId, ') + ..write('isEdited: $isEdited') + ..write(')')) + .toString(); + } +} + +class StackEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + StackEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn primaryAssetId = GeneratedColumn( + 'primary_asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + ownerId, + primaryAssetId, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'stack_entity'; + @override + Set get $primaryKey => {id}; + @override + StackEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return StackEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + primaryAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}primary_asset_id'], + )!, + ); + } + + @override + StackEntity createAlias(String alias) { + return StackEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class StackEntityData extends DataClass implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String primaryAssetId; + const StackEntityData({ + required this.id, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + required this.primaryAssetId, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + map['primary_asset_id'] = Variable(primaryAssetId); + return map; + } + + factory StackEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return StackEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + primaryAssetId: serializer.fromJson(json['primaryAssetId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'primaryAssetId': serializer.toJson(primaryAssetId), + }; + } + + StackEntityData copyWith({ + String? id, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + String? primaryAssetId, + }) => StackEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + primaryAssetId: primaryAssetId ?? this.primaryAssetId, + ); + StackEntityData copyWithCompanion(StackEntityCompanion data) { + return StackEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + primaryAssetId: data.primaryAssetId.present + ? data.primaryAssetId.value + : this.primaryAssetId, + ); + } + + @override + String toString() { + return (StringBuffer('StackEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('primaryAssetId: $primaryAssetId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, createdAt, updatedAt, ownerId, primaryAssetId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is StackEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.primaryAssetId == this.primaryAssetId); +} + +class StackEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value primaryAssetId; + const StackEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.primaryAssetId = const Value.absent(), + }); + StackEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + required String primaryAssetId, + }) : id = Value(id), + ownerId = Value(ownerId), + primaryAssetId = Value(primaryAssetId); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? primaryAssetId, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (primaryAssetId != null) 'primary_asset_id': primaryAssetId, + }); + } + + StackEntityCompanion copyWith({ + Value? id, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? primaryAssetId, + }) { + return StackEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + primaryAssetId: primaryAssetId ?? this.primaryAssetId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (primaryAssetId.present) { + map['primary_asset_id'] = Variable(primaryAssetId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('StackEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('primaryAssetId: $primaryAssetId') + ..write(')')) + .toString(); + } +} + +class LocalAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn width = GeneratedColumn( + 'width', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn height = GeneratedColumn( + 'height', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn durationInSeconds = GeneratedColumn( + 'duration_in_seconds', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn checksum = GeneratedColumn( + 'checksum', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn orientation = GeneratedColumn( + 'orientation', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn iCloudId = GeneratedColumn( + 'i_cloud_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn adjustmentTime = + GeneratedColumn( + 'adjustment_time', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn latitude = GeneratedColumn( + 'latitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn longitude = GeneratedColumn( + 'longitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn playbackStyle = GeneratedColumn( + 'playback_style', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + orientation, + iCloudId, + adjustmentTime, + latitude, + longitude, + playbackStyle, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_asset_entity'; + @override + Set get $primaryKey => {id}; + @override + LocalAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAssetEntityData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}type'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + width: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}width'], + ), + height: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}height'], + ), + durationInSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}duration_in_seconds'], + ), + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + checksum: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}checksum'], + ), + isFavorite: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_favorite'], + )!, + orientation: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}orientation'], + )!, + iCloudId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}i_cloud_id'], + ), + adjustmentTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}adjustment_time'], + ), + latitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}latitude'], + ), + longitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}longitude'], + ), + playbackStyle: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}playback_style'], + )!, + ); + } + + @override + LocalAssetEntity createAlias(String alias) { + return LocalAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAssetEntityData extends DataClass + implements Insertable { + final String name; + final int type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final String id; + final String? checksum; + final bool isFavorite; + final int orientation; + final String? iCloudId; + final DateTime? adjustmentTime; + final double? latitude; + final double? longitude; + final int playbackStyle; + const LocalAssetEntityData({ + required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.id, + this.checksum, + required this.isFavorite, + required this.orientation, + this.iCloudId, + this.adjustmentTime, + this.latitude, + this.longitude, + required this.playbackStyle, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = Variable(durationInSeconds); + } + map['id'] = Variable(id); + if (!nullToAbsent || checksum != null) { + map['checksum'] = Variable(checksum); + } + map['is_favorite'] = Variable(isFavorite); + map['orientation'] = Variable(orientation); + if (!nullToAbsent || iCloudId != null) { + map['i_cloud_id'] = Variable(iCloudId); + } + if (!nullToAbsent || adjustmentTime != null) { + map['adjustment_time'] = Variable(adjustmentTime); + } + if (!nullToAbsent || latitude != null) { + map['latitude'] = Variable(latitude); + } + if (!nullToAbsent || longitude != null) { + map['longitude'] = Variable(longitude); + } + map['playback_style'] = Variable(playbackStyle); + return map; + } + + factory LocalAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAssetEntityData( + name: serializer.fromJson(json['name']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + orientation: serializer.fromJson(json['orientation']), + iCloudId: serializer.fromJson(json['iCloudId']), + adjustmentTime: serializer.fromJson(json['adjustmentTime']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + playbackStyle: serializer.fromJson(json['playbackStyle']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + 'orientation': serializer.toJson(orientation), + 'iCloudId': serializer.toJson(iCloudId), + 'adjustmentTime': serializer.toJson(adjustmentTime), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + 'playbackStyle': serializer.toJson(playbackStyle), + }; + } + + LocalAssetEntityData copyWith({ + String? name, + int? type, + DateTime? createdAt, + DateTime? updatedAt, + Value width = const Value.absent(), + Value height = const Value.absent(), + Value durationInSeconds = const Value.absent(), + String? id, + Value checksum = const Value.absent(), + bool? isFavorite, + int? orientation, + Value iCloudId = const Value.absent(), + Value adjustmentTime = const Value.absent(), + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + int? playbackStyle, + }) => LocalAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + checksum: checksum.present ? checksum.value : this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + iCloudId: iCloudId.present ? iCloudId.value : this.iCloudId, + adjustmentTime: adjustmentTime.present + ? adjustmentTime.value + : this.adjustmentTime, + latitude: latitude.present ? latitude.value : this.latitude, + longitude: longitude.present ? longitude.value : this.longitude, + playbackStyle: playbackStyle ?? this.playbackStyle, + ); + LocalAssetEntityData copyWithCompanion(LocalAssetEntityCompanion data) { + return LocalAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + width: data.width.present ? data.width.value : this.width, + height: data.height.present ? data.height.value : this.height, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: data.isFavorite.present + ? data.isFavorite.value + : this.isFavorite, + orientation: data.orientation.present + ? data.orientation.value + : this.orientation, + iCloudId: data.iCloudId.present ? data.iCloudId.value : this.iCloudId, + adjustmentTime: data.adjustmentTime.present + ? data.adjustmentTime.value + : this.adjustmentTime, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + playbackStyle: data.playbackStyle.present + ? data.playbackStyle.value + : this.playbackStyle, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation, ') + ..write('iCloudId: $iCloudId, ') + ..write('adjustmentTime: $adjustmentTime, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('playbackStyle: $playbackStyle') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + orientation, + iCloudId, + adjustmentTime, + latitude, + longitude, + playbackStyle, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.width == this.width && + other.height == this.height && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite && + other.orientation == this.orientation && + other.iCloudId == this.iCloudId && + other.adjustmentTime == this.adjustmentTime && + other.latitude == this.latitude && + other.longitude == this.longitude && + other.playbackStyle == this.playbackStyle); +} + +class LocalAssetEntityCompanion extends UpdateCompanion { + final Value name; + final Value type; + final Value createdAt; + final Value updatedAt; + final Value width; + final Value height; + final Value durationInSeconds; + final Value id; + final Value checksum; + final Value isFavorite; + final Value orientation; + final Value iCloudId; + final Value adjustmentTime; + final Value latitude; + final Value longitude; + final Value playbackStyle; + const LocalAssetEntityCompanion({ + this.name = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + this.id = const Value.absent(), + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + this.iCloudId = const Value.absent(), + this.adjustmentTime = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.playbackStyle = const Value.absent(), + }); + LocalAssetEntityCompanion.insert({ + required String name, + required int type, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + required String id, + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + this.iCloudId = const Value.absent(), + this.adjustmentTime = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.playbackStyle = const Value.absent(), + }) : name = Value(name), + type = Value(type), + id = Value(id); + static Insertable custom({ + Expression? name, + Expression? type, + Expression? createdAt, + Expression? updatedAt, + Expression? width, + Expression? height, + Expression? durationInSeconds, + Expression? id, + Expression? checksum, + Expression? isFavorite, + Expression? orientation, + Expression? iCloudId, + Expression? adjustmentTime, + Expression? latitude, + Expression? longitude, + Expression? playbackStyle, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + if (orientation != null) 'orientation': orientation, + if (iCloudId != null) 'i_cloud_id': iCloudId, + if (adjustmentTime != null) 'adjustment_time': adjustmentTime, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (playbackStyle != null) 'playback_style': playbackStyle, + }); + } + + LocalAssetEntityCompanion copyWith({ + Value? name, + Value? type, + Value? createdAt, + Value? updatedAt, + Value? width, + Value? height, + Value? durationInSeconds, + Value? id, + Value? checksum, + Value? isFavorite, + Value? orientation, + Value? iCloudId, + Value? adjustmentTime, + Value? latitude, + Value? longitude, + Value? playbackStyle, + }) { + return LocalAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + iCloudId: iCloudId ?? this.iCloudId, + adjustmentTime: adjustmentTime ?? this.adjustmentTime, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + playbackStyle: playbackStyle ?? this.playbackStyle, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = Variable(id.value); + } + if (checksum.present) { + map['checksum'] = Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (orientation.present) { + map['orientation'] = Variable(orientation.value); + } + if (iCloudId.present) { + map['i_cloud_id'] = Variable(iCloudId.value); + } + if (adjustmentTime.present) { + map['adjustment_time'] = Variable(adjustmentTime.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.value); + } + if (playbackStyle.present) { + map['playback_style'] = Variable(playbackStyle.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation, ') + ..write('iCloudId: $iCloudId, ') + ..write('adjustmentTime: $adjustmentTime, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('playbackStyle: $playbackStyle') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const CustomExpression('\'\''), + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn thumbnailAssetId = GeneratedColumn( + 'thumbnail_asset_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE SET NULL', + ), + ); + late final GeneratedColumn isActivityEnabled = GeneratedColumn( + 'is_activity_enabled', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_activity_enabled" IN (0, 1))', + ), + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn order = GeneratedColumn( + 'order', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + name, + description, + createdAt, + updatedAt, + ownerId, + thumbnailAssetId, + isActivityEnabled, + order, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_entity'; + @override + Set get $primaryKey => {id}; + @override + RemoteAlbumEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + description: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + thumbnailAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}thumbnail_asset_id'], + ), + isActivityEnabled: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_activity_enabled'], + )!, + order: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}order'], + )!, + ); + } + + @override + RemoteAlbumEntity createAlias(String alias) { + return RemoteAlbumEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumEntityData extends DataClass + implements Insertable { + final String id; + final String name; + final String description; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String? thumbnailAssetId; + final bool isActivityEnabled; + final int order; + const RemoteAlbumEntityData({ + required this.id, + required this.name, + required this.description, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + this.thumbnailAssetId, + required this.isActivityEnabled, + required this.order, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['description'] = Variable(description); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + if (!nullToAbsent || thumbnailAssetId != null) { + map['thumbnail_asset_id'] = Variable(thumbnailAssetId); + } + map['is_activity_enabled'] = Variable(isActivityEnabled); + map['order'] = Variable(order); + return map; + } + + factory RemoteAlbumEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + description: serializer.fromJson(json['description']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + thumbnailAssetId: serializer.fromJson(json['thumbnailAssetId']), + isActivityEnabled: serializer.fromJson(json['isActivityEnabled']), + order: serializer.fromJson(json['order']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'description': serializer.toJson(description), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'thumbnailAssetId': serializer.toJson(thumbnailAssetId), + 'isActivityEnabled': serializer.toJson(isActivityEnabled), + 'order': serializer.toJson(order), + }; + } + + RemoteAlbumEntityData copyWith({ + String? id, + String? name, + String? description, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + Value thumbnailAssetId = const Value.absent(), + bool? isActivityEnabled, + int? order, + }) => RemoteAlbumEntityData( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + thumbnailAssetId: thumbnailAssetId.present + ? thumbnailAssetId.value + : this.thumbnailAssetId, + isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled, + order: order ?? this.order, + ); + RemoteAlbumEntityData copyWithCompanion(RemoteAlbumEntityCompanion data) { + return RemoteAlbumEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + description: data.description.present + ? data.description.value + : this.description, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + thumbnailAssetId: data.thumbnailAssetId.present + ? data.thumbnailAssetId.value + : this.thumbnailAssetId, + isActivityEnabled: data.isActivityEnabled.present + ? data.isActivityEnabled.value + : this.isActivityEnabled, + order: data.order.present ? data.order.value : this.order, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('description: $description, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('thumbnailAssetId: $thumbnailAssetId, ') + ..write('isActivityEnabled: $isActivityEnabled, ') + ..write('order: $order') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + description, + createdAt, + updatedAt, + ownerId, + thumbnailAssetId, + isActivityEnabled, + order, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumEntityData && + other.id == this.id && + other.name == this.name && + other.description == this.description && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.thumbnailAssetId == this.thumbnailAssetId && + other.isActivityEnabled == this.isActivityEnabled && + other.order == this.order); +} + +class RemoteAlbumEntityCompanion + extends UpdateCompanion { + final Value id; + final Value name; + final Value description; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value thumbnailAssetId; + final Value isActivityEnabled; + final Value order; + const RemoteAlbumEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.description = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.thumbnailAssetId = const Value.absent(), + this.isActivityEnabled = const Value.absent(), + this.order = const Value.absent(), + }); + RemoteAlbumEntityCompanion.insert({ + required String id, + required String name, + this.description = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + this.thumbnailAssetId = const Value.absent(), + this.isActivityEnabled = const Value.absent(), + required int order, + }) : id = Value(id), + name = Value(name), + ownerId = Value(ownerId), + order = Value(order); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? description, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? thumbnailAssetId, + Expression? isActivityEnabled, + Expression? order, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (description != null) 'description': description, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (thumbnailAssetId != null) 'thumbnail_asset_id': thumbnailAssetId, + if (isActivityEnabled != null) 'is_activity_enabled': isActivityEnabled, + if (order != null) 'order': order, + }); + } + + RemoteAlbumEntityCompanion copyWith({ + Value? id, + Value? name, + Value? description, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? thumbnailAssetId, + Value? isActivityEnabled, + Value? order, + }) { + return RemoteAlbumEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + thumbnailAssetId: thumbnailAssetId ?? this.thumbnailAssetId, + isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled, + order: order ?? this.order, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (thumbnailAssetId.present) { + map['thumbnail_asset_id'] = Variable(thumbnailAssetId.value); + } + if (isActivityEnabled.present) { + map['is_activity_enabled'] = Variable(isActivityEnabled.value); + } + if (order.present) { + map['order'] = Variable(order.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('description: $description, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('thumbnailAssetId: $thumbnailAssetId, ') + ..write('isActivityEnabled: $isActivityEnabled, ') + ..write('order: $order') + ..write(')')) + .toString(); + } +} + +class LocalAlbumEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAlbumEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn backupSelection = GeneratedColumn( + 'backup_selection', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn isIosSharedAlbum = GeneratedColumn( + 'is_ios_shared_album', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_ios_shared_album" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn linkedRemoteAlbumId = + GeneratedColumn( + 'linked_remote_album_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_album_entity (id) ON DELETE SET NULL', + ), + ); + late final GeneratedColumn marker_ = GeneratedColumn( + 'marker', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("marker" IN (0, 1))', + ), + ); + @override + List get $columns => [ + id, + name, + updatedAt, + backupSelection, + isIosSharedAlbum, + linkedRemoteAlbumId, + marker_, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_album_entity'; + @override + Set get $primaryKey => {id}; + @override + LocalAlbumEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAlbumEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + backupSelection: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}backup_selection'], + )!, + isIosSharedAlbum: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_ios_shared_album'], + )!, + linkedRemoteAlbumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}linked_remote_album_id'], + ), + marker_: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}marker'], + ), + ); + } + + @override + LocalAlbumEntity createAlias(String alias) { + return LocalAlbumEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAlbumEntityData extends DataClass + implements Insertable { + final String id; + final String name; + final DateTime updatedAt; + final int backupSelection; + final bool isIosSharedAlbum; + final String? linkedRemoteAlbumId; + final bool? marker_; + const LocalAlbumEntityData({ + required this.id, + required this.name, + required this.updatedAt, + required this.backupSelection, + required this.isIosSharedAlbum, + this.linkedRemoteAlbumId, + this.marker_, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['updated_at'] = Variable(updatedAt); + map['backup_selection'] = Variable(backupSelection); + map['is_ios_shared_album'] = Variable(isIosSharedAlbum); + if (!nullToAbsent || linkedRemoteAlbumId != null) { + map['linked_remote_album_id'] = Variable(linkedRemoteAlbumId); + } + if (!nullToAbsent || marker_ != null) { + map['marker'] = Variable(marker_); + } + return map; + } + + factory LocalAlbumEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAlbumEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + updatedAt: serializer.fromJson(json['updatedAt']), + backupSelection: serializer.fromJson(json['backupSelection']), + isIosSharedAlbum: serializer.fromJson(json['isIosSharedAlbum']), + linkedRemoteAlbumId: serializer.fromJson( + json['linkedRemoteAlbumId'], + ), + marker_: serializer.fromJson(json['marker_']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'updatedAt': serializer.toJson(updatedAt), + 'backupSelection': serializer.toJson(backupSelection), + 'isIosSharedAlbum': serializer.toJson(isIosSharedAlbum), + 'linkedRemoteAlbumId': serializer.toJson(linkedRemoteAlbumId), + 'marker_': serializer.toJson(marker_), + }; + } + + LocalAlbumEntityData copyWith({ + String? id, + String? name, + DateTime? updatedAt, + int? backupSelection, + bool? isIosSharedAlbum, + Value linkedRemoteAlbumId = const Value.absent(), + Value marker_ = const Value.absent(), + }) => LocalAlbumEntityData( + id: id ?? this.id, + name: name ?? this.name, + updatedAt: updatedAt ?? this.updatedAt, + backupSelection: backupSelection ?? this.backupSelection, + isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, + linkedRemoteAlbumId: linkedRemoteAlbumId.present + ? linkedRemoteAlbumId.value + : this.linkedRemoteAlbumId, + marker_: marker_.present ? marker_.value : this.marker_, + ); + LocalAlbumEntityData copyWithCompanion(LocalAlbumEntityCompanion data) { + return LocalAlbumEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + backupSelection: data.backupSelection.present + ? data.backupSelection.value + : this.backupSelection, + isIosSharedAlbum: data.isIosSharedAlbum.present + ? data.isIosSharedAlbum.value + : this.isIosSharedAlbum, + linkedRemoteAlbumId: data.linkedRemoteAlbumId.present + ? data.linkedRemoteAlbumId.value + : this.linkedRemoteAlbumId, + marker_: data.marker_.present ? data.marker_.value : this.marker_, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAlbumEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('updatedAt: $updatedAt, ') + ..write('backupSelection: $backupSelection, ') + ..write('isIosSharedAlbum: $isIosSharedAlbum, ') + ..write('linkedRemoteAlbumId: $linkedRemoteAlbumId, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + updatedAt, + backupSelection, + isIosSharedAlbum, + linkedRemoteAlbumId, + marker_, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAlbumEntityData && + other.id == this.id && + other.name == this.name && + other.updatedAt == this.updatedAt && + other.backupSelection == this.backupSelection && + other.isIosSharedAlbum == this.isIosSharedAlbum && + other.linkedRemoteAlbumId == this.linkedRemoteAlbumId && + other.marker_ == this.marker_); +} + +class LocalAlbumEntityCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value updatedAt; + final Value backupSelection; + final Value isIosSharedAlbum; + final Value linkedRemoteAlbumId; + final Value marker_; + const LocalAlbumEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.updatedAt = const Value.absent(), + this.backupSelection = const Value.absent(), + this.isIosSharedAlbum = const Value.absent(), + this.linkedRemoteAlbumId = const Value.absent(), + this.marker_ = const Value.absent(), + }); + LocalAlbumEntityCompanion.insert({ + required String id, + required String name, + this.updatedAt = const Value.absent(), + required int backupSelection, + this.isIosSharedAlbum = const Value.absent(), + this.linkedRemoteAlbumId = const Value.absent(), + this.marker_ = const Value.absent(), + }) : id = Value(id), + name = Value(name), + backupSelection = Value(backupSelection); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? updatedAt, + Expression? backupSelection, + Expression? isIosSharedAlbum, + Expression? linkedRemoteAlbumId, + Expression? marker_, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (updatedAt != null) 'updated_at': updatedAt, + if (backupSelection != null) 'backup_selection': backupSelection, + if (isIosSharedAlbum != null) 'is_ios_shared_album': isIosSharedAlbum, + if (linkedRemoteAlbumId != null) + 'linked_remote_album_id': linkedRemoteAlbumId, + if (marker_ != null) 'marker': marker_, + }); + } + + LocalAlbumEntityCompanion copyWith({ + Value? id, + Value? name, + Value? updatedAt, + Value? backupSelection, + Value? isIosSharedAlbum, + Value? linkedRemoteAlbumId, + Value? marker_, + }) { + return LocalAlbumEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + updatedAt: updatedAt ?? this.updatedAt, + backupSelection: backupSelection ?? this.backupSelection, + isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, + linkedRemoteAlbumId: linkedRemoteAlbumId ?? this.linkedRemoteAlbumId, + marker_: marker_ ?? this.marker_, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (backupSelection.present) { + map['backup_selection'] = Variable(backupSelection.value); + } + if (isIosSharedAlbum.present) { + map['is_ios_shared_album'] = Variable(isIosSharedAlbum.value); + } + if (linkedRemoteAlbumId.present) { + map['linked_remote_album_id'] = Variable( + linkedRemoteAlbumId.value, + ); + } + if (marker_.present) { + map['marker'] = Variable(marker_.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAlbumEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('updatedAt: $updatedAt, ') + ..write('backupSelection: $backupSelection, ') + ..write('isIosSharedAlbum: $isIosSharedAlbum, ') + ..write('linkedRemoteAlbumId: $linkedRemoteAlbumId, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } +} + +class LocalAlbumAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAlbumAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES local_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES local_album_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn marker_ = GeneratedColumn( + 'marker', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("marker" IN (0, 1))', + ), + ); + @override + List get $columns => [assetId, albumId, marker_]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_album_asset_entity'; + @override + Set get $primaryKey => {assetId, albumId}; + @override + LocalAlbumAssetEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAlbumAssetEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + albumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}album_id'], + )!, + marker_: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}marker'], + ), + ); + } + + @override + LocalAlbumAssetEntity createAlias(String alias) { + return LocalAlbumAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAlbumAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String albumId; + final bool? marker_; + const LocalAlbumAssetEntityData({ + required this.assetId, + required this.albumId, + this.marker_, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['album_id'] = Variable(albumId); + if (!nullToAbsent || marker_ != null) { + map['marker'] = Variable(marker_); + } + return map; + } + + factory LocalAlbumAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAlbumAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + albumId: serializer.fromJson(json['albumId']), + marker_: serializer.fromJson(json['marker_']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'albumId': serializer.toJson(albumId), + 'marker_': serializer.toJson(marker_), + }; + } + + LocalAlbumAssetEntityData copyWith({ + String? assetId, + String? albumId, + Value marker_ = const Value.absent(), + }) => LocalAlbumAssetEntityData( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + marker_: marker_.present ? marker_.value : this.marker_, + ); + LocalAlbumAssetEntityData copyWithCompanion( + LocalAlbumAssetEntityCompanion data, + ) { + return LocalAlbumAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + marker_: data.marker_.present ? data.marker_.value : this.marker_, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAlbumAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, albumId, marker_); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAlbumAssetEntityData && + other.assetId == this.assetId && + other.albumId == this.albumId && + other.marker_ == this.marker_); +} + +class LocalAlbumAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value albumId; + final Value marker_; + const LocalAlbumAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.albumId = const Value.absent(), + this.marker_ = const Value.absent(), + }); + LocalAlbumAssetEntityCompanion.insert({ + required String assetId, + required String albumId, + this.marker_ = const Value.absent(), + }) : assetId = Value(assetId), + albumId = Value(albumId); + static Insertable custom({ + Expression? assetId, + Expression? albumId, + Expression? marker_, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (albumId != null) 'album_id': albumId, + if (marker_ != null) 'marker': marker_, + }); + } + + LocalAlbumAssetEntityCompanion copyWith({ + Value? assetId, + Value? albumId, + Value? marker_, + }) { + return LocalAlbumAssetEntityCompanion( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + marker_: marker_ ?? this.marker_, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + if (marker_.present) { + map['marker'] = Variable(marker_.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAlbumAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } +} + +class AuthUserEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + AuthUserEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn isAdmin = GeneratedColumn( + 'is_admin', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_admin" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn hasProfileImage = GeneratedColumn( + 'has_profile_image', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("has_profile_image" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn profileChangedAt = + GeneratedColumn( + 'profile_changed_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn avatarColor = GeneratedColumn( + 'avatar_color', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn quotaSizeInBytes = GeneratedColumn( + 'quota_size_in_bytes', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn quotaUsageInBytes = GeneratedColumn( + 'quota_usage_in_bytes', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn pinCode = GeneratedColumn( + 'pin_code', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + name, + email, + isAdmin, + hasProfileImage, + profileChangedAt, + avatarColor, + quotaSizeInBytes, + quotaUsageInBytes, + pinCode, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'auth_user_entity'; + @override + Set get $primaryKey => {id}; + @override + AuthUserEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AuthUserEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + isAdmin: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_admin'], + )!, + hasProfileImage: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}has_profile_image'], + )!, + profileChangedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}profile_changed_at'], + )!, + avatarColor: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}avatar_color'], + )!, + quotaSizeInBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}quota_size_in_bytes'], + )!, + quotaUsageInBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}quota_usage_in_bytes'], + )!, + pinCode: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}pin_code'], + ), + ); + } + + @override + AuthUserEntity createAlias(String alias) { + return AuthUserEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class AuthUserEntityData extends DataClass + implements Insertable { + final String id; + final String name; + final String email; + final bool isAdmin; + final bool hasProfileImage; + final DateTime profileChangedAt; + final int avatarColor; + final int quotaSizeInBytes; + final int quotaUsageInBytes; + final String? pinCode; + const AuthUserEntityData({ + required this.id, + required this.name, + required this.email, + required this.isAdmin, + required this.hasProfileImage, + required this.profileChangedAt, + required this.avatarColor, + required this.quotaSizeInBytes, + required this.quotaUsageInBytes, + this.pinCode, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['email'] = Variable(email); + map['is_admin'] = Variable(isAdmin); + map['has_profile_image'] = Variable(hasProfileImage); + map['profile_changed_at'] = Variable(profileChangedAt); + map['avatar_color'] = Variable(avatarColor); + map['quota_size_in_bytes'] = Variable(quotaSizeInBytes); + map['quota_usage_in_bytes'] = Variable(quotaUsageInBytes); + if (!nullToAbsent || pinCode != null) { + map['pin_code'] = Variable(pinCode); + } + return map; + } + + factory AuthUserEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AuthUserEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + email: serializer.fromJson(json['email']), + isAdmin: serializer.fromJson(json['isAdmin']), + hasProfileImage: serializer.fromJson(json['hasProfileImage']), + profileChangedAt: serializer.fromJson(json['profileChangedAt']), + avatarColor: serializer.fromJson(json['avatarColor']), + quotaSizeInBytes: serializer.fromJson(json['quotaSizeInBytes']), + quotaUsageInBytes: serializer.fromJson(json['quotaUsageInBytes']), + pinCode: serializer.fromJson(json['pinCode']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'email': serializer.toJson(email), + 'isAdmin': serializer.toJson(isAdmin), + 'hasProfileImage': serializer.toJson(hasProfileImage), + 'profileChangedAt': serializer.toJson(profileChangedAt), + 'avatarColor': serializer.toJson(avatarColor), + 'quotaSizeInBytes': serializer.toJson(quotaSizeInBytes), + 'quotaUsageInBytes': serializer.toJson(quotaUsageInBytes), + 'pinCode': serializer.toJson(pinCode), + }; + } + + AuthUserEntityData copyWith({ + String? id, + String? name, + String? email, + bool? isAdmin, + bool? hasProfileImage, + DateTime? profileChangedAt, + int? avatarColor, + int? quotaSizeInBytes, + int? quotaUsageInBytes, + Value pinCode = const Value.absent(), + }) => AuthUserEntityData( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + isAdmin: isAdmin ?? this.isAdmin, + hasProfileImage: hasProfileImage ?? this.hasProfileImage, + profileChangedAt: profileChangedAt ?? this.profileChangedAt, + avatarColor: avatarColor ?? this.avatarColor, + quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes, + quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes, + pinCode: pinCode.present ? pinCode.value : this.pinCode, + ); + AuthUserEntityData copyWithCompanion(AuthUserEntityCompanion data) { + return AuthUserEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + email: data.email.present ? data.email.value : this.email, + isAdmin: data.isAdmin.present ? data.isAdmin.value : this.isAdmin, + hasProfileImage: data.hasProfileImage.present + ? data.hasProfileImage.value + : this.hasProfileImage, + profileChangedAt: data.profileChangedAt.present + ? data.profileChangedAt.value + : this.profileChangedAt, + avatarColor: data.avatarColor.present + ? data.avatarColor.value + : this.avatarColor, + quotaSizeInBytes: data.quotaSizeInBytes.present + ? data.quotaSizeInBytes.value + : this.quotaSizeInBytes, + quotaUsageInBytes: data.quotaUsageInBytes.present + ? data.quotaUsageInBytes.value + : this.quotaUsageInBytes, + pinCode: data.pinCode.present ? data.pinCode.value : this.pinCode, + ); + } + + @override + String toString() { + return (StringBuffer('AuthUserEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('email: $email, ') + ..write('isAdmin: $isAdmin, ') + ..write('hasProfileImage: $hasProfileImage, ') + ..write('profileChangedAt: $profileChangedAt, ') + ..write('avatarColor: $avatarColor, ') + ..write('quotaSizeInBytes: $quotaSizeInBytes, ') + ..write('quotaUsageInBytes: $quotaUsageInBytes, ') + ..write('pinCode: $pinCode') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + email, + isAdmin, + hasProfileImage, + profileChangedAt, + avatarColor, + quotaSizeInBytes, + quotaUsageInBytes, + pinCode, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AuthUserEntityData && + other.id == this.id && + other.name == this.name && + other.email == this.email && + other.isAdmin == this.isAdmin && + other.hasProfileImage == this.hasProfileImage && + other.profileChangedAt == this.profileChangedAt && + other.avatarColor == this.avatarColor && + other.quotaSizeInBytes == this.quotaSizeInBytes && + other.quotaUsageInBytes == this.quotaUsageInBytes && + other.pinCode == this.pinCode); +} + +class AuthUserEntityCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value email; + final Value isAdmin; + final Value hasProfileImage; + final Value profileChangedAt; + final Value avatarColor; + final Value quotaSizeInBytes; + final Value quotaUsageInBytes; + final Value pinCode; + const AuthUserEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.email = const Value.absent(), + this.isAdmin = const Value.absent(), + this.hasProfileImage = const Value.absent(), + this.profileChangedAt = const Value.absent(), + this.avatarColor = const Value.absent(), + this.quotaSizeInBytes = const Value.absent(), + this.quotaUsageInBytes = const Value.absent(), + this.pinCode = const Value.absent(), + }); + AuthUserEntityCompanion.insert({ + required String id, + required String name, + required String email, + this.isAdmin = const Value.absent(), + this.hasProfileImage = const Value.absent(), + this.profileChangedAt = const Value.absent(), + required int avatarColor, + this.quotaSizeInBytes = const Value.absent(), + this.quotaUsageInBytes = const Value.absent(), + this.pinCode = const Value.absent(), + }) : id = Value(id), + name = Value(name), + email = Value(email), + avatarColor = Value(avatarColor); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? email, + Expression? isAdmin, + Expression? hasProfileImage, + Expression? profileChangedAt, + Expression? avatarColor, + Expression? quotaSizeInBytes, + Expression? quotaUsageInBytes, + Expression? pinCode, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (email != null) 'email': email, + if (isAdmin != null) 'is_admin': isAdmin, + if (hasProfileImage != null) 'has_profile_image': hasProfileImage, + if (profileChangedAt != null) 'profile_changed_at': profileChangedAt, + if (avatarColor != null) 'avatar_color': avatarColor, + if (quotaSizeInBytes != null) 'quota_size_in_bytes': quotaSizeInBytes, + if (quotaUsageInBytes != null) 'quota_usage_in_bytes': quotaUsageInBytes, + if (pinCode != null) 'pin_code': pinCode, + }); + } + + AuthUserEntityCompanion copyWith({ + Value? id, + Value? name, + Value? email, + Value? isAdmin, + Value? hasProfileImage, + Value? profileChangedAt, + Value? avatarColor, + Value? quotaSizeInBytes, + Value? quotaUsageInBytes, + Value? pinCode, + }) { + return AuthUserEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + isAdmin: isAdmin ?? this.isAdmin, + hasProfileImage: hasProfileImage ?? this.hasProfileImage, + profileChangedAt: profileChangedAt ?? this.profileChangedAt, + avatarColor: avatarColor ?? this.avatarColor, + quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes, + quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes, + pinCode: pinCode ?? this.pinCode, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (isAdmin.present) { + map['is_admin'] = Variable(isAdmin.value); + } + if (hasProfileImage.present) { + map['has_profile_image'] = Variable(hasProfileImage.value); + } + if (profileChangedAt.present) { + map['profile_changed_at'] = Variable(profileChangedAt.value); + } + if (avatarColor.present) { + map['avatar_color'] = Variable(avatarColor.value); + } + if (quotaSizeInBytes.present) { + map['quota_size_in_bytes'] = Variable(quotaSizeInBytes.value); + } + if (quotaUsageInBytes.present) { + map['quota_usage_in_bytes'] = Variable(quotaUsageInBytes.value); + } + if (pinCode.present) { + map['pin_code'] = Variable(pinCode.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AuthUserEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('email: $email, ') + ..write('isAdmin: $isAdmin, ') + ..write('hasProfileImage: $hasProfileImage, ') + ..write('profileChangedAt: $profileChangedAt, ') + ..write('avatarColor: $avatarColor, ') + ..write('quotaSizeInBytes: $quotaSizeInBytes, ') + ..write('quotaUsageInBytes: $quotaUsageInBytes, ') + ..write('pinCode: $pinCode') + ..write(')')) + .toString(); + } +} + +class UserMetadataEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + UserMetadataEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.blob, + requiredDuringInsert: true, + ); + @override + List get $columns => [userId, key, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'user_metadata_entity'; + @override + Set get $primaryKey => {userId, key}; + @override + UserMetadataEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UserMetadataEntityData( + userId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}user_id'], + )!, + key: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}key'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.blob, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + UserMetadataEntity createAlias(String alias) { + return UserMetadataEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class UserMetadataEntityData extends DataClass + implements Insertable { + final String userId; + final int key; + final Uint8List value; + const UserMetadataEntityData({ + required this.userId, + required this.key, + required this.value, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['user_id'] = Variable(userId); + map['key'] = Variable(key); + map['value'] = Variable(value); + return map; + } + + factory UserMetadataEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UserMetadataEntityData( + userId: serializer.fromJson(json['userId']), + key: serializer.fromJson(json['key']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'userId': serializer.toJson(userId), + 'key': serializer.toJson(key), + 'value': serializer.toJson(value), + }; + } + + UserMetadataEntityData copyWith({ + String? userId, + int? key, + Uint8List? value, + }) => UserMetadataEntityData( + userId: userId ?? this.userId, + key: key ?? this.key, + value: value ?? this.value, + ); + UserMetadataEntityData copyWithCompanion(UserMetadataEntityCompanion data) { + return UserMetadataEntityData( + userId: data.userId.present ? data.userId.value : this.userId, + key: data.key.present ? data.key.value : this.key, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('UserMetadataEntityData(') + ..write('userId: $userId, ') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(userId, key, $driftBlobEquality.hash(value)); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UserMetadataEntityData && + other.userId == this.userId && + other.key == this.key && + $driftBlobEquality.equals(other.value, this.value)); +} + +class UserMetadataEntityCompanion + extends UpdateCompanion { + final Value userId; + final Value key; + final Value value; + const UserMetadataEntityCompanion({ + this.userId = const Value.absent(), + this.key = const Value.absent(), + this.value = const Value.absent(), + }); + UserMetadataEntityCompanion.insert({ + required String userId, + required int key, + required Uint8List value, + }) : userId = Value(userId), + key = Value(key), + value = Value(value); + static Insertable custom({ + Expression? userId, + Expression? key, + Expression? value, + }) { + return RawValuesInsertable({ + if (userId != null) 'user_id': userId, + if (key != null) 'key': key, + if (value != null) 'value': value, + }); + } + + UserMetadataEntityCompanion copyWith({ + Value? userId, + Value? key, + Value? value, + }) { + return UserMetadataEntityCompanion( + userId: userId ?? this.userId, + key: key ?? this.key, + value: value ?? this.value, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (key.present) { + map['key'] = Variable(key.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UserMetadataEntityCompanion(') + ..write('userId: $userId, ') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } +} + +class PartnerEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + PartnerEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn sharedById = GeneratedColumn( + 'shared_by_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn sharedWithId = GeneratedColumn( + 'shared_with_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn inTimeline = GeneratedColumn( + 'in_timeline', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("in_timeline" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [sharedById, sharedWithId, inTimeline]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'partner_entity'; + @override + Set get $primaryKey => {sharedById, sharedWithId}; + @override + PartnerEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PartnerEntityData( + sharedById: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}shared_by_id'], + )!, + sharedWithId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}shared_with_id'], + )!, + inTimeline: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}in_timeline'], + )!, + ); + } + + @override + PartnerEntity createAlias(String alias) { + return PartnerEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class PartnerEntityData extends DataClass + implements Insertable { + final String sharedById; + final String sharedWithId; + final bool inTimeline; + const PartnerEntityData({ + required this.sharedById, + required this.sharedWithId, + required this.inTimeline, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['shared_by_id'] = Variable(sharedById); + map['shared_with_id'] = Variable(sharedWithId); + map['in_timeline'] = Variable(inTimeline); + return map; + } + + factory PartnerEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PartnerEntityData( + sharedById: serializer.fromJson(json['sharedById']), + sharedWithId: serializer.fromJson(json['sharedWithId']), + inTimeline: serializer.fromJson(json['inTimeline']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'sharedById': serializer.toJson(sharedById), + 'sharedWithId': serializer.toJson(sharedWithId), + 'inTimeline': serializer.toJson(inTimeline), + }; + } + + PartnerEntityData copyWith({ + String? sharedById, + String? sharedWithId, + bool? inTimeline, + }) => PartnerEntityData( + sharedById: sharedById ?? this.sharedById, + sharedWithId: sharedWithId ?? this.sharedWithId, + inTimeline: inTimeline ?? this.inTimeline, + ); + PartnerEntityData copyWithCompanion(PartnerEntityCompanion data) { + return PartnerEntityData( + sharedById: data.sharedById.present + ? data.sharedById.value + : this.sharedById, + sharedWithId: data.sharedWithId.present + ? data.sharedWithId.value + : this.sharedWithId, + inTimeline: data.inTimeline.present + ? data.inTimeline.value + : this.inTimeline, + ); + } + + @override + String toString() { + return (StringBuffer('PartnerEntityData(') + ..write('sharedById: $sharedById, ') + ..write('sharedWithId: $sharedWithId, ') + ..write('inTimeline: $inTimeline') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(sharedById, sharedWithId, inTimeline); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PartnerEntityData && + other.sharedById == this.sharedById && + other.sharedWithId == this.sharedWithId && + other.inTimeline == this.inTimeline); +} + +class PartnerEntityCompanion extends UpdateCompanion { + final Value sharedById; + final Value sharedWithId; + final Value inTimeline; + const PartnerEntityCompanion({ + this.sharedById = const Value.absent(), + this.sharedWithId = const Value.absent(), + this.inTimeline = const Value.absent(), + }); + PartnerEntityCompanion.insert({ + required String sharedById, + required String sharedWithId, + this.inTimeline = const Value.absent(), + }) : sharedById = Value(sharedById), + sharedWithId = Value(sharedWithId); + static Insertable custom({ + Expression? sharedById, + Expression? sharedWithId, + Expression? inTimeline, + }) { + return RawValuesInsertable({ + if (sharedById != null) 'shared_by_id': sharedById, + if (sharedWithId != null) 'shared_with_id': sharedWithId, + if (inTimeline != null) 'in_timeline': inTimeline, + }); + } + + PartnerEntityCompanion copyWith({ + Value? sharedById, + Value? sharedWithId, + Value? inTimeline, + }) { + return PartnerEntityCompanion( + sharedById: sharedById ?? this.sharedById, + sharedWithId: sharedWithId ?? this.sharedWithId, + inTimeline: inTimeline ?? this.inTimeline, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (sharedById.present) { + map['shared_by_id'] = Variable(sharedById.value); + } + if (sharedWithId.present) { + map['shared_with_id'] = Variable(sharedWithId.value); + } + if (inTimeline.present) { + map['in_timeline'] = Variable(inTimeline.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PartnerEntityCompanion(') + ..write('sharedById: $sharedById, ') + ..write('sharedWithId: $sharedWithId, ') + ..write('inTimeline: $inTimeline') + ..write(')')) + .toString(); + } +} + +class RemoteExifEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteExifEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn city = GeneratedColumn( + 'city', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn state = GeneratedColumn( + 'state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn country = GeneratedColumn( + 'country', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn dateTimeOriginal = + GeneratedColumn( + 'date_time_original', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn height = GeneratedColumn( + 'height', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn width = GeneratedColumn( + 'width', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn exposureTime = GeneratedColumn( + 'exposure_time', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn fNumber = GeneratedColumn( + 'f_number', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn fileSize = GeneratedColumn( + 'file_size', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn focalLength = GeneratedColumn( + 'focal_length', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn latitude = GeneratedColumn( + 'latitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn longitude = GeneratedColumn( + 'longitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn iso = GeneratedColumn( + 'iso', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn make = GeneratedColumn( + 'make', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn model = GeneratedColumn( + 'model', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn lens = GeneratedColumn( + 'lens', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn orientation = GeneratedColumn( + 'orientation', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn timeZone = GeneratedColumn( + 'time_zone', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn rating = GeneratedColumn( + 'rating', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn projectionType = GeneratedColumn( + 'projection_type', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + assetId, + city, + state, + country, + dateTimeOriginal, + description, + height, + width, + exposureTime, + fNumber, + fileSize, + focalLength, + latitude, + longitude, + iso, + make, + model, + lens, + orientation, + timeZone, + rating, + projectionType, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_exif_entity'; + @override + Set get $primaryKey => {assetId}; + @override + RemoteExifEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteExifEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + city: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}city'], + ), + state: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}state'], + ), + country: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}country'], + ), + dateTimeOriginal: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}date_time_original'], + ), + description: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + ), + height: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}height'], + ), + width: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}width'], + ), + exposureTime: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}exposure_time'], + ), + fNumber: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}f_number'], + ), + fileSize: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}file_size'], + ), + focalLength: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}focal_length'], + ), + latitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}latitude'], + ), + longitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}longitude'], + ), + iso: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}iso'], + ), + make: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}make'], + ), + model: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}model'], + ), + lens: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}lens'], + ), + orientation: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}orientation'], + ), + timeZone: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}time_zone'], + ), + rating: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}rating'], + ), + projectionType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}projection_type'], + ), + ); + } + + @override + RemoteExifEntity createAlias(String alias) { + return RemoteExifEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteExifEntityData extends DataClass + implements Insertable { + final String assetId; + final String? city; + final String? state; + final String? country; + final DateTime? dateTimeOriginal; + final String? description; + final int? height; + final int? width; + final String? exposureTime; + final double? fNumber; + final int? fileSize; + final double? focalLength; + final double? latitude; + final double? longitude; + final int? iso; + final String? make; + final String? model; + final String? lens; + final String? orientation; + final String? timeZone; + final int? rating; + final String? projectionType; + const RemoteExifEntityData({ + required this.assetId, + this.city, + this.state, + this.country, + this.dateTimeOriginal, + this.description, + this.height, + this.width, + this.exposureTime, + this.fNumber, + this.fileSize, + this.focalLength, + this.latitude, + this.longitude, + this.iso, + this.make, + this.model, + this.lens, + this.orientation, + this.timeZone, + this.rating, + this.projectionType, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + if (!nullToAbsent || city != null) { + map['city'] = Variable(city); + } + if (!nullToAbsent || state != null) { + map['state'] = Variable(state); + } + if (!nullToAbsent || country != null) { + map['country'] = Variable(country); + } + if (!nullToAbsent || dateTimeOriginal != null) { + map['date_time_original'] = Variable(dateTimeOriginal); + } + if (!nullToAbsent || description != null) { + map['description'] = Variable(description); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || exposureTime != null) { + map['exposure_time'] = Variable(exposureTime); + } + if (!nullToAbsent || fNumber != null) { + map['f_number'] = Variable(fNumber); + } + if (!nullToAbsent || fileSize != null) { + map['file_size'] = Variable(fileSize); + } + if (!nullToAbsent || focalLength != null) { + map['focal_length'] = Variable(focalLength); + } + if (!nullToAbsent || latitude != null) { + map['latitude'] = Variable(latitude); + } + if (!nullToAbsent || longitude != null) { + map['longitude'] = Variable(longitude); + } + if (!nullToAbsent || iso != null) { + map['iso'] = Variable(iso); + } + if (!nullToAbsent || make != null) { + map['make'] = Variable(make); + } + if (!nullToAbsent || model != null) { + map['model'] = Variable(model); + } + if (!nullToAbsent || lens != null) { + map['lens'] = Variable(lens); + } + if (!nullToAbsent || orientation != null) { + map['orientation'] = Variable(orientation); + } + if (!nullToAbsent || timeZone != null) { + map['time_zone'] = Variable(timeZone); + } + if (!nullToAbsent || rating != null) { + map['rating'] = Variable(rating); + } + if (!nullToAbsent || projectionType != null) { + map['projection_type'] = Variable(projectionType); + } + return map; + } + + factory RemoteExifEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteExifEntityData( + assetId: serializer.fromJson(json['assetId']), + city: serializer.fromJson(json['city']), + state: serializer.fromJson(json['state']), + country: serializer.fromJson(json['country']), + dateTimeOriginal: serializer.fromJson( + json['dateTimeOriginal'], + ), + description: serializer.fromJson(json['description']), + height: serializer.fromJson(json['height']), + width: serializer.fromJson(json['width']), + exposureTime: serializer.fromJson(json['exposureTime']), + fNumber: serializer.fromJson(json['fNumber']), + fileSize: serializer.fromJson(json['fileSize']), + focalLength: serializer.fromJson(json['focalLength']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + iso: serializer.fromJson(json['iso']), + make: serializer.fromJson(json['make']), + model: serializer.fromJson(json['model']), + lens: serializer.fromJson(json['lens']), + orientation: serializer.fromJson(json['orientation']), + timeZone: serializer.fromJson(json['timeZone']), + rating: serializer.fromJson(json['rating']), + projectionType: serializer.fromJson(json['projectionType']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'city': serializer.toJson(city), + 'state': serializer.toJson(state), + 'country': serializer.toJson(country), + 'dateTimeOriginal': serializer.toJson(dateTimeOriginal), + 'description': serializer.toJson(description), + 'height': serializer.toJson(height), + 'width': serializer.toJson(width), + 'exposureTime': serializer.toJson(exposureTime), + 'fNumber': serializer.toJson(fNumber), + 'fileSize': serializer.toJson(fileSize), + 'focalLength': serializer.toJson(focalLength), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + 'iso': serializer.toJson(iso), + 'make': serializer.toJson(make), + 'model': serializer.toJson(model), + 'lens': serializer.toJson(lens), + 'orientation': serializer.toJson(orientation), + 'timeZone': serializer.toJson(timeZone), + 'rating': serializer.toJson(rating), + 'projectionType': serializer.toJson(projectionType), + }; + } + + RemoteExifEntityData copyWith({ + String? assetId, + Value city = const Value.absent(), + Value state = const Value.absent(), + Value country = const Value.absent(), + Value dateTimeOriginal = const Value.absent(), + Value description = const Value.absent(), + Value height = const Value.absent(), + Value width = const Value.absent(), + Value exposureTime = const Value.absent(), + Value fNumber = const Value.absent(), + Value fileSize = const Value.absent(), + Value focalLength = const Value.absent(), + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + Value iso = const Value.absent(), + Value make = const Value.absent(), + Value model = const Value.absent(), + Value lens = const Value.absent(), + Value orientation = const Value.absent(), + Value timeZone = const Value.absent(), + Value rating = const Value.absent(), + Value projectionType = const Value.absent(), + }) => RemoteExifEntityData( + assetId: assetId ?? this.assetId, + city: city.present ? city.value : this.city, + state: state.present ? state.value : this.state, + country: country.present ? country.value : this.country, + dateTimeOriginal: dateTimeOriginal.present + ? dateTimeOriginal.value + : this.dateTimeOriginal, + description: description.present ? description.value : this.description, + height: height.present ? height.value : this.height, + width: width.present ? width.value : this.width, + exposureTime: exposureTime.present ? exposureTime.value : this.exposureTime, + fNumber: fNumber.present ? fNumber.value : this.fNumber, + fileSize: fileSize.present ? fileSize.value : this.fileSize, + focalLength: focalLength.present ? focalLength.value : this.focalLength, + latitude: latitude.present ? latitude.value : this.latitude, + longitude: longitude.present ? longitude.value : this.longitude, + iso: iso.present ? iso.value : this.iso, + make: make.present ? make.value : this.make, + model: model.present ? model.value : this.model, + lens: lens.present ? lens.value : this.lens, + orientation: orientation.present ? orientation.value : this.orientation, + timeZone: timeZone.present ? timeZone.value : this.timeZone, + rating: rating.present ? rating.value : this.rating, + projectionType: projectionType.present + ? projectionType.value + : this.projectionType, + ); + RemoteExifEntityData copyWithCompanion(RemoteExifEntityCompanion data) { + return RemoteExifEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + city: data.city.present ? data.city.value : this.city, + state: data.state.present ? data.state.value : this.state, + country: data.country.present ? data.country.value : this.country, + dateTimeOriginal: data.dateTimeOriginal.present + ? data.dateTimeOriginal.value + : this.dateTimeOriginal, + description: data.description.present + ? data.description.value + : this.description, + height: data.height.present ? data.height.value : this.height, + width: data.width.present ? data.width.value : this.width, + exposureTime: data.exposureTime.present + ? data.exposureTime.value + : this.exposureTime, + fNumber: data.fNumber.present ? data.fNumber.value : this.fNumber, + fileSize: data.fileSize.present ? data.fileSize.value : this.fileSize, + focalLength: data.focalLength.present + ? data.focalLength.value + : this.focalLength, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + iso: data.iso.present ? data.iso.value : this.iso, + make: data.make.present ? data.make.value : this.make, + model: data.model.present ? data.model.value : this.model, + lens: data.lens.present ? data.lens.value : this.lens, + orientation: data.orientation.present + ? data.orientation.value + : this.orientation, + timeZone: data.timeZone.present ? data.timeZone.value : this.timeZone, + rating: data.rating.present ? data.rating.value : this.rating, + projectionType: data.projectionType.present + ? data.projectionType.value + : this.projectionType, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteExifEntityData(') + ..write('assetId: $assetId, ') + ..write('city: $city, ') + ..write('state: $state, ') + ..write('country: $country, ') + ..write('dateTimeOriginal: $dateTimeOriginal, ') + ..write('description: $description, ') + ..write('height: $height, ') + ..write('width: $width, ') + ..write('exposureTime: $exposureTime, ') + ..write('fNumber: $fNumber, ') + ..write('fileSize: $fileSize, ') + ..write('focalLength: $focalLength, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('iso: $iso, ') + ..write('make: $make, ') + ..write('model: $model, ') + ..write('lens: $lens, ') + ..write('orientation: $orientation, ') + ..write('timeZone: $timeZone, ') + ..write('rating: $rating, ') + ..write('projectionType: $projectionType') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hashAll([ + assetId, + city, + state, + country, + dateTimeOriginal, + description, + height, + width, + exposureTime, + fNumber, + fileSize, + focalLength, + latitude, + longitude, + iso, + make, + model, + lens, + orientation, + timeZone, + rating, + projectionType, + ]); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteExifEntityData && + other.assetId == this.assetId && + other.city == this.city && + other.state == this.state && + other.country == this.country && + other.dateTimeOriginal == this.dateTimeOriginal && + other.description == this.description && + other.height == this.height && + other.width == this.width && + other.exposureTime == this.exposureTime && + other.fNumber == this.fNumber && + other.fileSize == this.fileSize && + other.focalLength == this.focalLength && + other.latitude == this.latitude && + other.longitude == this.longitude && + other.iso == this.iso && + other.make == this.make && + other.model == this.model && + other.lens == this.lens && + other.orientation == this.orientation && + other.timeZone == this.timeZone && + other.rating == this.rating && + other.projectionType == this.projectionType); +} + +class RemoteExifEntityCompanion extends UpdateCompanion { + final Value assetId; + final Value city; + final Value state; + final Value country; + final Value dateTimeOriginal; + final Value description; + final Value height; + final Value width; + final Value exposureTime; + final Value fNumber; + final Value fileSize; + final Value focalLength; + final Value latitude; + final Value longitude; + final Value iso; + final Value make; + final Value model; + final Value lens; + final Value orientation; + final Value timeZone; + final Value rating; + final Value projectionType; + const RemoteExifEntityCompanion({ + this.assetId = const Value.absent(), + this.city = const Value.absent(), + this.state = const Value.absent(), + this.country = const Value.absent(), + this.dateTimeOriginal = const Value.absent(), + this.description = const Value.absent(), + this.height = const Value.absent(), + this.width = const Value.absent(), + this.exposureTime = const Value.absent(), + this.fNumber = const Value.absent(), + this.fileSize = const Value.absent(), + this.focalLength = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.iso = const Value.absent(), + this.make = const Value.absent(), + this.model = const Value.absent(), + this.lens = const Value.absent(), + this.orientation = const Value.absent(), + this.timeZone = const Value.absent(), + this.rating = const Value.absent(), + this.projectionType = const Value.absent(), + }); + RemoteExifEntityCompanion.insert({ + required String assetId, + this.city = const Value.absent(), + this.state = const Value.absent(), + this.country = const Value.absent(), + this.dateTimeOriginal = const Value.absent(), + this.description = const Value.absent(), + this.height = const Value.absent(), + this.width = const Value.absent(), + this.exposureTime = const Value.absent(), + this.fNumber = const Value.absent(), + this.fileSize = const Value.absent(), + this.focalLength = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.iso = const Value.absent(), + this.make = const Value.absent(), + this.model = const Value.absent(), + this.lens = const Value.absent(), + this.orientation = const Value.absent(), + this.timeZone = const Value.absent(), + this.rating = const Value.absent(), + this.projectionType = const Value.absent(), + }) : assetId = Value(assetId); + static Insertable custom({ + Expression? assetId, + Expression? city, + Expression? state, + Expression? country, + Expression? dateTimeOriginal, + Expression? description, + Expression? height, + Expression? width, + Expression? exposureTime, + Expression? fNumber, + Expression? fileSize, + Expression? focalLength, + Expression? latitude, + Expression? longitude, + Expression? iso, + Expression? make, + Expression? model, + Expression? lens, + Expression? orientation, + Expression? timeZone, + Expression? rating, + Expression? projectionType, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (city != null) 'city': city, + if (state != null) 'state': state, + if (country != null) 'country': country, + if (dateTimeOriginal != null) 'date_time_original': dateTimeOriginal, + if (description != null) 'description': description, + if (height != null) 'height': height, + if (width != null) 'width': width, + if (exposureTime != null) 'exposure_time': exposureTime, + if (fNumber != null) 'f_number': fNumber, + if (fileSize != null) 'file_size': fileSize, + if (focalLength != null) 'focal_length': focalLength, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (iso != null) 'iso': iso, + if (make != null) 'make': make, + if (model != null) 'model': model, + if (lens != null) 'lens': lens, + if (orientation != null) 'orientation': orientation, + if (timeZone != null) 'time_zone': timeZone, + if (rating != null) 'rating': rating, + if (projectionType != null) 'projection_type': projectionType, + }); + } + + RemoteExifEntityCompanion copyWith({ + Value? assetId, + Value? city, + Value? state, + Value? country, + Value? dateTimeOriginal, + Value? description, + Value? height, + Value? width, + Value? exposureTime, + Value? fNumber, + Value? fileSize, + Value? focalLength, + Value? latitude, + Value? longitude, + Value? iso, + Value? make, + Value? model, + Value? lens, + Value? orientation, + Value? timeZone, + Value? rating, + Value? projectionType, + }) { + return RemoteExifEntityCompanion( + assetId: assetId ?? this.assetId, + city: city ?? this.city, + state: state ?? this.state, + country: country ?? this.country, + dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal, + description: description ?? this.description, + height: height ?? this.height, + width: width ?? this.width, + exposureTime: exposureTime ?? this.exposureTime, + fNumber: fNumber ?? this.fNumber, + fileSize: fileSize ?? this.fileSize, + focalLength: focalLength ?? this.focalLength, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + iso: iso ?? this.iso, + make: make ?? this.make, + model: model ?? this.model, + lens: lens ?? this.lens, + orientation: orientation ?? this.orientation, + timeZone: timeZone ?? this.timeZone, + rating: rating ?? this.rating, + projectionType: projectionType ?? this.projectionType, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (city.present) { + map['city'] = Variable(city.value); + } + if (state.present) { + map['state'] = Variable(state.value); + } + if (country.present) { + map['country'] = Variable(country.value); + } + if (dateTimeOriginal.present) { + map['date_time_original'] = Variable(dateTimeOriginal.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (exposureTime.present) { + map['exposure_time'] = Variable(exposureTime.value); + } + if (fNumber.present) { + map['f_number'] = Variable(fNumber.value); + } + if (fileSize.present) { + map['file_size'] = Variable(fileSize.value); + } + if (focalLength.present) { + map['focal_length'] = Variable(focalLength.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.value); + } + if (iso.present) { + map['iso'] = Variable(iso.value); + } + if (make.present) { + map['make'] = Variable(make.value); + } + if (model.present) { + map['model'] = Variable(model.value); + } + if (lens.present) { + map['lens'] = Variable(lens.value); + } + if (orientation.present) { + map['orientation'] = Variable(orientation.value); + } + if (timeZone.present) { + map['time_zone'] = Variable(timeZone.value); + } + if (rating.present) { + map['rating'] = Variable(rating.value); + } + if (projectionType.present) { + map['projection_type'] = Variable(projectionType.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteExifEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('city: $city, ') + ..write('state: $state, ') + ..write('country: $country, ') + ..write('dateTimeOriginal: $dateTimeOriginal, ') + ..write('description: $description, ') + ..write('height: $height, ') + ..write('width: $width, ') + ..write('exposureTime: $exposureTime, ') + ..write('fNumber: $fNumber, ') + ..write('fileSize: $fileSize, ') + ..write('focalLength: $focalLength, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('iso: $iso, ') + ..write('make: $make, ') + ..write('model: $model, ') + ..write('lens: $lens, ') + ..write('orientation: $orientation, ') + ..write('timeZone: $timeZone, ') + ..write('rating: $rating, ') + ..write('projectionType: $projectionType') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_album_entity (id) ON DELETE CASCADE', + ), + ); + @override + List get $columns => [assetId, albumId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_asset_entity'; + @override + Set get $primaryKey => {assetId, albumId}; + @override + RemoteAlbumAssetEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumAssetEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + albumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}album_id'], + )!, + ); + } + + @override + RemoteAlbumAssetEntity createAlias(String alias) { + return RemoteAlbumAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String albumId; + const RemoteAlbumAssetEntityData({ + required this.assetId, + required this.albumId, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['album_id'] = Variable(albumId); + return map; + } + + factory RemoteAlbumAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + albumId: serializer.fromJson(json['albumId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'albumId': serializer.toJson(albumId), + }; + } + + RemoteAlbumAssetEntityData copyWith({String? assetId, String? albumId}) => + RemoteAlbumAssetEntityData( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + RemoteAlbumAssetEntityData copyWithCompanion( + RemoteAlbumAssetEntityCompanion data, + ) { + return RemoteAlbumAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, albumId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumAssetEntityData && + other.assetId == this.assetId && + other.albumId == this.albumId); +} + +class RemoteAlbumAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value albumId; + const RemoteAlbumAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.albumId = const Value.absent(), + }); + RemoteAlbumAssetEntityCompanion.insert({ + required String assetId, + required String albumId, + }) : assetId = Value(assetId), + albumId = Value(albumId); + static Insertable custom({ + Expression? assetId, + Expression? albumId, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (albumId != null) 'album_id': albumId, + }); + } + + RemoteAlbumAssetEntityCompanion copyWith({ + Value? assetId, + Value? albumId, + }) { + return RemoteAlbumAssetEntityCompanion( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumUserEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumUserEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_album_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn role = GeneratedColumn( + 'role', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [albumId, userId, role]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_user_entity'; + @override + Set get $primaryKey => {albumId, userId}; + @override + RemoteAlbumUserEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumUserEntityData( + albumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}album_id'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}user_id'], + )!, + role: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}role'], + )!, + ); + } + + @override + RemoteAlbumUserEntity createAlias(String alias) { + return RemoteAlbumUserEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumUserEntityData extends DataClass + implements Insertable { + final String albumId; + final String userId; + final int role; + const RemoteAlbumUserEntityData({ + required this.albumId, + required this.userId, + required this.role, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['album_id'] = Variable(albumId); + map['user_id'] = Variable(userId); + map['role'] = Variable(role); + return map; + } + + factory RemoteAlbumUserEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumUserEntityData( + albumId: serializer.fromJson(json['albumId']), + userId: serializer.fromJson(json['userId']), + role: serializer.fromJson(json['role']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'albumId': serializer.toJson(albumId), + 'userId': serializer.toJson(userId), + 'role': serializer.toJson(role), + }; + } + + RemoteAlbumUserEntityData copyWith({ + String? albumId, + String? userId, + int? role, + }) => RemoteAlbumUserEntityData( + albumId: albumId ?? this.albumId, + userId: userId ?? this.userId, + role: role ?? this.role, + ); + RemoteAlbumUserEntityData copyWithCompanion( + RemoteAlbumUserEntityCompanion data, + ) { + return RemoteAlbumUserEntityData( + albumId: data.albumId.present ? data.albumId.value : this.albumId, + userId: data.userId.present ? data.userId.value : this.userId, + role: data.role.present ? data.role.value : this.role, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumUserEntityData(') + ..write('albumId: $albumId, ') + ..write('userId: $userId, ') + ..write('role: $role') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(albumId, userId, role); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumUserEntityData && + other.albumId == this.albumId && + other.userId == this.userId && + other.role == this.role); +} + +class RemoteAlbumUserEntityCompanion + extends UpdateCompanion { + final Value albumId; + final Value userId; + final Value role; + const RemoteAlbumUserEntityCompanion({ + this.albumId = const Value.absent(), + this.userId = const Value.absent(), + this.role = const Value.absent(), + }); + RemoteAlbumUserEntityCompanion.insert({ + required String albumId, + required String userId, + required int role, + }) : albumId = Value(albumId), + userId = Value(userId), + role = Value(role); + static Insertable custom({ + Expression? albumId, + Expression? userId, + Expression? role, + }) { + return RawValuesInsertable({ + if (albumId != null) 'album_id': albumId, + if (userId != null) 'user_id': userId, + if (role != null) 'role': role, + }); + } + + RemoteAlbumUserEntityCompanion copyWith({ + Value? albumId, + Value? userId, + Value? role, + }) { + return RemoteAlbumUserEntityCompanion( + albumId: albumId ?? this.albumId, + userId: userId ?? this.userId, + role: role ?? this.role, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (role.present) { + map['role'] = Variable(role.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumUserEntityCompanion(') + ..write('albumId: $albumId, ') + ..write('userId: $userId, ') + ..write('role: $role') + ..write(')')) + .toString(); + } +} + +class RemoteAssetCloudIdEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAssetCloudIdEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn cloudId = GeneratedColumn( + 'cloud_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn adjustmentTime = + GeneratedColumn( + 'adjustment_time', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn latitude = GeneratedColumn( + 'latitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + late final GeneratedColumn longitude = GeneratedColumn( + 'longitude', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + assetId, + cloudId, + createdAt, + adjustmentTime, + latitude, + longitude, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_asset_cloud_id_entity'; + @override + Set get $primaryKey => {assetId}; + @override + RemoteAssetCloudIdEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAssetCloudIdEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + cloudId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}cloud_id'], + ), + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + ), + adjustmentTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}adjustment_time'], + ), + latitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}latitude'], + ), + longitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}longitude'], + ), + ); + } + + @override + RemoteAssetCloudIdEntity createAlias(String alias) { + return RemoteAssetCloudIdEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAssetCloudIdEntityData extends DataClass + implements Insertable { + final String assetId; + final String? cloudId; + final DateTime? createdAt; + final DateTime? adjustmentTime; + final double? latitude; + final double? longitude; + const RemoteAssetCloudIdEntityData({ + required this.assetId, + this.cloudId, + this.createdAt, + this.adjustmentTime, + this.latitude, + this.longitude, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + if (!nullToAbsent || cloudId != null) { + map['cloud_id'] = Variable(cloudId); + } + if (!nullToAbsent || createdAt != null) { + map['created_at'] = Variable(createdAt); + } + if (!nullToAbsent || adjustmentTime != null) { + map['adjustment_time'] = Variable(adjustmentTime); + } + if (!nullToAbsent || latitude != null) { + map['latitude'] = Variable(latitude); + } + if (!nullToAbsent || longitude != null) { + map['longitude'] = Variable(longitude); + } + return map; + } + + factory RemoteAssetCloudIdEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAssetCloudIdEntityData( + assetId: serializer.fromJson(json['assetId']), + cloudId: serializer.fromJson(json['cloudId']), + createdAt: serializer.fromJson(json['createdAt']), + adjustmentTime: serializer.fromJson(json['adjustmentTime']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'cloudId': serializer.toJson(cloudId), + 'createdAt': serializer.toJson(createdAt), + 'adjustmentTime': serializer.toJson(adjustmentTime), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + }; + } + + RemoteAssetCloudIdEntityData copyWith({ + String? assetId, + Value cloudId = const Value.absent(), + Value createdAt = const Value.absent(), + Value adjustmentTime = const Value.absent(), + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + }) => RemoteAssetCloudIdEntityData( + assetId: assetId ?? this.assetId, + cloudId: cloudId.present ? cloudId.value : this.cloudId, + createdAt: createdAt.present ? createdAt.value : this.createdAt, + adjustmentTime: adjustmentTime.present + ? adjustmentTime.value + : this.adjustmentTime, + latitude: latitude.present ? latitude.value : this.latitude, + longitude: longitude.present ? longitude.value : this.longitude, + ); + RemoteAssetCloudIdEntityData copyWithCompanion( + RemoteAssetCloudIdEntityCompanion data, + ) { + return RemoteAssetCloudIdEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + cloudId: data.cloudId.present ? data.cloudId.value : this.cloudId, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + adjustmentTime: data.adjustmentTime.present + ? data.adjustmentTime.value + : this.adjustmentTime, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAssetCloudIdEntityData(') + ..write('assetId: $assetId, ') + ..write('cloudId: $cloudId, ') + ..write('createdAt: $createdAt, ') + ..write('adjustmentTime: $adjustmentTime, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + assetId, + cloudId, + createdAt, + adjustmentTime, + latitude, + longitude, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAssetCloudIdEntityData && + other.assetId == this.assetId && + other.cloudId == this.cloudId && + other.createdAt == this.createdAt && + other.adjustmentTime == this.adjustmentTime && + other.latitude == this.latitude && + other.longitude == this.longitude); +} + +class RemoteAssetCloudIdEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value cloudId; + final Value createdAt; + final Value adjustmentTime; + final Value latitude; + final Value longitude; + const RemoteAssetCloudIdEntityCompanion({ + this.assetId = const Value.absent(), + this.cloudId = const Value.absent(), + this.createdAt = const Value.absent(), + this.adjustmentTime = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + }); + RemoteAssetCloudIdEntityCompanion.insert({ + required String assetId, + this.cloudId = const Value.absent(), + this.createdAt = const Value.absent(), + this.adjustmentTime = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + }) : assetId = Value(assetId); + static Insertable custom({ + Expression? assetId, + Expression? cloudId, + Expression? createdAt, + Expression? adjustmentTime, + Expression? latitude, + Expression? longitude, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (cloudId != null) 'cloud_id': cloudId, + if (createdAt != null) 'created_at': createdAt, + if (adjustmentTime != null) 'adjustment_time': adjustmentTime, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + }); + } + + RemoteAssetCloudIdEntityCompanion copyWith({ + Value? assetId, + Value? cloudId, + Value? createdAt, + Value? adjustmentTime, + Value? latitude, + Value? longitude, + }) { + return RemoteAssetCloudIdEntityCompanion( + assetId: assetId ?? this.assetId, + cloudId: cloudId ?? this.cloudId, + createdAt: createdAt ?? this.createdAt, + adjustmentTime: adjustmentTime ?? this.adjustmentTime, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (cloudId.present) { + map['cloud_id'] = Variable(cloudId.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (adjustmentTime.present) { + map['adjustment_time'] = Variable(adjustmentTime.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAssetCloudIdEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('cloudId: $cloudId, ') + ..write('createdAt: $createdAt, ') + ..write('adjustmentTime: $adjustmentTime, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude') + ..write(')')) + .toString(); + } +} + +class MemoryEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + MemoryEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn data = GeneratedColumn( + 'data', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn isSaved = GeneratedColumn( + 'is_saved', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_saved" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn memoryAt = GeneratedColumn( + 'memory_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + late final GeneratedColumn seenAt = GeneratedColumn( + 'seen_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn showAt = GeneratedColumn( + 'show_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + late final GeneratedColumn hideAt = GeneratedColumn( + 'hide_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + deletedAt, + ownerId, + type, + data, + isSaved, + memoryAt, + seenAt, + showAt, + hideAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'memory_entity'; + @override + Set get $primaryKey => {id}; + @override + MemoryEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MemoryEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + deletedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}deleted_at'], + ), + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}type'], + )!, + data: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}data'], + )!, + isSaved: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_saved'], + )!, + memoryAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}memory_at'], + )!, + seenAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}seen_at'], + ), + showAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}show_at'], + ), + hideAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}hide_at'], + ), + ); + } + + @override + MemoryEntity createAlias(String alias) { + return MemoryEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class MemoryEntityData extends DataClass + implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? deletedAt; + final String ownerId; + final int type; + final String data; + final bool isSaved; + final DateTime memoryAt; + final DateTime? seenAt; + final DateTime? showAt; + final DateTime? hideAt; + const MemoryEntityData({ + required this.id, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.ownerId, + required this.type, + required this.data, + required this.isSaved, + required this.memoryAt, + this.seenAt, + this.showAt, + this.hideAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + map['owner_id'] = Variable(ownerId); + map['type'] = Variable(type); + map['data'] = Variable(data); + map['is_saved'] = Variable(isSaved); + map['memory_at'] = Variable(memoryAt); + if (!nullToAbsent || seenAt != null) { + map['seen_at'] = Variable(seenAt); + } + if (!nullToAbsent || showAt != null) { + map['show_at'] = Variable(showAt); + } + if (!nullToAbsent || hideAt != null) { + map['hide_at'] = Variable(hideAt); + } + return map; + } + + factory MemoryEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MemoryEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + deletedAt: serializer.fromJson(json['deletedAt']), + ownerId: serializer.fromJson(json['ownerId']), + type: serializer.fromJson(json['type']), + data: serializer.fromJson(json['data']), + isSaved: serializer.fromJson(json['isSaved']), + memoryAt: serializer.fromJson(json['memoryAt']), + seenAt: serializer.fromJson(json['seenAt']), + showAt: serializer.fromJson(json['showAt']), + hideAt: serializer.fromJson(json['hideAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'deletedAt': serializer.toJson(deletedAt), + 'ownerId': serializer.toJson(ownerId), + 'type': serializer.toJson(type), + 'data': serializer.toJson(data), + 'isSaved': serializer.toJson(isSaved), + 'memoryAt': serializer.toJson(memoryAt), + 'seenAt': serializer.toJson(seenAt), + 'showAt': serializer.toJson(showAt), + 'hideAt': serializer.toJson(hideAt), + }; + } + + MemoryEntityData copyWith({ + String? id, + DateTime? createdAt, + DateTime? updatedAt, + Value deletedAt = const Value.absent(), + String? ownerId, + int? type, + String? data, + bool? isSaved, + DateTime? memoryAt, + Value seenAt = const Value.absent(), + Value showAt = const Value.absent(), + Value hideAt = const Value.absent(), + }) => MemoryEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + ownerId: ownerId ?? this.ownerId, + type: type ?? this.type, + data: data ?? this.data, + isSaved: isSaved ?? this.isSaved, + memoryAt: memoryAt ?? this.memoryAt, + seenAt: seenAt.present ? seenAt.value : this.seenAt, + showAt: showAt.present ? showAt.value : this.showAt, + hideAt: hideAt.present ? hideAt.value : this.hideAt, + ); + MemoryEntityData copyWithCompanion(MemoryEntityCompanion data) { + return MemoryEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + type: data.type.present ? data.type.value : this.type, + data: data.data.present ? data.data.value : this.data, + isSaved: data.isSaved.present ? data.isSaved.value : this.isSaved, + memoryAt: data.memoryAt.present ? data.memoryAt.value : this.memoryAt, + seenAt: data.seenAt.present ? data.seenAt.value : this.seenAt, + showAt: data.showAt.present ? data.showAt.value : this.showAt, + hideAt: data.hideAt.present ? data.hideAt.value : this.hideAt, + ); + } + + @override + String toString() { + return (StringBuffer('MemoryEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('ownerId: $ownerId, ') + ..write('type: $type, ') + ..write('data: $data, ') + ..write('isSaved: $isSaved, ') + ..write('memoryAt: $memoryAt, ') + ..write('seenAt: $seenAt, ') + ..write('showAt: $showAt, ') + ..write('hideAt: $hideAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + createdAt, + updatedAt, + deletedAt, + ownerId, + type, + data, + isSaved, + memoryAt, + seenAt, + showAt, + hideAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MemoryEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.deletedAt == this.deletedAt && + other.ownerId == this.ownerId && + other.type == this.type && + other.data == this.data && + other.isSaved == this.isSaved && + other.memoryAt == this.memoryAt && + other.seenAt == this.seenAt && + other.showAt == this.showAt && + other.hideAt == this.hideAt); +} + +class MemoryEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value deletedAt; + final Value ownerId; + final Value type; + final Value data; + final Value isSaved; + final Value memoryAt; + final Value seenAt; + final Value showAt; + final Value hideAt; + const MemoryEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.type = const Value.absent(), + this.data = const Value.absent(), + this.isSaved = const Value.absent(), + this.memoryAt = const Value.absent(), + this.seenAt = const Value.absent(), + this.showAt = const Value.absent(), + this.hideAt = const Value.absent(), + }); + MemoryEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + required String ownerId, + required int type, + required String data, + this.isSaved = const Value.absent(), + required DateTime memoryAt, + this.seenAt = const Value.absent(), + this.showAt = const Value.absent(), + this.hideAt = const Value.absent(), + }) : id = Value(id), + ownerId = Value(ownerId), + type = Value(type), + data = Value(data), + memoryAt = Value(memoryAt); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? deletedAt, + Expression? ownerId, + Expression? type, + Expression? data, + Expression? isSaved, + Expression? memoryAt, + Expression? seenAt, + Expression? showAt, + Expression? hideAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (deletedAt != null) 'deleted_at': deletedAt, + if (ownerId != null) 'owner_id': ownerId, + if (type != null) 'type': type, + if (data != null) 'data': data, + if (isSaved != null) 'is_saved': isSaved, + if (memoryAt != null) 'memory_at': memoryAt, + if (seenAt != null) 'seen_at': seenAt, + if (showAt != null) 'show_at': showAt, + if (hideAt != null) 'hide_at': hideAt, + }); + } + + MemoryEntityCompanion copyWith({ + Value? id, + Value? createdAt, + Value? updatedAt, + Value? deletedAt, + Value? ownerId, + Value? type, + Value? data, + Value? isSaved, + Value? memoryAt, + Value? seenAt, + Value? showAt, + Value? hideAt, + }) { + return MemoryEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + ownerId: ownerId ?? this.ownerId, + type: type ?? this.type, + data: data ?? this.data, + isSaved: isSaved ?? this.isSaved, + memoryAt: memoryAt ?? this.memoryAt, + seenAt: seenAt ?? this.seenAt, + showAt: showAt ?? this.showAt, + hideAt: hideAt ?? this.hideAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (data.present) { + map['data'] = Variable(data.value); + } + if (isSaved.present) { + map['is_saved'] = Variable(isSaved.value); + } + if (memoryAt.present) { + map['memory_at'] = Variable(memoryAt.value); + } + if (seenAt.present) { + map['seen_at'] = Variable(seenAt.value); + } + if (showAt.present) { + map['show_at'] = Variable(showAt.value); + } + if (hideAt.present) { + map['hide_at'] = Variable(hideAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MemoryEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('ownerId: $ownerId, ') + ..write('type: $type, ') + ..write('data: $data, ') + ..write('isSaved: $isSaved, ') + ..write('memoryAt: $memoryAt, ') + ..write('seenAt: $seenAt, ') + ..write('showAt: $showAt, ') + ..write('hideAt: $hideAt') + ..write(')')) + .toString(); + } +} + +class MemoryAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + MemoryAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn memoryId = GeneratedColumn( + 'memory_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES memory_entity (id) ON DELETE CASCADE', + ), + ); + @override + List get $columns => [assetId, memoryId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'memory_asset_entity'; + @override + Set get $primaryKey => {assetId, memoryId}; + @override + MemoryAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MemoryAssetEntityData( + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + memoryId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}memory_id'], + )!, + ); + } + + @override + MemoryAssetEntity createAlias(String alias) { + return MemoryAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class MemoryAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String memoryId; + const MemoryAssetEntityData({required this.assetId, required this.memoryId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['memory_id'] = Variable(memoryId); + return map; + } + + factory MemoryAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MemoryAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + memoryId: serializer.fromJson(json['memoryId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'memoryId': serializer.toJson(memoryId), + }; + } + + MemoryAssetEntityData copyWith({String? assetId, String? memoryId}) => + MemoryAssetEntityData( + assetId: assetId ?? this.assetId, + memoryId: memoryId ?? this.memoryId, + ); + MemoryAssetEntityData copyWithCompanion(MemoryAssetEntityCompanion data) { + return MemoryAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + memoryId: data.memoryId.present ? data.memoryId.value : this.memoryId, + ); + } + + @override + String toString() { + return (StringBuffer('MemoryAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('memoryId: $memoryId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, memoryId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MemoryAssetEntityData && + other.assetId == this.assetId && + other.memoryId == this.memoryId); +} + +class MemoryAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value memoryId; + const MemoryAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.memoryId = const Value.absent(), + }); + MemoryAssetEntityCompanion.insert({ + required String assetId, + required String memoryId, + }) : assetId = Value(assetId), + memoryId = Value(memoryId); + static Insertable custom({ + Expression? assetId, + Expression? memoryId, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (memoryId != null) 'memory_id': memoryId, + }); + } + + MemoryAssetEntityCompanion copyWith({ + Value? assetId, + Value? memoryId, + }) { + return MemoryAssetEntityCompanion( + assetId: assetId ?? this.assetId, + memoryId: memoryId ?? this.memoryId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (memoryId.present) { + map['memory_id'] = Variable(memoryId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MemoryAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('memoryId: $memoryId') + ..write(')')) + .toString(); + } +} + +class PersonEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + PersonEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn faceAssetId = GeneratedColumn( + 'face_asset_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))', + ), + ); + late final GeneratedColumn isHidden = GeneratedColumn( + 'is_hidden', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_hidden" IN (0, 1))', + ), + ); + late final GeneratedColumn color = GeneratedColumn( + 'color', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn birthDate = GeneratedColumn( + 'birth_date', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + ownerId, + name, + faceAssetId, + isFavorite, + isHidden, + color, + birthDate, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'person_entity'; + @override + Set get $primaryKey => {id}; + @override + PersonEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PersonEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + ownerId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}owner_id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + faceAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}face_asset_id'], + ), + isFavorite: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_favorite'], + )!, + isHidden: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_hidden'], + )!, + color: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}color'], + ), + birthDate: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}birth_date'], + ), + ); + } + + @override + PersonEntity createAlias(String alias) { + return PersonEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class PersonEntityData extends DataClass + implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String name; + final String? faceAssetId; + final bool isFavorite; + final bool isHidden; + final String? color; + final DateTime? birthDate; + const PersonEntityData({ + required this.id, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + required this.name, + this.faceAssetId, + required this.isFavorite, + required this.isHidden, + this.color, + this.birthDate, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + map['name'] = Variable(name); + if (!nullToAbsent || faceAssetId != null) { + map['face_asset_id'] = Variable(faceAssetId); + } + map['is_favorite'] = Variable(isFavorite); + map['is_hidden'] = Variable(isHidden); + if (!nullToAbsent || color != null) { + map['color'] = Variable(color); + } + if (!nullToAbsent || birthDate != null) { + map['birth_date'] = Variable(birthDate); + } + return map; + } + + factory PersonEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PersonEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + name: serializer.fromJson(json['name']), + faceAssetId: serializer.fromJson(json['faceAssetId']), + isFavorite: serializer.fromJson(json['isFavorite']), + isHidden: serializer.fromJson(json['isHidden']), + color: serializer.fromJson(json['color']), + birthDate: serializer.fromJson(json['birthDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'name': serializer.toJson(name), + 'faceAssetId': serializer.toJson(faceAssetId), + 'isFavorite': serializer.toJson(isFavorite), + 'isHidden': serializer.toJson(isHidden), + 'color': serializer.toJson(color), + 'birthDate': serializer.toJson(birthDate), + }; + } + + PersonEntityData copyWith({ + String? id, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + String? name, + Value faceAssetId = const Value.absent(), + bool? isFavorite, + bool? isHidden, + Value color = const Value.absent(), + Value birthDate = const Value.absent(), + }) => PersonEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + name: name ?? this.name, + faceAssetId: faceAssetId.present ? faceAssetId.value : this.faceAssetId, + isFavorite: isFavorite ?? this.isFavorite, + isHidden: isHidden ?? this.isHidden, + color: color.present ? color.value : this.color, + birthDate: birthDate.present ? birthDate.value : this.birthDate, + ); + PersonEntityData copyWithCompanion(PersonEntityCompanion data) { + return PersonEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + name: data.name.present ? data.name.value : this.name, + faceAssetId: data.faceAssetId.present + ? data.faceAssetId.value + : this.faceAssetId, + isFavorite: data.isFavorite.present + ? data.isFavorite.value + : this.isFavorite, + isHidden: data.isHidden.present ? data.isHidden.value : this.isHidden, + color: data.color.present ? data.color.value : this.color, + birthDate: data.birthDate.present ? data.birthDate.value : this.birthDate, + ); + } + + @override + String toString() { + return (StringBuffer('PersonEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('name: $name, ') + ..write('faceAssetId: $faceAssetId, ') + ..write('isFavorite: $isFavorite, ') + ..write('isHidden: $isHidden, ') + ..write('color: $color, ') + ..write('birthDate: $birthDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + createdAt, + updatedAt, + ownerId, + name, + faceAssetId, + isFavorite, + isHidden, + color, + birthDate, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PersonEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.name == this.name && + other.faceAssetId == this.faceAssetId && + other.isFavorite == this.isFavorite && + other.isHidden == this.isHidden && + other.color == this.color && + other.birthDate == this.birthDate); +} + +class PersonEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value name; + final Value faceAssetId; + final Value isFavorite; + final Value isHidden; + final Value color; + final Value birthDate; + const PersonEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.name = const Value.absent(), + this.faceAssetId = const Value.absent(), + this.isFavorite = const Value.absent(), + this.isHidden = const Value.absent(), + this.color = const Value.absent(), + this.birthDate = const Value.absent(), + }); + PersonEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + required String name, + this.faceAssetId = const Value.absent(), + required bool isFavorite, + required bool isHidden, + this.color = const Value.absent(), + this.birthDate = const Value.absent(), + }) : id = Value(id), + ownerId = Value(ownerId), + name = Value(name), + isFavorite = Value(isFavorite), + isHidden = Value(isHidden); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? name, + Expression? faceAssetId, + Expression? isFavorite, + Expression? isHidden, + Expression? color, + Expression? birthDate, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (name != null) 'name': name, + if (faceAssetId != null) 'face_asset_id': faceAssetId, + if (isFavorite != null) 'is_favorite': isFavorite, + if (isHidden != null) 'is_hidden': isHidden, + if (color != null) 'color': color, + if (birthDate != null) 'birth_date': birthDate, + }); + } + + PersonEntityCompanion copyWith({ + Value? id, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? name, + Value? faceAssetId, + Value? isFavorite, + Value? isHidden, + Value? color, + Value? birthDate, + }) { + return PersonEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + name: name ?? this.name, + faceAssetId: faceAssetId ?? this.faceAssetId, + isFavorite: isFavorite ?? this.isFavorite, + isHidden: isHidden ?? this.isHidden, + color: color ?? this.color, + birthDate: birthDate ?? this.birthDate, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (faceAssetId.present) { + map['face_asset_id'] = Variable(faceAssetId.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (isHidden.present) { + map['is_hidden'] = Variable(isHidden.value); + } + if (color.present) { + map['color'] = Variable(color.value); + } + if (birthDate.present) { + map['birth_date'] = Variable(birthDate.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PersonEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('name: $name, ') + ..write('faceAssetId: $faceAssetId, ') + ..write('isFavorite: $isFavorite, ') + ..write('isHidden: $isHidden, ') + ..write('color: $color, ') + ..write('birthDate: $birthDate') + ..write(')')) + .toString(); + } +} + +class AssetFaceEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + AssetFaceEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn personId = GeneratedColumn( + 'person_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES person_entity (id) ON DELETE SET NULL', + ), + ); + late final GeneratedColumn imageWidth = GeneratedColumn( + 'image_width', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn imageHeight = GeneratedColumn( + 'image_height', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn boundingBoxX1 = GeneratedColumn( + 'bounding_box_x1', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn boundingBoxY1 = GeneratedColumn( + 'bounding_box_y1', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn boundingBoxX2 = GeneratedColumn( + 'bounding_box_x2', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn boundingBoxY2 = GeneratedColumn( + 'bounding_box_y2', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn sourceType = GeneratedColumn( + 'source_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn isVisible = GeneratedColumn( + 'is_visible', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_visible" IN (0, 1))', + ), + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + assetId, + personId, + imageWidth, + imageHeight, + boundingBoxX1, + boundingBoxY1, + boundingBoxX2, + boundingBoxY2, + sourceType, + isVisible, + deletedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'asset_face_entity'; + @override + Set get $primaryKey => {id}; + @override + AssetFaceEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AssetFaceEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + personId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}person_id'], + ), + imageWidth: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}image_width'], + )!, + imageHeight: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}image_height'], + )!, + boundingBoxX1: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}bounding_box_x1'], + )!, + boundingBoxY1: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}bounding_box_y1'], + )!, + boundingBoxX2: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}bounding_box_x2'], + )!, + boundingBoxY2: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}bounding_box_y2'], + )!, + sourceType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_type'], + )!, + isVisible: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_visible'], + )!, + deletedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}deleted_at'], + ), + ); + } + + @override + AssetFaceEntity createAlias(String alias) { + return AssetFaceEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class AssetFaceEntityData extends DataClass + implements Insertable { + final String id; + final String assetId; + final String? personId; + final int imageWidth; + final int imageHeight; + final int boundingBoxX1; + final int boundingBoxY1; + final int boundingBoxX2; + final int boundingBoxY2; + final String sourceType; + final bool isVisible; + final DateTime? deletedAt; + const AssetFaceEntityData({ + required this.id, + required this.assetId, + this.personId, + required this.imageWidth, + required this.imageHeight, + required this.boundingBoxX1, + required this.boundingBoxY1, + required this.boundingBoxX2, + required this.boundingBoxY2, + required this.sourceType, + required this.isVisible, + this.deletedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['asset_id'] = Variable(assetId); + if (!nullToAbsent || personId != null) { + map['person_id'] = Variable(personId); + } + map['image_width'] = Variable(imageWidth); + map['image_height'] = Variable(imageHeight); + map['bounding_box_x1'] = Variable(boundingBoxX1); + map['bounding_box_y1'] = Variable(boundingBoxY1); + map['bounding_box_x2'] = Variable(boundingBoxX2); + map['bounding_box_y2'] = Variable(boundingBoxY2); + map['source_type'] = Variable(sourceType); + map['is_visible'] = Variable(isVisible); + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + return map; + } + + factory AssetFaceEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AssetFaceEntityData( + id: serializer.fromJson(json['id']), + assetId: serializer.fromJson(json['assetId']), + personId: serializer.fromJson(json['personId']), + imageWidth: serializer.fromJson(json['imageWidth']), + imageHeight: serializer.fromJson(json['imageHeight']), + boundingBoxX1: serializer.fromJson(json['boundingBoxX1']), + boundingBoxY1: serializer.fromJson(json['boundingBoxY1']), + boundingBoxX2: serializer.fromJson(json['boundingBoxX2']), + boundingBoxY2: serializer.fromJson(json['boundingBoxY2']), + sourceType: serializer.fromJson(json['sourceType']), + isVisible: serializer.fromJson(json['isVisible']), + deletedAt: serializer.fromJson(json['deletedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'assetId': serializer.toJson(assetId), + 'personId': serializer.toJson(personId), + 'imageWidth': serializer.toJson(imageWidth), + 'imageHeight': serializer.toJson(imageHeight), + 'boundingBoxX1': serializer.toJson(boundingBoxX1), + 'boundingBoxY1': serializer.toJson(boundingBoxY1), + 'boundingBoxX2': serializer.toJson(boundingBoxX2), + 'boundingBoxY2': serializer.toJson(boundingBoxY2), + 'sourceType': serializer.toJson(sourceType), + 'isVisible': serializer.toJson(isVisible), + 'deletedAt': serializer.toJson(deletedAt), + }; + } + + AssetFaceEntityData copyWith({ + String? id, + String? assetId, + Value personId = const Value.absent(), + int? imageWidth, + int? imageHeight, + int? boundingBoxX1, + int? boundingBoxY1, + int? boundingBoxX2, + int? boundingBoxY2, + String? sourceType, + bool? isVisible, + Value deletedAt = const Value.absent(), + }) => AssetFaceEntityData( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + personId: personId.present ? personId.value : this.personId, + imageWidth: imageWidth ?? this.imageWidth, + imageHeight: imageHeight ?? this.imageHeight, + boundingBoxX1: boundingBoxX1 ?? this.boundingBoxX1, + boundingBoxY1: boundingBoxY1 ?? this.boundingBoxY1, + boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2, + boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2, + sourceType: sourceType ?? this.sourceType, + isVisible: isVisible ?? this.isVisible, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + ); + AssetFaceEntityData copyWithCompanion(AssetFaceEntityCompanion data) { + return AssetFaceEntityData( + id: data.id.present ? data.id.value : this.id, + assetId: data.assetId.present ? data.assetId.value : this.assetId, + personId: data.personId.present ? data.personId.value : this.personId, + imageWidth: data.imageWidth.present + ? data.imageWidth.value + : this.imageWidth, + imageHeight: data.imageHeight.present + ? data.imageHeight.value + : this.imageHeight, + boundingBoxX1: data.boundingBoxX1.present + ? data.boundingBoxX1.value + : this.boundingBoxX1, + boundingBoxY1: data.boundingBoxY1.present + ? data.boundingBoxY1.value + : this.boundingBoxY1, + boundingBoxX2: data.boundingBoxX2.present + ? data.boundingBoxX2.value + : this.boundingBoxX2, + boundingBoxY2: data.boundingBoxY2.present + ? data.boundingBoxY2.value + : this.boundingBoxY2, + sourceType: data.sourceType.present + ? data.sourceType.value + : this.sourceType, + isVisible: data.isVisible.present ? data.isVisible.value : this.isVisible, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + ); + } + + @override + String toString() { + return (StringBuffer('AssetFaceEntityData(') + ..write('id: $id, ') + ..write('assetId: $assetId, ') + ..write('personId: $personId, ') + ..write('imageWidth: $imageWidth, ') + ..write('imageHeight: $imageHeight, ') + ..write('boundingBoxX1: $boundingBoxX1, ') + ..write('boundingBoxY1: $boundingBoxY1, ') + ..write('boundingBoxX2: $boundingBoxX2, ') + ..write('boundingBoxY2: $boundingBoxY2, ') + ..write('sourceType: $sourceType, ') + ..write('isVisible: $isVisible, ') + ..write('deletedAt: $deletedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + assetId, + personId, + imageWidth, + imageHeight, + boundingBoxX1, + boundingBoxY1, + boundingBoxX2, + boundingBoxY2, + sourceType, + isVisible, + deletedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AssetFaceEntityData && + other.id == this.id && + other.assetId == this.assetId && + other.personId == this.personId && + other.imageWidth == this.imageWidth && + other.imageHeight == this.imageHeight && + other.boundingBoxX1 == this.boundingBoxX1 && + other.boundingBoxY1 == this.boundingBoxY1 && + other.boundingBoxX2 == this.boundingBoxX2 && + other.boundingBoxY2 == this.boundingBoxY2 && + other.sourceType == this.sourceType && + other.isVisible == this.isVisible && + other.deletedAt == this.deletedAt); +} + +class AssetFaceEntityCompanion extends UpdateCompanion { + final Value id; + final Value assetId; + final Value personId; + final Value imageWidth; + final Value imageHeight; + final Value boundingBoxX1; + final Value boundingBoxY1; + final Value boundingBoxX2; + final Value boundingBoxY2; + final Value sourceType; + final Value isVisible; + final Value deletedAt; + const AssetFaceEntityCompanion({ + this.id = const Value.absent(), + this.assetId = const Value.absent(), + this.personId = const Value.absent(), + this.imageWidth = const Value.absent(), + this.imageHeight = const Value.absent(), + this.boundingBoxX1 = const Value.absent(), + this.boundingBoxY1 = const Value.absent(), + this.boundingBoxX2 = const Value.absent(), + this.boundingBoxY2 = const Value.absent(), + this.sourceType = const Value.absent(), + this.isVisible = const Value.absent(), + this.deletedAt = const Value.absent(), + }); + AssetFaceEntityCompanion.insert({ + required String id, + required String assetId, + this.personId = const Value.absent(), + required int imageWidth, + required int imageHeight, + required int boundingBoxX1, + required int boundingBoxY1, + required int boundingBoxX2, + required int boundingBoxY2, + required String sourceType, + this.isVisible = const Value.absent(), + this.deletedAt = const Value.absent(), + }) : id = Value(id), + assetId = Value(assetId), + imageWidth = Value(imageWidth), + imageHeight = Value(imageHeight), + boundingBoxX1 = Value(boundingBoxX1), + boundingBoxY1 = Value(boundingBoxY1), + boundingBoxX2 = Value(boundingBoxX2), + boundingBoxY2 = Value(boundingBoxY2), + sourceType = Value(sourceType); + static Insertable custom({ + Expression? id, + Expression? assetId, + Expression? personId, + Expression? imageWidth, + Expression? imageHeight, + Expression? boundingBoxX1, + Expression? boundingBoxY1, + Expression? boundingBoxX2, + Expression? boundingBoxY2, + Expression? sourceType, + Expression? isVisible, + Expression? deletedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (assetId != null) 'asset_id': assetId, + if (personId != null) 'person_id': personId, + if (imageWidth != null) 'image_width': imageWidth, + if (imageHeight != null) 'image_height': imageHeight, + if (boundingBoxX1 != null) 'bounding_box_x1': boundingBoxX1, + if (boundingBoxY1 != null) 'bounding_box_y1': boundingBoxY1, + if (boundingBoxX2 != null) 'bounding_box_x2': boundingBoxX2, + if (boundingBoxY2 != null) 'bounding_box_y2': boundingBoxY2, + if (sourceType != null) 'source_type': sourceType, + if (isVisible != null) 'is_visible': isVisible, + if (deletedAt != null) 'deleted_at': deletedAt, + }); + } + + AssetFaceEntityCompanion copyWith({ + Value? id, + Value? assetId, + Value? personId, + Value? imageWidth, + Value? imageHeight, + Value? boundingBoxX1, + Value? boundingBoxY1, + Value? boundingBoxX2, + Value? boundingBoxY2, + Value? sourceType, + Value? isVisible, + Value? deletedAt, + }) { + return AssetFaceEntityCompanion( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + personId: personId ?? this.personId, + imageWidth: imageWidth ?? this.imageWidth, + imageHeight: imageHeight ?? this.imageHeight, + boundingBoxX1: boundingBoxX1 ?? this.boundingBoxX1, + boundingBoxY1: boundingBoxY1 ?? this.boundingBoxY1, + boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2, + boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2, + sourceType: sourceType ?? this.sourceType, + isVisible: isVisible ?? this.isVisible, + deletedAt: deletedAt ?? this.deletedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (personId.present) { + map['person_id'] = Variable(personId.value); + } + if (imageWidth.present) { + map['image_width'] = Variable(imageWidth.value); + } + if (imageHeight.present) { + map['image_height'] = Variable(imageHeight.value); + } + if (boundingBoxX1.present) { + map['bounding_box_x1'] = Variable(boundingBoxX1.value); + } + if (boundingBoxY1.present) { + map['bounding_box_y1'] = Variable(boundingBoxY1.value); + } + if (boundingBoxX2.present) { + map['bounding_box_x2'] = Variable(boundingBoxX2.value); + } + if (boundingBoxY2.present) { + map['bounding_box_y2'] = Variable(boundingBoxY2.value); + } + if (sourceType.present) { + map['source_type'] = Variable(sourceType.value); + } + if (isVisible.present) { + map['is_visible'] = Variable(isVisible.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AssetFaceEntityCompanion(') + ..write('id: $id, ') + ..write('assetId: $assetId, ') + ..write('personId: $personId, ') + ..write('imageWidth: $imageWidth, ') + ..write('imageHeight: $imageHeight, ') + ..write('boundingBoxX1: $boundingBoxX1, ') + ..write('boundingBoxY1: $boundingBoxY1, ') + ..write('boundingBoxX2: $boundingBoxX2, ') + ..write('boundingBoxY2: $boundingBoxY2, ') + ..write('sourceType: $sourceType, ') + ..write('isVisible: $isVisible, ') + ..write('deletedAt: $deletedAt') + ..write(')')) + .toString(); + } +} + +class StoreEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + StoreEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn stringValue = GeneratedColumn( + 'string_value', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn intValue = GeneratedColumn( + 'int_value', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + @override + List get $columns => [id, stringValue, intValue]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'store_entity'; + @override + Set get $primaryKey => {id}; + @override + StoreEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return StoreEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + stringValue: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}string_value'], + ), + intValue: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}int_value'], + ), + ); + } + + @override + StoreEntity createAlias(String alias) { + return StoreEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class StoreEntityData extends DataClass implements Insertable { + final int id; + final String? stringValue; + final int? intValue; + const StoreEntityData({required this.id, this.stringValue, this.intValue}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || stringValue != null) { + map['string_value'] = Variable(stringValue); + } + if (!nullToAbsent || intValue != null) { + map['int_value'] = Variable(intValue); + } + return map; + } + + factory StoreEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return StoreEntityData( + id: serializer.fromJson(json['id']), + stringValue: serializer.fromJson(json['stringValue']), + intValue: serializer.fromJson(json['intValue']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'stringValue': serializer.toJson(stringValue), + 'intValue': serializer.toJson(intValue), + }; + } + + StoreEntityData copyWith({ + int? id, + Value stringValue = const Value.absent(), + Value intValue = const Value.absent(), + }) => StoreEntityData( + id: id ?? this.id, + stringValue: stringValue.present ? stringValue.value : this.stringValue, + intValue: intValue.present ? intValue.value : this.intValue, + ); + StoreEntityData copyWithCompanion(StoreEntityCompanion data) { + return StoreEntityData( + id: data.id.present ? data.id.value : this.id, + stringValue: data.stringValue.present + ? data.stringValue.value + : this.stringValue, + intValue: data.intValue.present ? data.intValue.value : this.intValue, + ); + } + + @override + String toString() { + return (StringBuffer('StoreEntityData(') + ..write('id: $id, ') + ..write('stringValue: $stringValue, ') + ..write('intValue: $intValue') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, stringValue, intValue); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is StoreEntityData && + other.id == this.id && + other.stringValue == this.stringValue && + other.intValue == this.intValue); +} + +class StoreEntityCompanion extends UpdateCompanion { + final Value id; + final Value stringValue; + final Value intValue; + const StoreEntityCompanion({ + this.id = const Value.absent(), + this.stringValue = const Value.absent(), + this.intValue = const Value.absent(), + }); + StoreEntityCompanion.insert({ + required int id, + this.stringValue = const Value.absent(), + this.intValue = const Value.absent(), + }) : id = Value(id); + static Insertable custom({ + Expression? id, + Expression? stringValue, + Expression? intValue, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (stringValue != null) 'string_value': stringValue, + if (intValue != null) 'int_value': intValue, + }); + } + + StoreEntityCompanion copyWith({ + Value? id, + Value? stringValue, + Value? intValue, + }) { + return StoreEntityCompanion( + id: id ?? this.id, + stringValue: stringValue ?? this.stringValue, + intValue: intValue ?? this.intValue, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (stringValue.present) { + map['string_value'] = Variable(stringValue.value); + } + if (intValue.present) { + map['int_value'] = Variable(intValue.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('StoreEntityCompanion(') + ..write('id: $id, ') + ..write('stringValue: $stringValue, ') + ..write('intValue: $intValue') + ..write(')')) + .toString(); + } +} + +class TrashedLocalAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + TrashedLocalAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP'), + ); + late final GeneratedColumn width = GeneratedColumn( + 'width', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn height = GeneratedColumn( + 'height', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn durationInSeconds = GeneratedColumn( + 'duration_in_seconds', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn checksum = GeneratedColumn( + 'checksum', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))', + ), + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn orientation = GeneratedColumn( + 'orientation', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn playbackStyle = GeneratedColumn( + 'playback_style', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + albumId, + checksum, + isFavorite, + orientation, + source, + playbackStyle, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'trashed_local_asset_entity'; + @override + Set get $primaryKey => {id, albumId}; + @override + TrashedLocalAssetEntityData map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TrashedLocalAssetEntityData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + type: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}type'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + width: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}width'], + ), + height: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}height'], + ), + durationInSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}duration_in_seconds'], + ), + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + albumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}album_id'], + )!, + checksum: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}checksum'], + ), + isFavorite: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_favorite'], + )!, + orientation: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}orientation'], + )!, + source: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}source'], + )!, + playbackStyle: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}playback_style'], + )!, + ); + } + + @override + TrashedLocalAssetEntity createAlias(String alias) { + return TrashedLocalAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class TrashedLocalAssetEntityData extends DataClass + implements Insertable { + final String name; + final int type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final String id; + final String albumId; + final String? checksum; + final bool isFavorite; + final int orientation; + final int source; + final int playbackStyle; + const TrashedLocalAssetEntityData({ + required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.id, + required this.albumId, + this.checksum, + required this.isFavorite, + required this.orientation, + required this.source, + required this.playbackStyle, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = Variable(durationInSeconds); + } + map['id'] = Variable(id); + map['album_id'] = Variable(albumId); + if (!nullToAbsent || checksum != null) { + map['checksum'] = Variable(checksum); + } + map['is_favorite'] = Variable(isFavorite); + map['orientation'] = Variable(orientation); + map['source'] = Variable(source); + map['playback_style'] = Variable(playbackStyle); + return map; + } + + factory TrashedLocalAssetEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TrashedLocalAssetEntityData( + name: serializer.fromJson(json['name']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + albumId: serializer.fromJson(json['albumId']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + orientation: serializer.fromJson(json['orientation']), + source: serializer.fromJson(json['source']), + playbackStyle: serializer.fromJson(json['playbackStyle']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'albumId': serializer.toJson(albumId), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + 'orientation': serializer.toJson(orientation), + 'source': serializer.toJson(source), + 'playbackStyle': serializer.toJson(playbackStyle), + }; + } + + TrashedLocalAssetEntityData copyWith({ + String? name, + int? type, + DateTime? createdAt, + DateTime? updatedAt, + Value width = const Value.absent(), + Value height = const Value.absent(), + Value durationInSeconds = const Value.absent(), + String? id, + String? albumId, + Value checksum = const Value.absent(), + bool? isFavorite, + int? orientation, + int? source, + int? playbackStyle, + }) => TrashedLocalAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + albumId: albumId ?? this.albumId, + checksum: checksum.present ? checksum.value : this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + source: source ?? this.source, + playbackStyle: playbackStyle ?? this.playbackStyle, + ); + TrashedLocalAssetEntityData copyWithCompanion( + TrashedLocalAssetEntityCompanion data, + ) { + return TrashedLocalAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + width: data.width.present ? data.width.value : this.width, + height: data.height.present ? data.height.value : this.height, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: data.isFavorite.present + ? data.isFavorite.value + : this.isFavorite, + orientation: data.orientation.present + ? data.orientation.value + : this.orientation, + source: data.source.present ? data.source.value : this.source, + playbackStyle: data.playbackStyle.present + ? data.playbackStyle.value + : this.playbackStyle, + ); + } + + @override + String toString() { + return (StringBuffer('TrashedLocalAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('albumId: $albumId, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation, ') + ..write('source: $source, ') + ..write('playbackStyle: $playbackStyle') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + albumId, + checksum, + isFavorite, + orientation, + source, + playbackStyle, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TrashedLocalAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.width == this.width && + other.height == this.height && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.albumId == this.albumId && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite && + other.orientation == this.orientation && + other.source == this.source && + other.playbackStyle == this.playbackStyle); +} + +class TrashedLocalAssetEntityCompanion + extends UpdateCompanion { + final Value name; + final Value type; + final Value createdAt; + final Value updatedAt; + final Value width; + final Value height; + final Value durationInSeconds; + final Value id; + final Value albumId; + final Value checksum; + final Value isFavorite; + final Value orientation; + final Value source; + final Value playbackStyle; + const TrashedLocalAssetEntityCompanion({ + this.name = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + this.id = const Value.absent(), + this.albumId = const Value.absent(), + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + this.source = const Value.absent(), + this.playbackStyle = const Value.absent(), + }); + TrashedLocalAssetEntityCompanion.insert({ + required String name, + required int type, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + required String id, + required String albumId, + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + required int source, + this.playbackStyle = const Value.absent(), + }) : name = Value(name), + type = Value(type), + id = Value(id), + albumId = Value(albumId), + source = Value(source); + static Insertable custom({ + Expression? name, + Expression? type, + Expression? createdAt, + Expression? updatedAt, + Expression? width, + Expression? height, + Expression? durationInSeconds, + Expression? id, + Expression? albumId, + Expression? checksum, + Expression? isFavorite, + Expression? orientation, + Expression? source, + Expression? playbackStyle, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (albumId != null) 'album_id': albumId, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + if (orientation != null) 'orientation': orientation, + if (source != null) 'source': source, + if (playbackStyle != null) 'playback_style': playbackStyle, + }); + } + + TrashedLocalAssetEntityCompanion copyWith({ + Value? name, + Value? type, + Value? createdAt, + Value? updatedAt, + Value? width, + Value? height, + Value? durationInSeconds, + Value? id, + Value? albumId, + Value? checksum, + Value? isFavorite, + Value? orientation, + Value? source, + Value? playbackStyle, + }) { + return TrashedLocalAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + albumId: albumId ?? this.albumId, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + source: source ?? this.source, + playbackStyle: playbackStyle ?? this.playbackStyle, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = Variable(id.value); + } + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + if (checksum.present) { + map['checksum'] = Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (orientation.present) { + map['orientation'] = Variable(orientation.value); + } + if (source.present) { + map['source'] = Variable(source.value); + } + if (playbackStyle.present) { + map['playback_style'] = Variable(playbackStyle.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TrashedLocalAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('albumId: $albumId, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation, ') + ..write('source: $source, ') + ..write('playbackStyle: $playbackStyle') + ..write(')')) + .toString(); + } +} + +class AssetEditEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + AssetEditEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + late final GeneratedColumn action = GeneratedColumn( + 'action', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn parameters = GeneratedColumn( + 'parameters', + aliasedName, + false, + type: DriftSqlType.blob, + requiredDuringInsert: true, + ); + late final GeneratedColumn sequence = GeneratedColumn( + 'sequence', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + assetId, + action, + parameters, + sequence, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'asset_edit_entity'; + @override + Set get $primaryKey => {id}; + @override + AssetEditEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AssetEditEntityData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + assetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + action: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}action'], + )!, + parameters: attachedDatabase.typeMapping.read( + DriftSqlType.blob, + data['${effectivePrefix}parameters'], + )!, + sequence: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}sequence'], + )!, + ); + } + + @override + AssetEditEntity createAlias(String alias) { + return AssetEditEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class AssetEditEntityData extends DataClass + implements Insertable { + final String id; + final String assetId; + final int action; + final Uint8List parameters; + final int sequence; + const AssetEditEntityData({ + required this.id, + required this.assetId, + required this.action, + required this.parameters, + required this.sequence, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['asset_id'] = Variable(assetId); + map['action'] = Variable(action); + map['parameters'] = Variable(parameters); + map['sequence'] = Variable(sequence); + return map; + } + + factory AssetEditEntityData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AssetEditEntityData( + id: serializer.fromJson(json['id']), + assetId: serializer.fromJson(json['assetId']), + action: serializer.fromJson(json['action']), + parameters: serializer.fromJson(json['parameters']), + sequence: serializer.fromJson(json['sequence']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'assetId': serializer.toJson(assetId), + 'action': serializer.toJson(action), + 'parameters': serializer.toJson(parameters), + 'sequence': serializer.toJson(sequence), + }; + } + + AssetEditEntityData copyWith({ + String? id, + String? assetId, + int? action, + Uint8List? parameters, + int? sequence, + }) => AssetEditEntityData( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + action: action ?? this.action, + parameters: parameters ?? this.parameters, + sequence: sequence ?? this.sequence, + ); + AssetEditEntityData copyWithCompanion(AssetEditEntityCompanion data) { + return AssetEditEntityData( + id: data.id.present ? data.id.value : this.id, + assetId: data.assetId.present ? data.assetId.value : this.assetId, + action: data.action.present ? data.action.value : this.action, + parameters: data.parameters.present + ? data.parameters.value + : this.parameters, + sequence: data.sequence.present ? data.sequence.value : this.sequence, + ); + } + + @override + String toString() { + return (StringBuffer('AssetEditEntityData(') + ..write('id: $id, ') + ..write('assetId: $assetId, ') + ..write('action: $action, ') + ..write('parameters: $parameters, ') + ..write('sequence: $sequence') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + assetId, + action, + $driftBlobEquality.hash(parameters), + sequence, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AssetEditEntityData && + other.id == this.id && + other.assetId == this.assetId && + other.action == this.action && + $driftBlobEquality.equals(other.parameters, this.parameters) && + other.sequence == this.sequence); +} + +class AssetEditEntityCompanion extends UpdateCompanion { + final Value id; + final Value assetId; + final Value action; + final Value parameters; + final Value sequence; + const AssetEditEntityCompanion({ + this.id = const Value.absent(), + this.assetId = const Value.absent(), + this.action = const Value.absent(), + this.parameters = const Value.absent(), + this.sequence = const Value.absent(), + }); + AssetEditEntityCompanion.insert({ + required String id, + required String assetId, + required int action, + required Uint8List parameters, + required int sequence, + }) : id = Value(id), + assetId = Value(assetId), + action = Value(action), + parameters = Value(parameters), + sequence = Value(sequence); + static Insertable custom({ + Expression? id, + Expression? assetId, + Expression? action, + Expression? parameters, + Expression? sequence, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (assetId != null) 'asset_id': assetId, + if (action != null) 'action': action, + if (parameters != null) 'parameters': parameters, + if (sequence != null) 'sequence': sequence, + }); + } + + AssetEditEntityCompanion copyWith({ + Value? id, + Value? assetId, + Value? action, + Value? parameters, + Value? sequence, + }) { + return AssetEditEntityCompanion( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + action: action ?? this.action, + parameters: parameters ?? this.parameters, + sequence: sequence ?? this.sequence, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (action.present) { + map['action'] = Variable(action.value); + } + if (parameters.present) { + map['parameters'] = Variable(parameters.value); + } + if (sequence.present) { + map['sequence'] = Variable(sequence.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AssetEditEntityCompanion(') + ..write('id: $id, ') + ..write('assetId: $assetId, ') + ..write('action: $action, ') + ..write('parameters: $parameters, ') + ..write('sequence: $sequence') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV22 extends GeneratedDatabase { + DatabaseAtV22(QueryExecutor e) : super(e); + late final UserEntity userEntity = UserEntity(this); + late final RemoteAssetEntity remoteAssetEntity = RemoteAssetEntity(this); + late final StackEntity stackEntity = StackEntity(this); + late final LocalAssetEntity localAssetEntity = LocalAssetEntity(this); + late final RemoteAlbumEntity remoteAlbumEntity = RemoteAlbumEntity(this); + late final LocalAlbumEntity localAlbumEntity = LocalAlbumEntity(this); + late final LocalAlbumAssetEntity localAlbumAssetEntity = + LocalAlbumAssetEntity(this); + late final Index idxLocalAlbumAssetAlbumAsset = Index( + 'idx_local_album_asset_album_asset', + 'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)', + ); + late final Index idxRemoteAlbumOwnerId = Index( + 'idx_remote_album_owner_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)', + ); + late final Index idxLocalAssetChecksum = Index( + 'idx_local_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)', + ); + late final Index idxLocalAssetCloudId = Index( + 'idx_local_asset_cloud_id', + 'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)', + ); + late final Index idxStackPrimaryAssetId = Index( + 'idx_stack_primary_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)', + ); + late final Index idxRemoteAssetOwnerChecksum = Index( + 'idx_remote_asset_owner_checksum', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', + ); + late final Index uQRemoteAssetsOwnerChecksum = 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)', + ); + late final Index uQRemoteAssetsOwnerLibraryChecksum = 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)', + ); + late final Index idxRemoteAssetChecksum = Index( + 'idx_remote_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)', + ); + late final Index idxRemoteAssetStackId = Index( + 'idx_remote_asset_stack_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)', + ); + late final Index idxRemoteAssetLocalDateTimeDay = Index( + 'idx_remote_asset_local_date_time_day', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))', + ); + late final Index idxRemoteAssetLocalDateTimeMonth = Index( + 'idx_remote_asset_local_date_time_month', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))', + ); + late final AuthUserEntity authUserEntity = AuthUserEntity(this); + late final UserMetadataEntity userMetadataEntity = UserMetadataEntity(this); + late final PartnerEntity partnerEntity = PartnerEntity(this); + late final RemoteExifEntity remoteExifEntity = RemoteExifEntity(this); + late final RemoteAlbumAssetEntity remoteAlbumAssetEntity = + RemoteAlbumAssetEntity(this); + late final RemoteAlbumUserEntity remoteAlbumUserEntity = + RemoteAlbumUserEntity(this); + late final RemoteAssetCloudIdEntity remoteAssetCloudIdEntity = + RemoteAssetCloudIdEntity(this); + late final MemoryEntity memoryEntity = MemoryEntity(this); + late final MemoryAssetEntity memoryAssetEntity = MemoryAssetEntity(this); + late final PersonEntity personEntity = PersonEntity(this); + late final AssetFaceEntity assetFaceEntity = AssetFaceEntity(this); + late final StoreEntity storeEntity = StoreEntity(this); + late final TrashedLocalAssetEntity trashedLocalAssetEntity = + TrashedLocalAssetEntity(this); + late final AssetEditEntity assetEditEntity = AssetEditEntity(this); + late final Index idxPartnerSharedWithId = Index( + 'idx_partner_shared_with_id', + 'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)', + ); + late final Index idxLatLng = Index( + 'idx_lat_lng', + 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)', + ); + late final Index idxRemoteAlbumAssetAlbumAsset = Index( + 'idx_remote_album_asset_album_asset', + 'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)', + ); + late final Index idxRemoteAssetCloudId = Index( + 'idx_remote_asset_cloud_id', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)', + ); + late final Index idxPersonOwnerId = Index( + 'idx_person_owner_id', + 'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)', + ); + late final Index idxAssetFacePersonId = Index( + 'idx_asset_face_person_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)', + ); + late final Index idxAssetFaceAssetId = Index( + 'idx_asset_face_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)', + ); + late final Index idxTrashedLocalAssetChecksum = Index( + 'idx_trashed_local_asset_checksum', + 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)', + ); + late final Index idxTrashedLocalAssetAlbum = Index( + 'idx_trashed_local_asset_album', + 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)', + ); + late final Index idxAssetEditAssetId = Index( + 'idx_asset_edit_asset_id', + 'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + userEntity, + remoteAssetEntity, + stackEntity, + localAssetEntity, + remoteAlbumEntity, + localAlbumEntity, + localAlbumAssetEntity, + idxLocalAlbumAssetAlbumAsset, + idxRemoteAlbumOwnerId, + idxLocalAssetChecksum, + idxLocalAssetCloudId, + idxStackPrimaryAssetId, + idxRemoteAssetOwnerChecksum, + uQRemoteAssetsOwnerChecksum, + uQRemoteAssetsOwnerLibraryChecksum, + idxRemoteAssetChecksum, + idxRemoteAssetStackId, + idxRemoteAssetLocalDateTimeDay, + idxRemoteAssetLocalDateTimeMonth, + authUserEntity, + userMetadataEntity, + partnerEntity, + remoteExifEntity, + remoteAlbumAssetEntity, + remoteAlbumUserEntity, + remoteAssetCloudIdEntity, + memoryEntity, + memoryAssetEntity, + personEntity, + assetFaceEntity, + storeEntity, + trashedLocalAssetEntity, + assetEditEntity, + idxPartnerSharedWithId, + idxLatLng, + idxRemoteAlbumAssetAlbumAsset, + idxRemoteAssetCloudId, + idxPersonOwnerId, + idxAssetFacePersonId, + idxAssetFaceAssetId, + idxTrashedLocalAssetChecksum, + idxTrashedLocalAssetAlbum, + idxAssetEditAssetId, + ]; + @override + int get schemaVersion => 22; + @override + DriftDatabaseOptions get options => + const DriftDatabaseOptions(storeDateTimeAsText: true); +} From ec7246b86f7b9a63db84473faeba67c22106b688 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 5 Mar 2026 22:00:37 -0500 Subject: [PATCH 088/150] refactor(web): add --font-sans CSS variable for primary font (#26730) --- web/src/app.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/app.css b/web/src/app.css index 3a4d29b466..1ff3bec99b 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -49,6 +49,7 @@ } @theme { + --font-sans: 'GoogleSans', sans-serif; --font-mono: 'GoogleSansCode', monospace; --spacing-18: 4.5rem; @@ -100,7 +101,7 @@ } :root { - font-family: 'GoogleSans', sans-serif; + font-family: var(--font-sans); letter-spacing: 0.1px; /* Used by layouts to ensure proper spacing between navbar and content */ From abfcffb4237fd5aed14b193ca0b8f3eba53869a9 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 5 Mar 2026 22:58:09 -0500 Subject: [PATCH 089/150] feat(web): toggle zoom on double-click in photo viewer (#26732) --- web/src/lib/components/asset-viewer/photo-viewer.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 3e609ff130..fd87450d58 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -255,6 +255,7 @@ bind:clientWidth={containerWidth} bind:clientHeight={containerHeight} role="presentation" + ondblclick={onZoom} onmousemove={handleImageMouseMove} onmouseleave={handleImageMouseLeave} > From 6012d22d9883d6d32c512579e7d0db2313a3eb79 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Fri, 6 Mar 2026 03:58:58 +0000 Subject: [PATCH 090/150] fix(mobile): incorrect asset dimensions in search (#26725) Search results use a different provider than the main timeline, and they appear appear to have diverged a bit. This means that assets can sometimes look wrong or different in search compared to the main timeline or albums. --- mobile/lib/domain/services/search.service.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mobile/lib/domain/services/search.service.dart b/mobile/lib/domain/services/search.service.dart index a3f935c492..004ad06b1b 100644 --- a/mobile/lib/domain/services/search.service.dart +++ b/mobile/lib/domain/services/search.service.dart @@ -70,13 +70,14 @@ extension on AssetResponseDto { _ => AssetVisibility.timeline, }, durationInSeconds: duration.toDuration()?.inSeconds ?? 0, - height: exifInfo?.exifImageHeight?.toInt(), - width: exifInfo?.exifImageWidth?.toInt(), + height: height?.toInt(), + width: width?.toInt(), isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, thumbHash: thumbhash, localId: null, type: type.toAssetType(), + stackId: stack?.id, isEdited: isEdited, ); } From 6e9a425592c66cba5e8713462e4f306641bf625a Mon Sep 17 00:00:00 2001 From: Snowknight26 Date: Fri, 6 Mar 2026 14:17:11 -0600 Subject: [PATCH 091/150] fix(web): asset viewer showing wrong viewer type when hovering on stack thumbnails (#26741) --- web/src/lib/components/asset-viewer/asset-viewer.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index c87baef42a..2a75ca4e83 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -362,7 +362,7 @@ const viewerKind = $derived.by(() => { if (previewStackedAsset) { - return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer'; + return previewStackedAsset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer'; } if (asset.type === AssetTypeEnum.Video) { return 'VideoViewer'; From e73686bd76b6147a4e1ccbce4eab23a4ece8ebd9 Mon Sep 17 00:00:00 2001 From: Luis Nachtigall <31982496+LeLunZ@users.noreply.github.com> Date: Sat, 7 Mar 2026 16:41:26 +0100 Subject: [PATCH 092/150] feat(android): enhance playback style detection using MIME type, reducing glide exposure (#26747) * feat(android): enhance playback style detection using MIME type * feat(android): improve playback style detection for GIF and WebP formats * fix(android): make playback style detection faster * refactor(android): simplify XMP reading logic for API 29 and below * update playback style detection documentation * use DefaultImageHeaderParser instead of all available ones for webp playbackStyle type detection --- .../alextran/immich/sync/MessagesImplBase.kt | 82 +++++++++++-------- 1 file changed, 49 insertions(+), 33 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index 0cc642c862..949720325e 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -16,6 +16,7 @@ import app.alextran.immich.core.ImmichPlugin import com.bumptech.glide.Glide import com.bumptech.glide.load.ImageHeaderParser import com.bumptech.glide.load.ImageHeaderParserUtils +import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -81,10 +82,13 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { } if (hasSpecialFormatColumn()) { add(SPECIAL_FORMAT_COLUMN) - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - // Fallback: read XMP from MediaStore to detect Motion Photos - // only needed if SPECIAL_FORMAT column isn't available - add(MediaStore.MediaColumns.XMP) + } else { + // fallback to mimetype and xmp for playback style detection on older Android versions + // both only needed if special format column is not available + add(MediaStore.MediaColumns.MIME_TYPE) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + add(MediaStore.MediaColumns.XMP) + } } }.toTypedArray() @@ -131,6 +135,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED) val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE) + val mimeTypeColumn = c.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE) val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID) val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH) val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT) @@ -177,7 +182,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0 val playbackStyle = detectPlaybackStyle( - numericId, rawMediaType, specialFormatColumn, xmpColumn, c + numericId, rawMediaType, mimeTypeColumn, specialFormatColumn, xmpColumn, c ) val asset = PlatformAsset( @@ -200,13 +205,14 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { } /** - * Detects the playback style for an asset using _special_format (API 33+) - * or XMP / MIME / RIFF header fallbacks (pre-33). + * Detects the playback style for an asset using _special_format (SDK Extension 21+) + * or XMP / MIME / RIFF header fallbacks. */ @SuppressLint("NewApi") private fun detectPlaybackStyle( assetId: Long, rawMediaType: Int, + mimeTypeColumn: Int, specialFormatColumn: Int, xmpColumn: Int, cursor: Cursor @@ -231,46 +237,56 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { return PlatformAssetPlaybackStyle.UNKNOWN } - // Pre-API 33 fallback + val mimeType = if (mimeTypeColumn != -1) cursor.getString(mimeTypeColumn) else null + + // GIFs are always animated and cannot be motion photos; no I/O needed + if (mimeType == "image/gif") { + return PlatformAssetPlaybackStyle.IMAGE_ANIMATED + } + val uri = ContentUris.withAppendedId( MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), assetId ) - // Read XMP from cursor (API 30+) or ExifInterface stream (pre-30) + // Only WebP needs a stream check to distinguish static vs animated; + // WebP files are not used as motion photos, so skip XMP detection + if (mimeType == "image/webp") { + try { + val glide = Glide.get(ctx) + ctx.contentResolver.openInputStream(uri)?.use { stream -> + val type = ImageHeaderParserUtils.getType( + listOf(DefaultImageHeaderParser()), + stream, + glide.arrayPool + ) + // Also check for GIF just in case MIME type is incorrect; Doesn't hurt performance + if (type == ImageHeaderParser.ImageType.ANIMATED_WEBP || type == ImageHeaderParser.ImageType.GIF) { + return PlatformAssetPlaybackStyle.IMAGE_ANIMATED + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to parse image header for asset $assetId", e) + } + // if mimeType is webp but not animated, its just an image. + return PlatformAssetPlaybackStyle.IMAGE + } + + + // Read XMP from cursor (API 30+) val xmp: String? = if (xmpColumn != -1) { cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8) } else { - try { - ctx.contentResolver.openInputStream(uri)?.use { stream -> - ExifInterface(stream).getAttribute(ExifInterface.TAG_XMP) - } - } catch (e: Exception) { - Log.w(TAG, "Failed to read XMP for asset $assetId", e) - null - } + // if xmp column is not available, we are on API 29 or below + // theoretically there were motion photos but the Camera:MotionPhoto xmp tag + // was only added in Android 11, so we should not have to worry about parsing XMP on older versions + null } if (xmp != null && "Camera:MotionPhoto" in xmp) { return PlatformAssetPlaybackStyle.LIVE_PHOTO } - try { - ctx.contentResolver.openInputStream(uri)?.use { stream -> - val glide = Glide.get(ctx) - val type = ImageHeaderParserUtils.getType( - glide.registry.imageHeaderParsers, - stream, - glide.arrayPool - ) - if (type == ImageHeaderParser.ImageType.GIF || type == ImageHeaderParser.ImageType.ANIMATED_WEBP) { - return PlatformAssetPlaybackStyle.IMAGE_ANIMATED - } - } - } catch (e: Exception) { - Log.w(TAG, "Failed to parse image header for asset $assetId", e) - } - return PlatformAssetPlaybackStyle.IMAGE } From dd72ec2621c9cc686997d26e20f1d2761c716c90 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Sat, 7 Mar 2026 18:07:34 +0000 Subject: [PATCH 093/150] fix(mobile): correct local asset dimensions (#26677) * fix(mobile): correct local asset dimensions We are constraining the size of videos so that they play nicely with hero animations, and don't stretch in weird ways. This however caused a regression as we are not account for local assets on Android which have un-oriented dimensions. * post-orientation width and height in local sync * migration * no need to handle it in asset viewer --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> Co-authored-by: Alex --- .../alextran/immich/sync/MessagesImplBase.kt | 7 ++++--- mobile/lib/utils/migration.dart | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt index 949720325e..05671579ae 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/sync/MessagesImplBase.kt @@ -185,16 +185,17 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { numericId, rawMediaType, mimeTypeColumn, specialFormatColumn, xmpColumn, c ) + val isFlipped = orientation == 90 || orientation == 270 val asset = PlatformAsset( id, name, assetType, createdAt, modifiedAt, - width, - height, + if (isFlipped) height else width, + if (isFlipped) width else height, duration, - orientation.toLong(), + 0L, isFavorite, playbackStyle = playbackStyle, ) diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index aeed9f616e..efb4d60369 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -35,7 +35,7 @@ import 'package:isar/isar.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 23; +const int targetVersion = 24; Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { final hasVersion = Store.tryGet(StoreKey.version) != null; @@ -105,6 +105,10 @@ Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { await _populateLocalAssetPlaybackStyle(drift); } + if (version < 24 && Store.isBetaTimelineEnabled) { + await _applyLocalAssetOrientation(drift); + } + if (version < 22 && !Store.isBetaTimelineEnabled) { await Store.put(StoreKey.needBetaMigration, true); } @@ -436,6 +440,18 @@ Future _populateLocalAssetPlaybackStyle(Drift db) async { } } +Future _applyLocalAssetOrientation(Drift db) { + final query = db.localAssetEntity.update() + ..where((filter) => (filter.orientation.equals(90) | (filter.orientation.equals(270)))); + return query.write( + LocalAssetEntityCompanion.custom( + width: db.localAssetEntity.height, + height: db.localAssetEntity.width, + orientation: const Variable(0), + ), + ); +} + AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) { PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown, PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image, From 4a384bca86c8e8d461e520e92a94a2fc67036996 Mon Sep 17 00:00:00 2001 From: Sergey Katsubo Date: Sat, 7 Mar 2026 21:08:42 +0300 Subject: [PATCH 094/150] fix(server): opus handling as accepted audio codec in transcode policy (#26736) * Fix opus handling as accepted audio codec in transcode policy Fix the issue when opus is among accepted audio codecs in transcode policy (which is default) but it still triggers transcoding because the codec name from ffprobe (opus) does not match `libopus` literal in Immich. Make a distinction between a codec name and encoder: - codec name: switch to `opus` as the audio codec name. This matches what ffprobe returns for a media file with opus audio. - encoder: continue using the `libopus` encoder in ffmpeg. * Add unit tests for accepted audio codecs and for libopus encoder * Add db migration for ffmpeg.targetAudioCodec opus * backward compatibility * tweak * noisy logs * full mapping * make check happy * mark deprecated * update api * indexOf --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> --- docs/docs/administration/system-settings.md | 2 +- docs/docs/install/config-file.md | 2 +- mobile/openapi/lib/model/audio_codec.dart | 3 + open-api/immich-openapi-specs.json | 1 + open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/config.ts | 2 +- server/src/constants.ts | 10 ++- server/src/dtos/system-config.dto.ts | 12 +++- server/src/enum.ts | 4 +- .../1772609167000-UpdateOpusCodecName.ts | 65 +++++++++++++++++++ server/src/services/media.service.spec.ts | 44 +++++++++++++ .../services/system-config.service.spec.ts | 2 +- server/src/utils/media.ts | 7 +- server/test/fixtures/media.stub.ts | 8 +++ .../admin-settings/FFmpegSettings.svelte | 4 +- 15 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 server/src/schema/migrations/1772609167000-UpdateOpusCodecName.ts diff --git a/docs/docs/administration/system-settings.md b/docs/docs/administration/system-settings.md index fdfdad29ea..7dc9c08db3 100644 --- a/docs/docs/administration/system-settings.md +++ b/docs/docs/administration/system-settings.md @@ -230,7 +230,7 @@ The default value is `ultrafast`. ### Audio codec (`ffmpeg.targetAudioCodec`) {#ffmpeg.targetAudioCodec} -Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `libopus`. +Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `opus`. The default value is `aac`. diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index bf815521ef..3355750603 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -27,7 +27,7 @@ The default configuration looks like this: "ffmpeg": { "accel": "disabled", "accelDecode": false, - "acceptedAudioCodecs": ["aac", "mp3", "libopus"], + "acceptedAudioCodecs": ["aac", "mp3", "opus"], "acceptedContainers": ["mov", "ogg", "webm"], "acceptedVideoCodecs": ["h264"], "bframes": -1, diff --git a/mobile/openapi/lib/model/audio_codec.dart b/mobile/openapi/lib/model/audio_codec.dart index 095c616995..be1ff0dcb9 100644 --- a/mobile/openapi/lib/model/audio_codec.dart +++ b/mobile/openapi/lib/model/audio_codec.dart @@ -26,6 +26,7 @@ class AudioCodec { static const mp3 = AudioCodec._(r'mp3'); static const aac = AudioCodec._(r'aac'); static const libopus = AudioCodec._(r'libopus'); + static const opus = AudioCodec._(r'opus'); static const pcmS16le = AudioCodec._(r'pcm_s16le'); /// List of all possible values in this [enum][AudioCodec]. @@ -33,6 +34,7 @@ class AudioCodec { mp3, aac, libopus, + opus, pcmS16le, ]; @@ -75,6 +77,7 @@ class AudioCodecTypeTransformer { case r'mp3': return AudioCodec.mp3; case r'aac': return AudioCodec.aac; case r'libopus': return AudioCodec.libopus; + case r'opus': return AudioCodec.opus; case r'pcm_s16le': return AudioCodec.pcmS16le; default: if (!allowNull) { diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 38e1fe8e01..d2eb322009 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -17260,6 +17260,7 @@ "mp3", "aac", "libopus", + "opus", "pcm_s16le" ], "type": "string" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 1ae12cd091..5c8ac6dbc1 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -7324,6 +7324,7 @@ export enum AudioCodec { Mp3 = "mp3", Aac = "aac", Libopus = "libopus", + Opus = "opus", PcmS16Le = "pcm_s16le" } export enum VideoContainer { diff --git a/server/src/config.ts b/server/src/config.ts index 2a43b51187..e6134df477 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -206,7 +206,7 @@ export const defaults = Object.freeze({ targetVideoCodec: VideoCodec.H264, acceptedVideoCodecs: [VideoCodec.H264], targetAudioCodec: AudioCodec.Aac, - acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus], + acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus], acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm], targetResolution: '720', maxBitrate: '0', diff --git a/server/src/constants.ts b/server/src/constants.ts index 9ea5e134b6..e24057beba 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -2,7 +2,7 @@ import { Duration } from 'luxon'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { SemVer } from 'semver'; -import { ApiTag, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum'; +import { ApiTag, AudioCodec, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum'; export const ErrorMessages = { InconsistentMediaLocation: @@ -201,3 +201,11 @@ export const endpointTags: Record = { [ApiTag.Workflows]: 'A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution.', }; + +export const AUDIO_ENCODER: Record = { + [AudioCodec.Aac]: 'aac', + [AudioCodec.Mp3]: 'mp3', + [AudioCodec.Libopus]: 'libopus', + [AudioCodec.Opus]: 'libopus', + [AudioCodec.PcmS16le]: 'pcm_s16le', +}; diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 7a0dcb6f3a..a214dbc467 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { ArrayMinSize, IsInt, @@ -92,6 +92,16 @@ export class SystemConfigFFmpegDto { targetAudioCodec!: AudioCodec; @ValidateEnum({ enum: AudioCodec, name: 'AudioCodec', each: true, description: 'Accepted audio codecs' }) + @Transform(({ value }) => { + if (Array.isArray(value)) { + const libopusIndex = value.indexOf('libopus'); + if (libopusIndex !== -1) { + value[libopusIndex] = 'opus'; + } + } + + return value; + }) acceptedAudioCodecs!: AudioCodec[]; @ValidateEnum({ enum: VideoContainer, name: 'VideoContainer', each: true, description: 'Accepted containers' }) diff --git a/server/src/enum.ts b/server/src/enum.ts index 2aa9bd2aa6..887c8fa93c 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -409,7 +409,9 @@ export enum VideoCodec { export enum AudioCodec { Mp3 = 'mp3', Aac = 'aac', - LibOpus = 'libopus', + /** @deprecated Use `Opus` instead */ + Libopus = 'libopus', + Opus = 'opus', PcmS16le = 'pcm_s16le', } diff --git a/server/src/schema/migrations/1772609167000-UpdateOpusCodecName.ts b/server/src/schema/migrations/1772609167000-UpdateOpusCodecName.ts new file mode 100644 index 0000000000..9fa5f7d788 --- /dev/null +++ b/server/src/schema/migrations/1772609167000-UpdateOpusCodecName.ts @@ -0,0 +1,65 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql` + UPDATE system_metadata + SET value = jsonb_set( + value, + '{ffmpeg,acceptedAudioCodecs}', + ( + SELECT jsonb_agg( + CASE + WHEN elem = 'libopus' THEN 'opus' + ELSE elem + END + ) + FROM jsonb_array_elements_text(value->'ffmpeg'->'acceptedAudioCodecs') elem + ) + ) + WHERE key = 'system-config' + AND value->'ffmpeg'->'acceptedAudioCodecs' ? 'libopus'; + `.execute(db); + + await sql` + UPDATE system_metadata + SET value = jsonb_set( + value, + '{ffmpeg,targetAudioCodec}', + '"opus"'::jsonb + ) + WHERE key = 'system-config' + AND value->'ffmpeg'->>'targetAudioCodec' = 'libopus'; + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql` + UPDATE system_metadata + SET value = jsonb_set( + value, + '{ffmpeg,acceptedAudioCodecs}', + ( + SELECT jsonb_agg( + CASE + WHEN elem = 'opus' THEN 'libopus' + ELSE elem + END + ) + FROM jsonb_array_elements_text(value->'ffmpeg'->'acceptedAudioCodecs') elem + ) + ) + WHERE key = 'system-config' + AND value->'ffmpeg'->'acceptedAudioCodecs' ? 'opus'; + `.execute(db); + + await sql` + UPDATE system_metadata + SET value = jsonb_set( + value, + '{ffmpeg,targetAudioCodec}', + '"libopus"'::jsonb + ) + WHERE key = 'system-config' + AND value->'ffmpeg'->>'targetAudioCodec' = 'opus'; + `.execute(db); +} diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 12440fb263..cd61d7b45b 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -2571,6 +2571,50 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).not.toHaveBeenCalled(); }); + describe('should skip transcoding for accepted audio codecs with optimal policy if video is fine', () => { + const acceptedCodecs = [ + { codec: 'aac', probeStub: probeStub.audioStreamAac }, + { codec: 'mp3', probeStub: probeStub.audioStreamMp3 }, + { codec: 'opus', probeStub: probeStub.audioStreamOpus }, + ]; + + beforeEach(() => { + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { + targetVideoCodec: VideoCodec.Hevc, + transcode: TranscodePolicy.Optimal, + targetResolution: '1080p', + }, + }); + }); + + it.each(acceptedCodecs)('should skip $codec', async ({ probeStub }) => { + mocks.media.probe.mockResolvedValue(probeStub); + await sut.handleVideoConversion({ id: 'video-id' }); + expect(mocks.media.transcode).not.toHaveBeenCalled(); + }); + }); + + it('should use libopus audio encoder when target audio is opus', async () => { + mocks.media.probe.mockResolvedValue(probeStub.audioStreamAac); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { + targetAudioCodec: AudioCodec.Opus, + transcode: TranscodePolicy.All, + }, + }); + await sut.handleVideoConversion({ id: 'video-id' }); + expect(mocks.media.transcode).toHaveBeenCalledWith( + '/original/path.ext', + expect.any(String), + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining(['-c:a libopus']), + twoPass: false, + }), + ); + }); + it('should fail if hwaccel is enabled for an unsupported codec', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 1c93c9d7d3..b346906fc8 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -55,7 +55,7 @@ const updatedConfig = Object.freeze({ threads: 0, preset: 'ultrafast', targetAudioCodec: AudioCodec.Aac, - acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus], + acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus], targetResolution: '720', targetVideoCodec: VideoCodec.H264, acceptedVideoCodecs: [VideoCodec.H264], diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index b2ffb9ac8b..ce185305bd 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -1,3 +1,4 @@ +import { AUDIO_ENCODER } from 'src/constants'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { CQMode, ToneMapping, TranscodeHardwareAcceleration, TranscodeTarget, VideoCodec } from 'src/enum'; import { @@ -117,7 +118,7 @@ export class BaseConfig implements VideoCodecSWConfig { getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) { const videoCodec = [TranscodeTarget.All, TranscodeTarget.Video].includes(target) ? this.getVideoCodec() : 'copy'; - const audioCodec = [TranscodeTarget.All, TranscodeTarget.Audio].includes(target) ? this.getAudioCodec() : 'copy'; + const audioCodec = [TranscodeTarget.All, TranscodeTarget.Audio].includes(target) ? this.getAudioEncoder() : 'copy'; const options = [ `-c:v ${videoCodec}`, @@ -305,8 +306,8 @@ export class BaseConfig implements VideoCodecSWConfig { return [options]; } - getAudioCodec(): string { - return this.config.targetAudioCodec; + getAudioEncoder(): string { + return AUDIO_ENCODER[this.config.targetAudioCodec]; } getVideoCodec(): string { diff --git a/server/test/fixtures/media.stub.ts b/server/test/fixtures/media.stub.ts index f80ad70c8f..23617fcaf0 100644 --- a/server/test/fixtures/media.stub.ts +++ b/server/test/fixtures/media.stub.ts @@ -221,6 +221,14 @@ export const probeStub = { ...probeStubDefault, audioStreams: [{ index: 1, codecName: 'aac', bitrate: 100 }], }), + audioStreamMp3: Object.freeze({ + ...probeStubDefault, + audioStreams: [{ index: 1, codecName: 'mp3', bitrate: 100 }], + }), + audioStreamOpus: Object.freeze({ + ...probeStubDefault, + audioStreams: [{ index: 1, codecName: 'opus', bitrate: 100 }], + }), audioStreamUnknown: Object.freeze({ ...probeStubDefault, audioStreams: [ diff --git a/web/src/lib/components/admin-settings/FFmpegSettings.svelte b/web/src/lib/components/admin-settings/FFmpegSettings.svelte index 83596069f9..e062b616b3 100644 --- a/web/src/lib/components/admin-settings/FFmpegSettings.svelte +++ b/web/src/lib/components/admin-settings/FFmpegSettings.svelte @@ -115,7 +115,7 @@ options={[ { value: AudioCodec.Aac, text: 'AAC' }, { value: AudioCodec.Mp3, text: 'MP3' }, - { value: AudioCodec.Libopus, text: 'Opus' }, + { value: AudioCodec.Opus, text: 'Opus' }, { value: AudioCodec.PcmS16Le, text: 'PCM (16 bit)' }, ]} isEdited={!isEqual( @@ -174,7 +174,7 @@ options={[ { value: AudioCodec.Aac, text: 'aac' }, { value: AudioCodec.Mp3, text: 'mp3' }, - { value: AudioCodec.Libopus, text: 'opus' }, + { value: AudioCodec.Opus, text: 'opus' }, ]} name="acodec" isEdited={configToEdit.ffmpeg.targetAudioCodec !== config.ffmpeg.targetAudioCodec} From aaf34fa7d4fb2b47a9d29f53c2d41f40b2c13637 Mon Sep 17 00:00:00 2001 From: Aleksander Pejcic Date: Sat, 7 Mar 2026 19:40:43 +0100 Subject: [PATCH 095/150] feat(ml): enable openvino for cpu (#22948) * Enable OpenVINO CPU acceleration in Immich * Remove unnecessary debug log * Removing checking for device_ids for openvino since cpu will always be available * Find OpenVINOExecutionProvider index instead of assuming index 0 * Fix openvino tests * Fix failing test mock. OpenVINO expects provider options, but cuda provide doesn't so use that for mocked tests. * Support empty provider options in OrtSessions in which case ONNXRuntime will use its own defaults * Use OpenVINOExecutionProvider for test_sets_provider_options_kwarg * fix mock * simplify * unused variable --------- Co-authored-by: Aleksander Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> --- machine-learning/immich_ml/sessions/ort.py | 25 +++++++------ machine-learning/test_main.py | 43 +++++++++++++++++----- 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/machine-learning/immich_ml/sessions/ort.py b/machine-learning/immich_ml/sessions/ort.py index 5b728fce6f..bebd235970 100644 --- a/machine-learning/immich_ml/sessions/ort.py +++ b/machine-learning/immich_ml/sessions/ort.py @@ -64,14 +64,6 @@ class OrtSession: def _providers_default(self) -> list[str]: available_providers = set(ort.get_available_providers()) log.debug(f"Available ORT providers: {available_providers}") - if (openvino := "OpenVINOExecutionProvider") in available_providers: - device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids() - log.debug(f"Available OpenVINO devices: {device_ids}") - - gpu_devices = [device_id for device_id in device_ids if device_id.startswith("GPU")] - if not gpu_devices: - log.warning("No GPU device found in OpenVINO. Falling back to CPU.") - available_providers.remove(openvino) return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers] @property @@ -102,12 +94,19 @@ class OrtSession: "migraphx_fp16_enable": "1" if settings.rocm_precision == ModelPrecision.FP16 else "0", } case "OpenVINOExecutionProvider": - openvino_dir = self.model_path.parent / "openvino" - device = f"GPU.{settings.device_id}" + device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids() + # Check for available devices, preferring GPU over CPU + gpu_devices = [d for d in device_ids if d.startswith("GPU")] + if gpu_devices: + device_type = f"GPU.{settings.device_id}" + log.debug(f"OpenVINO: Using GPU device {device_type}") + else: + device_type = "CPU" + log.debug("OpenVINO: No GPU found, using CPU") options = { - "device_type": device, + "device_type": device_type, "precision": settings.openvino_precision.value, - "cache_dir": openvino_dir.as_posix(), + "cache_dir": (self.model_path.parent / "openvino").as_posix(), } case "CoreMLExecutionProvider": options = { @@ -139,12 +138,14 @@ class OrtSession: sess_options.enable_cpu_mem_arena = settings.model_arena # avoid thread contention between models + # Set inter_op threads if settings.model_inter_op_threads > 0: sess_options.inter_op_num_threads = settings.model_inter_op_threads # these defaults work well for CPU, but bottleneck GPU elif settings.model_inter_op_threads == 0 and self.providers == ["CPUExecutionProvider"]: sess_options.inter_op_num_threads = 1 + # Set intra_op threads if settings.model_intra_op_threads > 0: sess_options.intra_op_num_threads = settings.model_intra_op_threads elif settings.model_intra_op_threads == 0 and self.providers == ["CPUExecutionProvider"]: diff --git a/machine-learning/test_main.py b/machine-learning/test_main.py index a5cf1acc2e..0182c57c67 100644 --- a/machine-learning/test_main.py +++ b/machine-learning/test_main.py @@ -204,13 +204,6 @@ class TestOrtSession: assert session.providers == self.OV_EP - @pytest.mark.ov_device_ids(["CPU"]) - @pytest.mark.providers(OV_EP) - def test_avoids_openvino_if_gpu_not_available(self, providers: list[str], ov_device_ids: list[str]) -> None: - session = OrtSession("ViT-B-32__openai") - - assert session.providers == self.CPU_EP - @pytest.mark.providers(CUDA_EP_OUT_OF_ORDER) def test_sets_providers_in_correct_order(self, providers: list[str]) -> None: session = OrtSession("ViT-B-32__openai") @@ -256,7 +249,8 @@ class TestOrtSession: {"arena_extend_strategy": "kSameAsRequested"}, ] - def test_sets_provider_options_for_openvino(self) -> None: + @pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"]) + def test_sets_provider_options_for_openvino(self, ov_device_ids: list[str]) -> None: model_path = "/cache/ViT-B-32__openai/textual/model.onnx" os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" @@ -270,7 +264,8 @@ class TestOrtSession: } ] - def test_sets_openvino_to_fp16_if_enabled(self, mocker: MockerFixture) -> None: + @pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"]) + def test_sets_openvino_to_fp16_if_enabled(self, ov_device_ids: list[str], mocker: MockerFixture) -> None: model_path = "/cache/ViT-B-32__openai/textual/model.onnx" os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" mocker.patch.object(settings, "openvino_precision", ModelPrecision.FP16) @@ -285,6 +280,19 @@ class TestOrtSession: } ] + @pytest.mark.ov_device_ids(["CPU"]) + def test_sets_provider_options_for_openvino_cpu(self, ov_device_ids: list[str]) -> None: + model_path = "/cache/ViT-B-32__openai/model.onnx" + session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"]) + + assert session.provider_options == [ + { + "device_type": "CPU", + "precision": "FP32", + "cache_dir": "/cache/ViT-B-32__openai/openvino", + } + ] + def test_sets_provider_options_for_cuda(self) -> None: os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" @@ -341,6 +349,23 @@ class TestOrtSession: assert session.sess_options.inter_op_num_threads == 1 assert session.sess_options.intra_op_num_threads == 2 + @pytest.mark.ov_device_ids(["CPU"]) + def test_sets_default_sess_options_if_openvino_cpu(self, ov_device_ids: list[str]) -> None: + model_path = "/cache/ViT-B-32__openai/model.onnx" + session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"]) + + assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL + assert session.sess_options.inter_op_num_threads == 0 + assert session.sess_options.intra_op_num_threads == 0 + + @pytest.mark.ov_device_ids(["GPU.0", "CPU"]) + def test_sets_default_sess_options_if_openvino_gpu(self, ov_device_ids: list[str]) -> None: + model_path = "/cache/ViT-B-32__openai/model.onnx" + session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"]) + + assert session.sess_options.inter_op_num_threads == 0 + assert session.sess_options.intra_op_num_threads == 0 + def test_sets_default_sess_options_does_not_set_threads_if_non_cpu_and_default_threads(self) -> None: session = OrtSession("ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"]) From 7a83baaf272752c54f5efa08b31a68f0b31bccff Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Sun, 8 Mar 2026 03:37:41 -0400 Subject: [PATCH 096/150] feat: responsive video duration in thumbnail (#26770) --- .../components/assets/thumbnail/thumbnail.svelte | 14 +++++++------- .../assets/thumbnail/video-thumbnail.svelte | 10 +++++++--- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 6a786c6417..64b5a835ed 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -334,27 +334,27 @@ {#if !authManager.isSharedLink && asset.isFavorite} -
+
{/if} {#if !!assetOwner} -
-

+

+

{assetOwner.name}

{/if} {#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive} -
+
{/if} {#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR} -
+
@@ -362,7 +362,7 @@ {/if} {#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')} -
+
@@ -374,7 +374,7 @@
diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte index 9d3a6bfcb6..b4772cc1c4 100644 --- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte @@ -89,10 +89,10 @@ {/if}
{#if showTime} - +
diff --git a/web/src/lib/components/shared-components/tag-pill.svelte b/web/src/lib/components/shared-components/tag-pill.svelte new file mode 100644 index 0000000000..43148c5954 --- /dev/null +++ b/web/src/lib/components/shared-components/tag-pill.svelte @@ -0,0 +1,31 @@ + + +
+ +

+ {label} +

+
+ + +
diff --git a/web/src/lib/modals/AssetTagModal.svelte b/web/src/lib/modals/AssetTagModal.svelte index dbd5bdb118..5097be51aa 100644 --- a/web/src/lib/modals/AssetTagModal.svelte +++ b/web/src/lib/modals/AssetTagModal.svelte @@ -2,12 +2,13 @@ import { eventManager } from '$lib/managers/event-manager.svelte'; import { tagAssets } from '$lib/utils/asset-utils'; import { getAllTags, upsertTags, type TagResponseDto } from '@immich/sdk'; - import { FormModal, Icon } from '@immich/ui'; - import { mdiClose, mdiTag } from '@mdi/js'; + import { FormModal } from '@immich/ui'; + import { mdiTag } from '@mdi/js'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import { SvelteSet } from 'svelte/reactivity'; import Combobox, { type ComboBoxOption } from '../components/shared-components/combobox.svelte'; + import TagPill from '../components/shared-components/tag-pill.svelte'; interface Props { onClose: (updated?: boolean) => void; @@ -81,24 +82,7 @@ {#each selectedIds as tagId (tagId)} {@const tag = tagMap[tagId]} {#if tag} -
- -

- {tag.value} -

-
- - -
+ handleRemove(tagId)} /> {/if} {/each}
From 8e50d25f4559d3a185ffde1823de041b51e32515 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Tue, 10 Mar 2026 11:42:02 -0400 Subject: [PATCH 106/150] feat(web): animate zoom toggle with cubicOut easing (#26731) --- web/src/lib/actions/zoom-image.ts | 11 ++++--- .../asset-viewer/photo-viewer.svelte | 3 +- .../managers/asset-viewer-manager.svelte.ts | 31 +++++++++++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index 6288daa380..602ed9bd63 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -9,14 +9,15 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)), ]; - const stopIfDisabled = (event: Event) => { + const onInteractionStart = (event: Event) => { if (options?.disabled) { event.stopImmediatePropagation(); } + assetViewerManager.cancelZoomAnimation(); }; - node.addEventListener('wheel', stopIfDisabled, { capture: true }); - node.addEventListener('pointerdown', stopIfDisabled, { capture: true }); + node.addEventListener('wheel', onInteractionStart, { capture: true }); + node.addEventListener('pointerdown', onInteractionStart, { capture: true }); node.style.overflow = 'visible'; return { @@ -27,8 +28,8 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea for (const unsubscribe of unsubscribes) { unsubscribe(); } - node.removeEventListener('wheel', stopIfDisabled, { capture: true }); - node.removeEventListener('pointerdown', stopIfDisabled, { capture: true }); + node.removeEventListener('wheel', onInteractionStart, { capture: true }); + node.removeEventListener('pointerdown', onInteractionStart, { capture: true }); zoomInstance.cleanup(); }, }; diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index c3874a81e9..411c9f3ee3 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -106,7 +106,8 @@ }; const onZoom = () => { - assetViewerManager.zoom = assetViewerManager.zoom > 1 ? 1 : 2; + const targetZoom = assetViewerManager.zoom > 1 ? 1 : 2; + assetViewerManager.animatedZoom(targetZoom); }; const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow); diff --git a/web/src/lib/managers/asset-viewer-manager.svelte.ts b/web/src/lib/managers/asset-viewer-manager.svelte.ts index 36047d4690..0facbcdf47 100644 --- a/web/src/lib/managers/asset-viewer-manager.svelte.ts +++ b/web/src/lib/managers/asset-viewer-manager.svelte.ts @@ -2,6 +2,7 @@ import { canCopyImageToClipboard } from '$lib/utils/asset-utils'; import { BaseEventManager } from '$lib/utils/base-event-manager.svelte'; import { PersistedLocalStorage } from '$lib/utils/persisted'; import type { ZoomImageWheelState } from '@zoom-image/core'; +import { cubicOut } from 'svelte/easing'; const isShowDetailPanel = new PersistedLocalStorage('asset-viewer-state', false); @@ -21,6 +22,7 @@ export type Events = { export class AssetViewerManager extends BaseEventManager { #zoomState = $state(createDefaultZoomState()); + #animationFrameId: number | null = null; imgRef = $state(); isShowActivityPanel = $state(false); @@ -45,6 +47,7 @@ export class AssetViewerManager extends BaseEventManager { } set zoom(zoom: number) { + this.cancelZoomAnimation(); this.zoomState = { ...this.zoomState, currentZoom: zoom }; } @@ -69,7 +72,35 @@ export class AssetViewerManager extends BaseEventManager { this.#zoomState = state; } + cancelZoomAnimation() { + if (this.#animationFrameId !== null) { + cancelAnimationFrame(this.#animationFrameId); + this.#animationFrameId = null; + } + } + + animatedZoom(targetZoom: number, duration = 300) { + this.cancelZoomAnimation(); + + const startZoom = this.#zoomState.currentZoom; + const startTime = performance.now(); + + const frame = (currentTime: number) => { + const elapsed = currentTime - startTime; + const linearProgress = Math.min(elapsed / duration, 1); + const easedProgress = cubicOut(linearProgress); + const interpolatedZoom = startZoom + (targetZoom - startZoom) * easedProgress; + + this.zoomState = { ...this.#zoomState, currentZoom: interpolatedZoom }; + + this.#animationFrameId = linearProgress < 1 ? requestAnimationFrame(frame) : null; + }; + + this.#animationFrameId = requestAnimationFrame(frame); + } + resetZoomState() { + this.cancelZoomAnimation(); this.zoomState = createDefaultZoomState(); } From f79c8cf1c164a05593bedaf0606f5553ab6fbdb0 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:55:31 +0000 Subject: [PATCH 107/150] feat(mobile): consolidate video controls (#26673) Videos have recently been changed to support zooming, but this can make the controls in the centre of the screen unergonomic as they will either stay in the centre when dismissing, or stick to the video when zooming. Neither is great. We should align the behaviour with other apps which has the play/pause toggle at the bottom of the screen with the seeker bar instead. Co-authored-by: Alex --- mobile/lib/constants/colors.dart | 2 +- .../asset_viewer/asset_page.widget.dart | 6 - .../asset_viewer/bottom_bar.widget.dart | 30 +++-- .../asset_viewer/video_viewer.widget.dart | 40 +++--- .../video_viewer_controls.widget.dart | 114 ------------------ .../viewer_top_app_bar.widget.dart | 34 ++++-- .../asset_viewer/asset_viewer.provider.dart | 10 +- .../asset_viewer/video_player_provider.dart | 77 +++++++++--- mobile/lib/providers/cast.provider.dart | 10 ++ .../asset_viewer/formatted_duration.dart | 19 --- .../widgets/asset_viewer/video_controls.dart | 110 +++++++++++++++-- .../widgets/asset_viewer/video_position.dart | 110 ----------------- 12 files changed, 239 insertions(+), 323 deletions(-) delete mode 100644 mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart delete mode 100644 mobile/lib/widgets/asset_viewer/formatted_duration.dart delete mode 100644 mobile/lib/widgets/asset_viewer/video_position.dart diff --git a/mobile/lib/constants/colors.dart b/mobile/lib/constants/colors.dart index 069ed519cf..e39480de32 100644 --- a/mobile/lib/constants/colors.dart +++ b/mobile/lib/constants/colors.dart @@ -7,6 +7,6 @@ const String defaultColorPresetName = "indigo"; const Color immichBrandColorLight = Color(0xFF4150AF); const Color immichBrandColorDark = Color(0xFFACCBFA); -const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255); +const Color whiteOpacity75 = Color.fromRGBO(255, 255, 255, 0.75); const Color red400 = Color(0xFFEF5350); const Color grey200 = Color(0xFFEEEEEE); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index abb7b779fe..0934536471 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -19,7 +19,6 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; @@ -248,11 +247,6 @@ class _AssetPageState extends ConsumerState { if (scaleState != PhotoViewScaleState.initial) { if (_dragStart == null) _viewer.setControls(false); - - final heroTag = ref.read(assetViewerProvider).currentAsset?.heroTag; - if (heroTag != null) { - ref.read(videoPlayerProvider(heroTag).notifier).pause(); - } return; } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 113c55932f..cc171f4490 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -61,15 +61,27 @@ class ViewerBottomBar extends ConsumerWidget { ), ), child: Container( - color: Colors.black.withAlpha(125), - padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag), - if (!isReadonlyModeEnabled) - Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), - ], + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [Colors.black45, Colors.black12, Colors.transparent], + stops: [0.0, 0.7, 1.0], + ), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.only(top: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag), + if (!isReadonlyModeEnabled) + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), + ], + ), + ), ), ), ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index ecfe0b3ddc..9285c01c41 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -10,7 +10,6 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; @@ -186,11 +185,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg final source = await _videoSource; if (source == null || !mounted) return; - unawaited( - nc.loadVideoSource(source).catchError((error) { - _log.severe('Error loading video source: $error'); - }), - ); + await _notifier.load(source); final loopVideo = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.loopVideo); await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo); await _notifier.setVolume(1); @@ -213,21 +208,28 @@ class _NativeVideoViewerState extends ConsumerState with Widg @override Widget build(BuildContext context) { - // Prevent the provider from being disposed whilst the widget is alive. - ref.listen(videoPlayerProvider(widget.asset.heroTag), (_, __) {}); - final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); + final status = ref.watch(videoPlayerProvider(widget.asset.heroTag).select((v) => v.status)); - return Stack( - children: [ - Center(child: widget.image), - if (!isCasting) - Visibility.maintain( - visible: _isVideoReady, - child: NativeVideoPlayerView(onViewReady: _initController), - ), - if (widget.showControls) Center(child: VideoViewerControls(asset: widget.asset)), - ], + return IgnorePointer( + child: Stack( + children: [ + Center(child: widget.image), + if (!isCasting) ...[ + Visibility.maintain( + visible: _isVideoReady, + child: NativeVideoPlayerView(onViewReady: _initController), + ), + Center( + child: AnimatedOpacity( + opacity: status == VideoPlaybackStatus.buffering ? 1.0 : 0.0, + duration: const Duration(milliseconds: 400), + child: const CircularProgressIndicator(), + ), + ), + ], + ], + ), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart deleted file mode 100644 index e079f666ec..0000000000 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/models/cast/cast_manager_state.dart'; -import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; -import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/utils/hooks/timer_hook.dart'; -import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; -import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; - -class VideoViewerControls extends HookConsumerWidget { - final BaseAsset asset; - final Duration hideTimerDuration; - - const VideoViewerControls({super.key, required this.asset, this.hideTimerDuration = const Duration(seconds: 5)}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final videoPlayerName = asset.heroTag; - final assetIsVideo = asset.isVideo; - final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls && !s.showingDetails)); - final status = ref.watch(videoPlayerProvider(videoPlayerName).select((value) => value.status)); - - final cast = ref.watch(castProvider); - - // A timer to hide the controls - final hideTimer = useTimer(hideTimerDuration, () { - if (!context.mounted) { - return; - } - final status = ref.read(videoPlayerProvider(videoPlayerName)).status; - - // Do not hide on paused - if (status != VideoPlaybackStatus.paused && status != VideoPlaybackStatus.completed && assetIsVideo) { - ref.read(assetViewerProvider.notifier).setControls(false); - } - }); - final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting; - - /// Shows the controls and starts the timer to hide them - void showControlsAndStartHideTimer() { - hideTimer.reset(); - ref.read(assetViewerProvider.notifier).setControls(true); - } - - // When playback starts, reset the hide timer - ref.listen(videoPlayerProvider(videoPlayerName).select((v) => v.status), (previous, next) { - if (next == VideoPlaybackStatus.playing) { - hideTimer.reset(); - } - }); - - /// Toggles between playing and pausing depending on the state of the video - void togglePlay() { - showControlsAndStartHideTimer(); - - if (cast.isCasting) { - switch (cast.castState) { - case CastState.playing: - ref.read(castProvider.notifier).pause(); - case CastState.paused: - ref.read(castProvider.notifier).play(); - default: - } - return; - } - - final notifier = ref.read(videoPlayerProvider(videoPlayerName).notifier); - switch (status) { - case VideoPlaybackStatus.playing: - notifier.pause(); - case VideoPlaybackStatus.completed: - notifier.restart(); - default: - notifier.play(); - } - } - - void toggleControlsVisibility() { - if (showBuffering) return; - - if (showControls) { - ref.read(assetViewerProvider.notifier).setControls(false); - } else { - showControlsAndStartHideTimer(); - } - } - - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: toggleControlsVisibility, - child: IgnorePointer( - ignoring: !showControls, - child: Stack( - children: [ - if (showBuffering) - const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400))) - else - CenterPlayButton( - backgroundColor: Colors.black54, - iconColor: Colors.white, - isFinished: status == VideoPlaybackStatus.completed, - isPlaying: - status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing), - show: assetIsVideo && showControls, - onPressed: togglePlay, - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index 4ba4152a8d..397cd98ace 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -75,17 +75,29 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { child: AnimatedOpacity( opacity: opacity, duration: Durations.short2, - child: AppBar( - backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5), - leading: const _AppBarBackButton(), - iconTheme: const IconThemeData(size: 22, color: Colors.white), - actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), - shape: const Border(), - actions: showingDetails || isReadonlyModeEnabled - ? null - : isInLockedView - ? lockedViewActions - : actions, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: showingDetails + ? null + : const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black45, Colors.black12, Colors.transparent], + stops: [0.0, 0.7, 1.0], + ), + ), + child: AppBar( + backgroundColor: Colors.transparent, + leading: const _AppBarBackButton(), + iconTheme: const IconThemeData(size: 22, color: Colors.white), + actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), + shape: const Border(), + actions: showingDetails || isReadonlyModeEnabled + ? null + : isInLockedView + ? lockedViewActions + : actions, + ), ), ), ); diff --git a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart index 785dfd1e4c..19c92e7c96 100644 --- a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart +++ b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart @@ -100,11 +100,11 @@ class AssetViewerStateNotifier extends Notifier { return; } state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls); - if (showing) { - final heroTag = state.currentAsset?.heroTag; - if (heroTag != null) { - ref.read(videoPlayerProvider(heroTag).notifier).pause(); - } + + final heroTag = state.currentAsset?.heroTag; + if (heroTag != null) { + final notifier = ref.read(videoPlayerProvider(heroTag).notifier); + showing ? notifier.hold() : notifier.release(); } } diff --git a/mobile/lib/providers/asset_viewer/video_player_provider.dart b/mobile/lib/providers/asset_viewer/video_player_provider.dart index 0ca3bf4f74..a4a8bd1762 100644 --- a/mobile/lib/providers/asset_viewer/video_player_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_provider.dart @@ -44,10 +44,7 @@ class VideoPlayerNotifier extends StateNotifier { NativeVideoPlayerController? _controller; Timer? _bufferingTimer; Timer? _seekTimer; - - void attachController(NativeVideoPlayerController controller) { - _controller = controller; - } + VideoPlaybackStatus? _holdStatus; @override void dispose() { @@ -59,6 +56,19 @@ class VideoPlayerNotifier extends StateNotifier { super.dispose(); } + void attachController(NativeVideoPlayerController controller) { + _controller = controller; + } + + Future load(VideoSource source) async { + _startBufferingTimer(); + try { + await _controller?.loadVideoSource(source); + } catch (e) { + _log.severe('Error loading video source: $e'); + } + } + Future pause() async { if (_controller == null) return; @@ -94,16 +104,50 @@ class VideoPlayerNotifier extends StateNotifier { } void seekTo(Duration position) { - if (_controller == null) return; + if (_controller == null || state.position == position) return; state = state.copyWith(position: position); - _seekTimer?.cancel(); - _seekTimer = Timer(const Duration(milliseconds: 100), () { - _controller?.seekTo(position.inMilliseconds); + if (_seekTimer?.isActive ?? false) return; + + _seekTimer = Timer(const Duration(milliseconds: 150), () { + _controller?.seekTo(state.position.inMilliseconds); }); } + void toggle() { + _holdStatus = null; + + switch (state.status) { + case VideoPlaybackStatus.paused: + play(); + case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering: + pause(); + case VideoPlaybackStatus.completed: + restart(); + } + } + + /// Pauses playback and preserves the current status for later restoration. + void hold() { + if (_holdStatus != null) return; + + _holdStatus = state.status; + pause(); + } + + /// Restores playback to the status before [hold] was called. + void release() { + final status = _holdStatus; + _holdStatus = null; + + switch (status) { + case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering: + play(); + default: + } + } + Future restart() async { seekTo(Duration.zero); await play(); @@ -149,13 +193,12 @@ class VideoPlayerNotifier extends StateNotifier { final position = Duration(milliseconds: playbackInfo.position); if (state.position == position) return; - if (state.status == VideoPlaybackStatus.buffering) { - state = state.copyWith(position: position, status: VideoPlaybackStatus.playing); - } else { - state = state.copyWith(position: position); - } + if (state.status == VideoPlaybackStatus.playing) _startBufferingTimer(); - _startBufferingTimer(); + state = state.copyWith( + position: position, + status: state.status == VideoPlaybackStatus.buffering ? VideoPlaybackStatus.playing : null, + ); } void onNativeStatusChanged() { @@ -173,9 +216,7 @@ class VideoPlayerNotifier extends StateNotifier { onNativePlaybackEnded(); } - if (state.status != newStatus) { - state = state.copyWith(status: newStatus); - } + if (state.status != newStatus) state = state.copyWith(status: newStatus); } void onNativePlaybackEnded() { @@ -186,7 +227,7 @@ class VideoPlayerNotifier extends StateNotifier { void _startBufferingTimer() { _bufferingTimer?.cancel(); _bufferingTimer = Timer(const Duration(seconds: 3), () { - if (mounted && state.status == VideoPlaybackStatus.playing) { + if (mounted && state.status != VideoPlaybackStatus.completed) { state = state.copyWith(status: VideoPlaybackStatus.buffering); } }); diff --git a/mobile/lib/providers/cast.provider.dart b/mobile/lib/providers/cast.provider.dart index 1cd5ded487..fea95f42aa 100644 --- a/mobile/lib/providers/cast.provider.dart +++ b/mobile/lib/providers/cast.provider.dart @@ -91,6 +91,16 @@ class CastNotifier extends StateNotifier { return discovered; } + void toggle() { + switch (state.castState) { + case CastState.playing: + pause(); + case CastState.paused: + play(); + default: + } + } + void play() { _gCastService.play(); } diff --git a/mobile/lib/widgets/asset_viewer/formatted_duration.dart b/mobile/lib/widgets/asset_viewer/formatted_duration.dart deleted file mode 100644 index fbcc8e6482..0000000000 --- a/mobile/lib/widgets/asset_viewer/formatted_duration.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:immich_mobile/extensions/duration_extensions.dart'; - -class FormattedDuration extends StatelessWidget { - final Duration data; - const FormattedDuration(this.data, {super.key}); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: data.inHours > 0 ? 70 : 60, // use a fixed width to prevent jitter - child: Text( - data.format(), - style: const TextStyle(fontSize: 14.0, color: Colors.white, fontWeight: FontWeight.w500), - textAlign: TextAlign.center, - ), - ); - } -} diff --git a/mobile/lib/widgets/asset_viewer/video_controls.dart b/mobile/lib/widgets/asset_viewer/video_controls.dart index 381388d8d2..29e877b3dc 100644 --- a/mobile/lib/widgets/asset_viewer/video_controls.dart +++ b/mobile/lib/widgets/asset_viewer/video_controls.dart @@ -1,22 +1,110 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/widgets/asset_viewer/video_position.dart'; +import 'package:immich_mobile/constants/colors.dart'; +import 'package:immich_mobile/models/cast/cast_manager_state.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; +import 'package:immich_mobile/providers/cast.provider.dart'; +import 'package:immich_mobile/utils/hooks/timer_hook.dart'; +import 'package:immich_mobile/extensions/duration_extensions.dart'; +import 'package:immich_mobile/widgets/asset_viewer/animated_play_pause.dart'; -/// The video controls for the [videoPlayerProvider] -class VideoControls extends ConsumerWidget { +class VideoControls extends HookConsumerWidget { final String videoPlayerName; const VideoControls({super.key, required this.videoPlayerName}); + void _toggle(WidgetRef ref, bool isCasting) { + if (isCasting) { + ref.read(castProvider.notifier).toggle(); + } else { + ref.read(videoPlayerProvider(videoPlayerName).notifier).toggle(); + } + } + + void _onSeek(WidgetRef ref, bool isCasting, double value) { + final seekTo = Duration(microseconds: value.toInt()); + + if (isCasting) { + ref.read(castProvider.notifier).seekTo(seekTo); + return; + } + + ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekTo); + } + @override Widget build(BuildContext context, WidgetRef ref) { - final isPortrait = context.orientation == Orientation.portrait; - return isPortrait - ? VideoPosition(videoPlayerName: videoPlayerName) - : Padding( - padding: const EdgeInsets.symmetric(horizontal: 60.0), - child: VideoPosition(videoPlayerName: videoPlayerName), - ); + final provider = videoPlayerProvider(videoPlayerName); + final cast = ref.watch(castProvider); + final isCasting = cast.isCasting; + + final (position, duration) = isCasting + ? ref.watch(castProvider.select((c) => (c.currentTime, c.duration))) + : ref.watch(provider.select((v) => (v.position, v.duration))); + + final videoStatus = ref.watch(provider.select((v) => v.status)); + final isPlaying = isCasting + ? cast.castState == CastState.playing + : videoStatus == VideoPlaybackStatus.playing || videoStatus == VideoPlaybackStatus.buffering; + final isFinished = !isCasting && videoStatus == VideoPlaybackStatus.completed; + + final hideTimer = useTimer(const Duration(seconds: 5), () { + if (!context.mounted) return; + if (ref.read(provider).status == VideoPlaybackStatus.playing) { + ref.read(assetViewerProvider.notifier).setControls(false); + } + }); + + ref.listen(provider.select((v) => v.status), (_, __) => hideTimer.reset()); + + final notifier = ref.read(provider.notifier); + final isLoaded = duration != Duration.zero; + + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + spacing: 16, + children: [ + Row( + children: [ + IconButton( + iconSize: 32, + padding: const EdgeInsets.all(12), + constraints: const BoxConstraints(), + icon: isFinished + ? const Icon(Icons.replay, color: Colors.white, size: 32) + : AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying), + onPressed: () => _toggle(ref, isCasting), + ), + const Spacer(), + Text( + "${position.format()} / ${duration.format()}", + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + const SizedBox(width: 16), + ], + ), + Slider( + value: min(position.inMicroseconds.toDouble(), duration.inMicroseconds.toDouble()), + min: 0, + max: max(duration.inMicroseconds.toDouble(), 1), + thumbColor: Colors.white, + activeColor: Colors.white, + inactiveColor: whiteOpacity75, + padding: EdgeInsets.zero, + onChangeStart: (_) => notifier.hold(), + onChangeEnd: (_) => notifier.release(), + onChanged: isLoaded ? (value) => _onSeek(ref, isCasting, value) : null, + ), + ], + ), + ); } } diff --git a/mobile/lib/widgets/asset_viewer/video_position.dart b/mobile/lib/widgets/asset_viewer/video_position.dart deleted file mode 100644 index cbcbdb88e7..0000000000 --- a/mobile/lib/widgets/asset_viewer/video_position.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/colors.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; -import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart'; - -class VideoPosition extends HookConsumerWidget { - final String videoPlayerName; - - const VideoPosition({super.key, required this.videoPlayerName}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isCasting = ref.watch(castProvider).isCasting; - - final (position, duration) = isCasting - ? ref.watch(castProvider.select((c) => (c.currentTime, c.duration))) - : ref.watch(videoPlayerProvider(videoPlayerName).select((v) => (v.position, v.duration))); - - final wasPlaying = useRef(true); - return duration == Duration.zero - ? const _VideoPositionPlaceholder() - : Column( - children: [ - Padding( - // align with slider's inherent padding - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [FormattedDuration(position), FormattedDuration(duration)], - ), - ), - Row( - children: [ - Expanded( - child: Slider( - value: min(position.inMicroseconds / duration.inMicroseconds * 100, 100), - min: 0, - max: 100, - thumbColor: Colors.white, - activeColor: Colors.white, - inactiveColor: whiteOpacity75, - onChangeStart: (value) { - final status = ref.read(videoPlayerProvider(videoPlayerName)).status; - wasPlaying.value = status != VideoPlaybackStatus.paused; - ref.read(videoPlayerProvider(videoPlayerName).notifier).pause(); - }, - onChangeEnd: (value) { - if (wasPlaying.value) { - ref.read(videoPlayerProvider(videoPlayerName).notifier).play(); - } - }, - onChanged: (value) { - final seekToDuration = (duration * (value / 100.0)); - - if (isCasting) { - ref.read(castProvider.notifier).seekTo(seekToDuration); - return; - } - - ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekToDuration); - }, - ), - ), - ], - ), - ], - ); - } -} - -class _VideoPositionPlaceholder extends StatelessWidget { - const _VideoPositionPlaceholder(); - - static void _onChangedDummy(_) {} - - @override - Widget build(BuildContext context) { - return const Column( - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 12.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [FormattedDuration(Duration.zero), FormattedDuration(Duration.zero)], - ), - ), - Row( - children: [ - Expanded( - child: Slider( - value: 0.0, - min: 0, - max: 100, - thumbColor: Colors.white, - activeColor: Colors.white, - inactiveColor: whiteOpacity75, - onChanged: _onChangedDummy, - ), - ), - ], - ), - ], - ); - } -} From 56b8e1b8a920a3419627431d88414e0a02cf1352 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:34:20 +0100 Subject: [PATCH 108/150] chore(deps): update docker.io/valkey/valkey:9 docker digest to 3eeb097 (#26807) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.dev.yml | 2 +- docker/docker-compose.prod.yml | 2 +- docker/docker-compose.rootless.yml | 2 +- docker/docker-compose.yml | 2 +- e2e/docker-compose.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index c132c224aa..6e435b3c6b 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -155,7 +155,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d + image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 healthcheck: test: redis-cli ping || exit 1 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 3a5f781d5e..4d07794fea 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -56,7 +56,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d + image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docker/docker-compose.rootless.yml b/docker/docker-compose.rootless.yml index 7cbec36eb6..eb41bf9bca 100644 --- a/docker/docker-compose.rootless.yml +++ b/docker/docker-compose.rootless.yml @@ -61,7 +61,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d + image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 user: '1000:1000' security_opt: - no-new-privileges:true diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f016955b32..4437087d24 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -49,7 +49,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d + image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 7f117ee37c..957de4698e 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -44,7 +44,7 @@ services: redis: container_name: immich-e2e-redis - image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d + image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6 healthcheck: test: redis-cli ping || exit 1 From 45eff1c663ae8098418e2c2d209dedeb80c6ff27 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:43:30 +0100 Subject: [PATCH 109/150] fix(web): prevent unrelated assets from appearing in tag view (#26816) --- .../timeline-manager/timeline-manager.svelte.spec.ts | 11 +++++++++++ .../timeline-manager/timeline-manager.svelte.ts | 1 + web/src/lib/managers/timeline-manager/types.ts | 1 + web/src/lib/utils/timeline-util.ts | 1 + web/src/test-data/factories/asset-factory.ts | 1 + 5 files changed, 15 insertions(+) diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts index 8e31f28138..8addc173c4 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts @@ -286,6 +286,17 @@ describe('TimelineManager', () => { expect(timelineManager.assetCount).toEqual(1); }); + it('ignores new assets that do not match the tag filter', async () => { + await timelineManager.updateOptions({ tagId: 'tag-1' }); + + const matching = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ tags: ['tag-1'] })); + const unrelated = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ tags: ['tag-2'] })); + + timelineManager.upsertAssets([matching, unrelated]); + + expect(await getAssets(timelineManager)).toEqual([matching]); + }); + // disabled due to the wasm Justified Layout import it('ignores trashed assets when isTrashed is true', async () => { const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: false })); diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index 019290a5c9..38c593bd00 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -596,6 +596,7 @@ export class TimelineManager extends VirtualScrollManager { isMismatched(this.#options.visibility, asset.visibility) || isMismatched(this.#options.isFavorite, asset.isFavorite) || isMismatched(this.#options.isTrashed, asset.isTrashed) || + (this.#options.tagId && asset.tags && !asset.tags.includes(this.#options.tagId)) || (this.#options.assetFilter !== undefined && !this.#options.assetFilter.has(asset.id)) ); } diff --git a/web/src/lib/managers/timeline-manager/types.ts b/web/src/lib/managers/timeline-manager/types.ts index d528bfbdff..c7b0d226d2 100644 --- a/web/src/lib/managers/timeline-manager/types.ts +++ b/web/src/lib/managers/timeline-manager/types.ts @@ -18,6 +18,7 @@ export type Direction = 'earlier' | 'later'; export type TimelineAsset = { id: string; ownerId: string; + tags?: string[]; ratio: number; thumbhash: string | null; localDateTime: TimelineDateTime; diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index deccdd7d6e..d7dc5d6aa4 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -170,6 +170,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): return { id: assetResponse.id, ownerId: assetResponse.ownerId, + tags: assetResponse.tags?.map((tag) => tag.id), ratio, thumbhash: assetResponse.thumbhash, localDateTime, diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index 00dd588243..f3d2e80747 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -37,6 +37,7 @@ export const timelineAssetFactory = Sync.makeFactory({ id: Sync.each(() => faker.string.uuid()), ratio: Sync.each((i) => 0.2 + ((i * 0.618_034) % 3.8)), // deterministic random float between 0.2 and 4.0 ownerId: Sync.each(() => faker.string.uuid()), + tags: [], thumbhash: Sync.each(() => faker.string.alphanumeric(28)), localDateTime: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())), fileCreatedAt: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())), From 22b43bf4d9b330b865dfe5376272e225f63c02f3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:46:21 +0000 Subject: [PATCH 110/150] chore(deps): update dependency @types/node to ^24.11.0 (#26808) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package.json | 2 +- e2e/package.json | 2 +- open-api/typescript-sdk/package.json | 2 +- pnpm-lock.yaml | 8 ++++---- server/package.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cli/package.json b/cli/package.json index aed8be5bba..d6202e6a1a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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.14", + "@types/node": "^24.11.0", "@vitest/coverage-v8": "^4.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", diff --git a/e2e/package.json b/e2e/package.json index 962cf86ea3..34aedf3c46 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -32,7 +32,7 @@ "@playwright/test": "^1.44.1", "@socket.io/component-emitter": "^3.1.2", "@types/luxon": "^3.4.2", - "@types/node": "^24.10.14", + "@types/node": "^24.11.0", "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", "@types/supertest": "^6.0.2", diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index cdf2ef19dd..89b48d1d13 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^24.10.14", + "@types/node": "^24.11.0", "typescript": "^5.3.3" }, "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63ad290f7a..8765114881 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,7 +63,7 @@ importers: specifier: ^4.13.1 version: 4.13.4 '@types/node': - specifier: ^24.10.14 + specifier: ^24.11.0 version: 24.11.0 '@vitest/coverage-v8': specifier: ^4.0.0 @@ -220,7 +220,7 @@ importers: specifier: ^3.4.2 version: 3.7.1 '@types/node': - specifier: ^24.10.14 + specifier: ^24.11.0 version: 24.11.0 '@types/pg': specifier: ^8.15.1 @@ -323,7 +323,7 @@ importers: version: 1.2.0 devDependencies: '@types/node': - specifier: ^24.10.14 + specifier: ^24.11.0 version: 24.11.0 typescript: specifier: ^5.3.3 @@ -645,7 +645,7 @@ importers: specifier: ^2.0.0 version: 2.0.0 '@types/node': - specifier: ^24.10.14 + specifier: ^24.11.0 version: 24.11.0 '@types/nodemailer': specifier: ^7.0.0 diff --git a/server/package.json b/server/package.json index 943f630687..a4aeec2951 100644 --- a/server/package.json +++ b/server/package.json @@ -136,7 +136,7 @@ "@types/luxon": "^3.6.2", "@types/mock-fs": "^4.13.1", "@types/multer": "^2.0.0", - "@types/node": "^24.10.14", + "@types/node": "^24.11.0", "@types/nodemailer": "^7.0.0", "@types/picomatch": "^4.0.0", "@types/pngjs": "^6.0.5", From 1a4c5d73acb491d2877caf42abe6dee868248d40 Mon Sep 17 00:00:00 2001 From: Andreas Heinz Date: Tue, 10 Mar 2026 23:53:38 +0100 Subject: [PATCH 111/150] feat(web): add shortcut "p" to open/close the face tag box (#26826) --- .../components/asset-viewer/asset-viewer.svelte | 4 ++-- web/src/lib/modals/ShortcutsModal.svelte | 1 + web/src/lib/services/asset.service.ts | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 2a75ca4e83..cf1ad4be5a 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -396,7 +396,7 @@ ocrManager.hasOcrData, ); - const { Tag } = $derived(getAssetActions($t, asset)); + const { Tag, TagPeople } = $derived(getAssetActions($t, asset)); const showDetailPanel = $derived( asset.hasMetadata && $slideshowState === SlideshowState.None && @@ -405,7 +405,7 @@ ); - + diff --git a/web/src/lib/modals/ShortcutsModal.svelte b/web/src/lib/modals/ShortcutsModal.svelte index c233548878..56c666a17a 100644 --- a/web/src/lib/modals/ShortcutsModal.svelte +++ b/web/src/lib/modals/ShortcutsModal.svelte @@ -40,6 +40,7 @@ { key: ['s'], action: $t('stack_selected_photos') }, { key: ['l'], action: $t('add_to_album') }, { key: ['t'], action: $t('tag_assets') }, + { key: ['p'], action: $t('tag_people') }, { key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') }, { key: ['⇧', 'd'], action: $t('download') }, { key: ['Space'], action: $t('play_or_pause_video') }, diff --git a/web/src/lib/services/asset.service.ts b/web/src/lib/services/asset.service.ts index 530bbc70f1..5d7ae07684 100644 --- a/web/src/lib/services/asset.service.ts +++ b/web/src/lib/services/asset.service.ts @@ -5,6 +5,7 @@ import { eventManager } from '$lib/managers/event-manager.svelte'; import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte'; import AssetTagModal from '$lib/modals/AssetTagModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; +import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { user as authUser, preferences } from '$lib/stores/user.store'; import type { AssetControlContext } from '$lib/types'; import { getSharedLink, sleep } from '$lib/utils'; @@ -31,6 +32,7 @@ import { mdiDatabaseRefreshOutline, mdiDownload, mdiDownloadBox, + mdiFaceRecognition, mdiHeadSyncOutline, mdiHeart, mdiHeartOutline, @@ -223,6 +225,17 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = shortcuts: { key: 't' }, }; + const TagPeople: ActionItem = { + title: $t('tag_people'), + icon: mdiFaceRecognition, + type: $t('assets'), + $if: () => isOwner && asset.type === AssetTypeEnum.Image && !asset.isTrashed, + onAction: () => { + isFaceEditMode.value = !isFaceEditMode.value; + }, + shortcuts: { key: 'p' }, + }; + const Edit: ActionItem = { title: $t('editor'), icon: mdiTune, @@ -279,6 +292,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = ZoomOut, Copy, Tag, + TagPeople, Edit, RefreshFacesJob, RefreshMetadataJob, From 1ceb6d2e2164aaa8a0cb03dc9895ea84b3509457 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Wed, 11 Mar 2026 01:12:33 +0000 Subject: [PATCH 112/150] fix(mobile): use tabular figures in backup page (#26830) The numbers in the backup page are not monospace, and so changes cause the layout to shift. Using tabular figures (monospace) will prevent that. Refs: #25021 --- mobile/lib/pages/backup/drift_backup.page.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index c5084c0236..3c1b5ed1fe 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -344,6 +344,7 @@ class _RemainderCard extends ConsumerWidget { remainderCount.toString(), style: context.textTheme.titleLarge?.copyWith( color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255), + fontFeatures: [const FontFeature.tabularFigures()], ), ), if (syncStatus.isRemoteSyncing) @@ -483,6 +484,7 @@ class _PreparingStatusState extends ConsumerState { style: context.textTheme.titleMedium?.copyWith( color: context.colorScheme.primary, fontWeight: FontWeight.w600, + fontFeatures: [const FontFeature.tabularFigures()], ), ), ], @@ -507,6 +509,7 @@ class _PreparingStatusState extends ConsumerState { style: context.textTheme.titleMedium?.copyWith( color: context.primaryColor, fontWeight: FontWeight.w600, + fontFeatures: [const FontFeature.tabularFigures()], ), ), ], From 4571940a4ea5dd17e3b3de60be64365674000e06 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Wed, 11 Mar 2026 01:40:01 +0000 Subject: [PATCH 113/150] fix(mobile): wrap backup error message text (#26834) Refs: #25022 --- mobile/lib/pages/backup/drift_backup.page.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index 3c1b5ed1fe..3ba3389eea 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -148,10 +148,12 @@ class _DriftBackupPageState extends ConsumerState { children: [ Icon(Icons.warning_rounded, color: context.colorScheme.error, fill: 1), const SizedBox(width: 8), - Text( - context.t.backup_error_sync_failed, - style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error), - textAlign: TextAlign.center, + Flexible( + child: Text( + context.t.backup_error_sync_failed, + style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error), + textAlign: TextAlign.center, + ), ), ], ), From 9fc32b6f7a4ba665e064d2c3c800a83264e92a1c Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Wed, 11 Mar 2026 01:58:01 +0000 Subject: [PATCH 114/150] feat(mobile): use material design 3 slider (#26829) * feat(mobile): use material design 3 slider The new slider is easier to use, and looks more modern. * chore: add shadow to button and text for better visibility --------- Co-authored-by: Alex --- mobile/lib/theme/theme_data.dart | 2 -- .../widgets/asset_viewer/video_controls.dart | 22 ++++++++++++------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/mobile/lib/theme/theme_data.dart b/mobile/lib/theme/theme_data.dart index 3837d6337c..69b8596490 100644 --- a/mobile/lib/theme/theme_data.dart +++ b/mobile/lib/theme/theme_data.dart @@ -62,8 +62,6 @@ ThemeData getThemeData({required ColorScheme colorScheme, required Locale locale ), chipTheme: const ChipThemeData(side: BorderSide.none), sliderTheme: const SliderThemeData( - thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7), - trackHeight: 2.0, // ignore: deprecated_member_use year2023: false, ), diff --git a/mobile/lib/widgets/asset_viewer/video_controls.dart b/mobile/lib/widgets/asset_viewer/video_controls.dart index 29e877b3dc..4eed3903c9 100644 --- a/mobile/lib/widgets/asset_viewer/video_controls.dart +++ b/mobile/lib/widgets/asset_viewer/video_controls.dart @@ -14,6 +14,8 @@ import 'package:immich_mobile/widgets/asset_viewer/animated_play_pause.dart'; class VideoControls extends HookConsumerWidget { final String videoPlayerName; + static const List _controlShadows = [Shadow(color: Colors.black87, blurRadius: 6, offset: Offset(0, 1))]; + const VideoControls({super.key, required this.videoPlayerName}); void _toggle(WidgetRef ref, bool isCasting) { @@ -70,14 +72,17 @@ class VideoControls extends HookConsumerWidget { children: [ Row( children: [ - IconButton( - iconSize: 32, - padding: const EdgeInsets.all(12), - constraints: const BoxConstraints(), - icon: isFinished - ? const Icon(Icons.replay, color: Colors.white, size: 32) - : AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying), - onPressed: () => _toggle(ref, isCasting), + IconTheme( + data: const IconThemeData(shadows: _controlShadows), + child: IconButton( + iconSize: 32, + padding: const EdgeInsets.all(12), + constraints: const BoxConstraints(), + icon: isFinished + ? const Icon(Icons.replay, color: Colors.white, size: 32) + : AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying), + onPressed: () => _toggle(ref, isCasting), + ), ), const Spacer(), Text( @@ -86,6 +91,7 @@ class VideoControls extends HookConsumerWidget { color: Colors.white, fontWeight: FontWeight.w500, fontFeatures: [FontFeature.tabularFigures()], + shadows: _controlShadows, ), ), const SizedBox(width: 16), From 9fc6fbc3735bbca045c9dfbd61807b55f15f894c Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:46:29 +0100 Subject: [PATCH 115/150] fix(web): restore asset update events in asset viewer (#26845) --- .../asset-viewer/asset-viewer.spec.ts | 76 +++++++++++++++++++ .../asset-viewer/asset-viewer.svelte | 8 ++ 2 files changed, 84 insertions(+) create mode 100644 web/src/lib/components/asset-viewer/asset-viewer.spec.ts diff --git a/web/src/lib/components/asset-viewer/asset-viewer.spec.ts b/web/src/lib/components/asset-viewer/asset-viewer.spec.ts new file mode 100644 index 0000000000..a1f50da86a --- /dev/null +++ b/web/src/lib/components/asset-viewer/asset-viewer.spec.ts @@ -0,0 +1,76 @@ +import { getAnimateMock } from '$lib/__mocks__/animate.mock'; +import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock'; +import { preferences as preferencesStore, resetSavedUser, user as userStore } from '$lib/stores/user.store'; +import { renderWithTooltips } from '$tests/helpers'; +import { updateAsset } from '@immich/sdk'; +import { assetFactory } from '@test-data/factories/asset-factory'; +import { preferencesFactory } from '@test-data/factories/preferences-factory'; +import { userAdminFactory } from '@test-data/factories/user-factory'; +import { fireEvent, waitFor } from '@testing-library/svelte'; +import AssetViewer from './asset-viewer.svelte'; + +vi.mock('$lib/managers/feature-flags-manager.svelte', () => ({ + featureFlagsManager: { + init: vi.fn(), + loadFeatureFlags: vi.fn(), + value: { smartSearch: true, trash: true }, + } as never, +})); + +vi.mock('$lib/stores/ocr.svelte', () => ({ + ocrManager: { + clear: vi.fn(), + getAssetOcr: vi.fn(), + hasOcrData: false, + showOverlay: false, + }, +})); + +vi.mock('@immich/sdk', async () => { + const sdk = await vi.importActual('@immich/sdk'); + return { + ...sdk, + updateAsset: vi.fn(), + }; +}); + +describe('AssetViewer', () => { + beforeAll(() => { + Element.prototype.animate = getAnimateMock(); + vi.stubGlobal('ResizeObserver', getResizeObserverMock()); + }); + + afterEach(() => { + resetSavedUser(); + vi.clearAllMocks(); + }); + + afterAll(() => { + vi.restoreAllMocks(); + }); + + it('updates the top bar favorite action after pressing favorite', async () => { + const ownerId = 'owner-id'; + const user = userAdminFactory.build({ id: ownerId }); + const asset = assetFactory.build({ ownerId, isFavorite: false, isTrashed: false }); + + userStore.set(user); + preferencesStore.set(preferencesFactory.build({ cast: { gCastEnabled: false } })); + vi.mocked(updateAsset).mockResolvedValue({ ...asset, isFavorite: true }); + + const { getByLabelText, queryByLabelText } = renderWithTooltips(AssetViewer, { + cursor: { current: asset }, + showNavigation: false, + }); + + expect(getByLabelText('to_favorite')).toBeInTheDocument(); + expect(queryByLabelText('unfavorite')).toBeNull(); + + await fireEvent.click(getByLabelText('to_favorite')); + + await waitFor(() => + expect(updateAsset).toHaveBeenCalledWith({ id: asset.id, updateAssetDto: { isFavorite: true } }), + ); + await waitFor(() => expect(getByLabelText('unfavorite')).toBeInTheDocument()); + }); +}); diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index cf1ad4be5a..8520e69a3d 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -5,6 +5,7 @@ import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte'; import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte'; import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte'; + import OnEvents from '$lib/components/OnEvents.svelte'; import { AssetAction, ProjectionType } from '$lib/constants'; import { activityManager } from '$lib/managers/activity-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; @@ -142,6 +143,12 @@ } }; + const onAssetUpdate = (updatedAsset: AssetResponseDto) => { + if (asset.id === updatedAsset.id) { + cursor = { ...cursor, current: updatedAsset }; + } + }; + onMount(() => { syncAssetViewerOpenClass(true); unsubscribes.push( @@ -406,6 +413,7 @@ + From 27f69b39b2a17b52653989c55c5e8e99648ab3fc Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:49:35 +0100 Subject: [PATCH 116/150] fix(server): use correct day ordering in timeline buckets (#26821) * fix(web): sort timeline day groups received from server * fix(server): use correct day ordering in timeline buckets --- server/src/queries/asset.repository.sql | 1 + server/src/repositories/asset.repository.ts | 4 +- .../repositories/asset.repository.spec.ts | 57 +++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 632fb823c6..a74a05f466 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -438,6 +438,7 @@ with and "stack"."primaryAssetId" != "asset"."id" ) order by + (asset."localDateTime" AT TIME ZONE 'UTC')::date desc, "asset"."fileCreatedAt" desc ), "agg" as ( diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index e971a995e6..82534dbfa3 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -744,6 +744,7 @@ export class AssetRepository { params: [DummyValue.TIME_BUCKET, { withStacked: true }, { user: { id: DummyValue.UUID } }], }) getTimeBucket(timeBucket: string, options: TimeBucketOptions, auth: AuthDto) { + const order = options.order ?? 'desc'; const query = this.db .with('cte', (qb) => qb @@ -841,7 +842,8 @@ export class AssetRepository { ) .$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted)) .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)) - .orderBy('asset.fileCreatedAt', options.order ?? 'desc'), + .orderBy(sql`(asset."localDateTime" AT TIME ZONE 'UTC')::date`, order) + .orderBy('asset.fileCreatedAt', order), ) .with('agg', (qb) => qb diff --git a/server/test/medium/specs/repositories/asset.repository.spec.ts b/server/test/medium/specs/repositories/asset.repository.spec.ts index 97f503e9ed..896489672e 100644 --- a/server/test/medium/specs/repositories/asset.repository.spec.ts +++ b/server/test/medium/specs/repositories/asset.repository.spec.ts @@ -1,9 +1,11 @@ import { Kysely } from 'kysely'; +import { AssetOrder, AssetVisibility } from 'src/enum'; import { AssetRepository } from 'src/repositories/asset.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { DB } from 'src/schema'; import { BaseService } from 'src/services/base.service'; import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; import { getKyselyDB } from 'test/utils'; let defaultDatabase: Kysely; @@ -22,6 +24,61 @@ beforeAll(async () => { }); describe(AssetRepository.name, () => { + describe('getTimeBucket', () => { + it('should order assets by local day first and fileCreatedAt within each day', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + const [{ asset: previousLocalDayAsset }, { asset: nextLocalDayEarlierAsset }, { asset: nextLocalDayLaterAsset }] = + await Promise.all([ + ctx.newAsset({ + ownerId: user.id, + fileCreatedAt: new Date('2026-03-09T00:30:00.000Z'), + localDateTime: new Date('2026-03-08T22:30:00.000Z'), + }), + ctx.newAsset({ + ownerId: user.id, + fileCreatedAt: new Date('2026-03-08T23:30:00.000Z'), + localDateTime: new Date('2026-03-09T01:30:00.000Z'), + }), + ctx.newAsset({ + ownerId: user.id, + fileCreatedAt: new Date('2026-03-08T23:45:00.000Z'), + localDateTime: new Date('2026-03-09T01:45:00.000Z'), + }), + ]); + + await Promise.all([ + ctx.newExif({ assetId: previousLocalDayAsset.id, timeZone: 'UTC-2' }), + ctx.newExif({ assetId: nextLocalDayEarlierAsset.id, timeZone: 'UTC+2' }), + ctx.newExif({ assetId: nextLocalDayLaterAsset.id, timeZone: 'UTC+2' }), + ]); + + const descendingBucket = await sut.getTimeBucket( + '2026-03-01', + { order: AssetOrder.Desc, userIds: [user.id], visibility: AssetVisibility.Timeline }, + auth, + ); + expect(JSON.parse(descendingBucket.assets)).toEqual( + expect.objectContaining({ + id: [nextLocalDayLaterAsset.id, nextLocalDayEarlierAsset.id, previousLocalDayAsset.id], + }), + ); + + const ascendingBucket = await sut.getTimeBucket( + '2026-03-01', + { order: AssetOrder.Asc, userIds: [user.id], visibility: AssetVisibility.Timeline }, + auth, + ); + expect(JSON.parse(ascendingBucket.assets)).toEqual( + expect.objectContaining({ + id: [previousLocalDayAsset.id, nextLocalDayEarlierAsset.id, nextLocalDayLaterAsset.id], + }), + ); + }); + }); + describe('upsertExif', () => { it('should append to locked columns', async () => { const { ctx, sut } = setup(); From 8764a1894b644d7b481ad2631d93461f58892668 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Wed, 11 Mar 2026 10:48:46 -0400 Subject: [PATCH 117/150] feat: adaptive progressive image loading for photo viewer (#26636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): adaptive progressive image loading for photo viewer Replace ImageManager with a new AdaptiveImageLoader that progressively loads images through quality tiers (thumbnail → preview → original). New components and utilities: - AdaptiveImage: layered image renderer with thumbhash, thumbnail, preview, and original layers with visibility managed by load state - AdaptiveImageLoader: state machine driving the quality progression with per-quality callbacks and error handling - ImageLayer/Image: low-level image elements with load/error lifecycle - PreloadManager: preloads adjacent assets for instant navigation - AlphaBackground/DelayedLoadingSpinner: loading state UI Zoom is handled via a derived CSS transform applied to the content wrapper in AdaptiveImage, with the zoom library (zoomTarget: null) only tracking state without manipulating the DOM directly. Also adds scaleToCover to container-utils and getAssetUrls to utils. * fix: don't partially render images in firefox * add passive loading indicator to asset-viewer --------- Co-authored-by: Alex --- e2e/src/specs/web/photo-viewer.e2e-spec.ts | 62 ++-- .../asset-viewer/broken-asset.e2e-spec.ts | 6 +- web/src/lib/actions/image-loader.svelte.ts | 25 ++ web/src/lib/actions/zoom-image.ts | 6 +- web/src/lib/components/AdaptiveImage.svelte | 228 +++++++++++++ web/src/lib/components/AlphaBackground.svelte | 11 + .../components/DelayedLoadingSpinner.svelte | 20 ++ web/src/lib/components/Image.svelte | 27 +- web/src/lib/components/ImageLayer.svelte | 47 +++ web/src/lib/components/LoadingDots.svelte | 46 +++ .../asset-viewer/PreloadManager.svelte.ts | 104 ++++++ .../asset-viewer/asset-viewer-nav-bar.svelte | 15 +- .../asset-viewer/asset-viewer.svelte | 181 ++++++----- .../face-editor/face-editor.svelte | 37 ++- .../asset-viewer/photo-viewer.svelte | 227 +++++-------- .../memory-page/memory-photo-viewer.svelte | 18 +- web/src/lib/managers/ImageManager.spec.ts | 99 ------ web/src/lib/managers/ImageManager.svelte.ts | 37 --- .../managers/asset-viewer-manager.svelte.ts | 15 + web/src/lib/utils.ts | 8 + .../lib/utils/adaptive-image-loader.spec.ts | 304 ++++++++++++++++++ .../lib/utils/adaptive-image-loader.svelte.ts | 164 ++++++++++ web/src/lib/utils/asset-utils.ts | 2 + web/src/lib/utils/container-utils.ts | 13 + web/src/lib/utils/layout-utils.spec.ts | 54 ++++ 25 files changed, 1340 insertions(+), 416 deletions(-) create mode 100644 web/src/lib/actions/image-loader.svelte.ts create mode 100644 web/src/lib/components/AdaptiveImage.svelte create mode 100644 web/src/lib/components/AlphaBackground.svelte create mode 100644 web/src/lib/components/DelayedLoadingSpinner.svelte create mode 100644 web/src/lib/components/ImageLayer.svelte create mode 100644 web/src/lib/components/LoadingDots.svelte create mode 100644 web/src/lib/components/asset-viewer/PreloadManager.svelte.ts delete mode 100644 web/src/lib/managers/ImageManager.spec.ts delete mode 100644 web/src/lib/managers/ImageManager.svelte.ts create mode 100644 web/src/lib/utils/adaptive-image-loader.spec.ts create mode 100644 web/src/lib/utils/adaptive-image-loader.svelte.ts create mode 100644 web/src/lib/utils/layout-utils.spec.ts diff --git a/e2e/src/specs/web/photo-viewer.e2e-spec.ts b/e2e/src/specs/web/photo-viewer.e2e-spec.ts index 3f9bb4237a..88b61278bc 100644 --- a/e2e/src/specs/web/photo-viewer.e2e-spec.ts +++ b/e2e/src/specs/web/photo-viewer.e2e-spec.ts @@ -1,14 +1,13 @@ import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk'; -import { Page, expect, test } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import type { Socket } from 'socket.io-client'; import { utils } from 'src/utils'; -function imageLocator(page: Page) { - return page.getByAltText('Image taken').locator('visible=true'); -} test.describe('Photo Viewer', () => { let admin: LoginResponseDto; let asset: AssetMediaResponseDto; let rawAsset: AssetMediaResponseDto; + let websocket: Socket; test.beforeAll(async () => { utils.initSdk(); @@ -16,6 +15,11 @@ test.describe('Photo Viewer', () => { admin = await utils.adminSetup(); asset = await utils.createAsset(admin.accessToken); rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } }); + websocket = await utils.connectWebsocket(admin.accessToken); + }); + + test.afterAll(() => { + utils.disconnectWebsocket(websocket); }); test.beforeEach(async ({ context, page }) => { @@ -26,31 +30,51 @@ test.describe('Photo Viewer', () => { test('loads original photo when zoomed', async ({ page }) => { await page.goto(`/photos/${asset.id}`); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); - const box = await imageLocator(page).boundingBox(); - expect(box).toBeTruthy(); - const { x, y, width, height } = box!; - await page.mouse.move(x + width / 2, y + height / 2); + + const preview = page.getByTestId('preview').filter({ visible: true }); + await expect(preview).toHaveAttribute('src', /.+/); + + const originalResponse = page.waitForResponse((response) => response.url().includes('/original')); + + const { width, height } = page.viewportSize()!; + await page.mouse.move(width / 2, height / 2); await page.mouse.wheel(0, -1); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original'); + + await originalResponse; + + const original = page.getByTestId('original').filter({ visible: true }); + await expect(original).toHaveAttribute('src', /original/); }); test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => { await page.goto(`/photos/${rawAsset.id}`); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); - const box = await imageLocator(page).boundingBox(); - expect(box).toBeTruthy(); - const { x, y, width, height } = box!; - await page.mouse.move(x + width / 2, y + height / 2); + + const preview = page.getByTestId('preview').filter({ visible: true }); + await expect(preview).toHaveAttribute('src', /.+/); + + const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize')); + + const { width, height } = page.viewportSize()!; + await page.mouse.move(width / 2, height / 2); await page.mouse.wheel(0, -1); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize'); + + await fullsizeResponse; + + const original = page.getByTestId('original').filter({ visible: true }); + await expect(original).toHaveAttribute('src', /fullsize/); }); test('reloads photo when checksum changes', async ({ page }) => { await page.goto(`/photos/${asset.id}`); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); - const initialSrc = await imageLocator(page).getAttribute('src'); + + const preview = page.getByTestId('preview').filter({ visible: true }); + await expect(preview).toHaveAttribute('src', /.+/); + const initialSrc = await preview.getAttribute('src'); + + const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id }); await utils.replaceAsset(admin.accessToken, asset.id); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc); + await websocketEvent; + + await expect(preview).not.toHaveAttribute('src', initialSrc!); }); }); diff --git a/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts b/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts index fa010f0c1b..2b036d3f52 100644 --- a/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts +++ b/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts @@ -64,7 +64,9 @@ test.describe('broken-asset responsiveness', () => { test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => { await context.route( - (url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`), + (url) => + url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`) || + url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`), async (route) => { return route.fulfill({ status: 404 }); }, @@ -73,7 +75,7 @@ test.describe('broken-asset responsiveness', () => { await page.goto(`/photos/${fixture.primaryAsset.id}`); await page.waitForSelector('#immich-asset-viewer'); - const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]'); + const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]').first(); await expect(viewerBrokenAsset).toBeVisible(); await expect(viewerBrokenAsset.locator('svg')).toBeVisible(); diff --git a/web/src/lib/actions/image-loader.svelte.ts b/web/src/lib/actions/image-loader.svelte.ts new file mode 100644 index 0000000000..49a53dac26 --- /dev/null +++ b/web/src/lib/actions/image-loader.svelte.ts @@ -0,0 +1,25 @@ +import { cancelImageUrl } from '$lib/utils/sw-messaging'; + +export function loadImage(src: string, onLoad: () => void, onError: () => void, onStart?: () => void) { + let destroyed = false; + + const handleLoad = () => !destroyed && onLoad(); + const handleError = () => !destroyed && onError(); + + const img = document.createElement('img'); + img.addEventListener('load', handleLoad); + img.addEventListener('error', handleError); + + onStart?.(); + img.src = src; + + return () => { + destroyed = true; + img.removeEventListener('load', handleLoad); + img.removeEventListener('error', handleError); + cancelImageUrl(src); + img.remove(); + }; +} + +export type LoadImageFunction = typeof loadImage; diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index 602ed9bd63..66659997d2 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -2,7 +2,11 @@ import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { createZoomImageWheel } from '@zoom-image/core'; export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => { - const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState }); + const zoomInstance = createZoomImageWheel(node, { + maxZoom: 10, + initialState: assetViewerManager.zoomState, + zoomTarget: null, + }); const unsubscribes = [ assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }), diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte new file mode 100644 index 0000000000..92e3fad2d3 --- /dev/null +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -0,0 +1,228 @@ + + +
+ {@render backdrop?.()} + +
+
+ {#if show.alphaBackground} + + {/if} + + {#if show.thumbhash} + {#if asset.thumbhash} + + + {:else if show.spinner} + + {/if} + {/if} + + {#if show.thumbnail} + + {/if} + + {#if show.brokenAsset} + + {/if} + + {#if show.preview} + + {/if} + + {#if show.original} + + {/if} +
+
+
diff --git a/web/src/lib/components/AlphaBackground.svelte b/web/src/lib/components/AlphaBackground.svelte new file mode 100644 index 0000000000..c0d8536a2f --- /dev/null +++ b/web/src/lib/components/AlphaBackground.svelte @@ -0,0 +1,11 @@ + + +
diff --git a/web/src/lib/components/DelayedLoadingSpinner.svelte b/web/src/lib/components/DelayedLoadingSpinner.svelte new file mode 100644 index 0000000000..d18d373566 --- /dev/null +++ b/web/src/lib/components/DelayedLoadingSpinner.svelte @@ -0,0 +1,20 @@ + + +
+ +
+ + diff --git a/web/src/lib/components/Image.svelte b/web/src/lib/components/Image.svelte index 417af56192..7ad6dc3ab7 100644 --- a/web/src/lib/components/Image.svelte +++ b/web/src/lib/components/Image.svelte @@ -1,4 +1,5 @@ + +{#key adaptiveImageLoader} +
+ adaptiveImageLoader.onStart(quality)} + onLoad={() => adaptiveImageLoader.onLoad(quality)} + onError={() => adaptiveImageLoader.onError(quality)} + bind:ref + class="h-full w-full bg-transparent" + {alt} + {role} + draggable={false} + data-testid={quality} + /> + {@render overlays?.()} +
+{/key} diff --git a/web/src/lib/components/LoadingDots.svelte b/web/src/lib/components/LoadingDots.svelte new file mode 100644 index 0000000000..3dcfcb8122 --- /dev/null +++ b/web/src/lib/components/LoadingDots.svelte @@ -0,0 +1,46 @@ + + +
+ {#each [0, 1, 2] as i (i)} + + {/each} +
+ + diff --git a/web/src/lib/components/asset-viewer/PreloadManager.svelte.ts b/web/src/lib/components/asset-viewer/PreloadManager.svelte.ts new file mode 100644 index 0000000000..38da1dc08d --- /dev/null +++ b/web/src/lib/components/asset-viewer/PreloadManager.svelte.ts @@ -0,0 +1,104 @@ +import { loadImage } from '$lib/actions/image-loader.svelte'; +import { getAssetUrls } from '$lib/utils'; +import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte'; +import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk'; + +type AssetCursor = { + current: AssetResponseDto; + nextAsset?: AssetResponseDto; + previousAsset?: AssetResponseDto; +}; + +export class PreloadManager { + private nextPreloader: AdaptiveImageLoader | undefined; + private previousPreloader: AdaptiveImageLoader | undefined; + + private startPreloader( + asset: AssetResponseDto | undefined, + sharedlink: SharedLinkResponseDto | undefined, + ): AdaptiveImageLoader | undefined { + if (!asset) { + return; + } + const urls = getAssetUrls(asset, sharedlink); + const afterThumbnail = (loader: AdaptiveImageLoader) => loader.trigger('preview'); + const qualityList: QualityList = [ + { + quality: 'thumbnail', + url: urls.thumbnail, + onAfterLoad: afterThumbnail, + onAfterError: afterThumbnail, + }, + { + quality: 'preview', + url: urls.preview, + onAfterError: (loader) => loader.trigger('original'), + }, + { quality: 'original', url: urls.original }, + ]; + const loader = new AdaptiveImageLoader(qualityList, undefined, loadImage); + loader.start(); + return loader; + } + + private destroyPreviousPreloader() { + this.previousPreloader?.destroy(); + this.previousPreloader = undefined; + } + + private destroyNextPreloader() { + this.nextPreloader?.destroy(); + this.nextPreloader = undefined; + } + + cancelBeforeNavigation(direction: 'previous' | 'next') { + switch (direction) { + case 'next': { + this.destroyPreviousPreloader(); + break; + } + case 'previous': { + this.destroyNextPreloader(); + break; + } + } + } + + updateAfterNavigation(oldCursor: AssetCursor, newCursor: AssetCursor, sharedlink: SharedLinkResponseDto | undefined) { + const movedForward = newCursor.current.id === oldCursor.nextAsset?.id; + const movedBackward = newCursor.current.id === oldCursor.previousAsset?.id; + + if (!movedBackward) { + this.destroyPreviousPreloader(); + } + + if (!movedForward) { + this.destroyNextPreloader(); + } + + if (movedForward) { + this.nextPreloader = this.startPreloader(newCursor.nextAsset, sharedlink); + } else if (movedBackward) { + this.previousPreloader = this.startPreloader(newCursor.previousAsset, sharedlink); + } else { + this.previousPreloader = this.startPreloader(newCursor.previousAsset, sharedlink); + this.nextPreloader = this.startPreloader(newCursor.nextAsset, sharedlink); + } + } + + initializePreloads(cursor: AssetCursor, sharedlink: SharedLinkResponseDto | undefined) { + if (cursor.nextAsset) { + this.nextPreloader = this.startPreloader(cursor.nextAsset, sharedlink); + } + if (cursor.previousAsset) { + this.previousPreloader = this.startPreloader(cursor.previousAsset, sharedlink); + } + } + + destroy() { + this.destroyNextPreloader(); + this.destroyPreviousPreloader(); + } +} + +export const preloadManager = new PreloadManager(); diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index bb52c71260..3ccadf944f 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -34,7 +34,9 @@ type PersonResponseDto, type StackResponseDto, } from '@immich/sdk'; - import { ActionButton, CommandPaletteDefaultProvider, type ActionItem } from '@immich/ui'; + import { ActionButton, CommandPaletteDefaultProvider, Tooltip, type ActionItem } from '@immich/ui'; + import LoadingDots from '$lib/components/LoadingDots.svelte'; + import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { mdiArrowLeft, mdiArrowRight, @@ -104,7 +106,16 @@
-
+
+ {#if assetViewerManager.isImageLoading} + + {#snippet child({ props })} +
+ +
+ {/snippet} +
+ {/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 8520e69a3d..3f7b048c8f 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -5,6 +5,7 @@ import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte'; import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte'; import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte'; + import { preloadManager } from '$lib/components/asset-viewer/PreloadManager.svelte'; import OnEvents from '$lib/components/OnEvents.svelte'; import { AssetAction, ProjectionType } from '$lib/constants'; import { activityManager } from '$lib/managers/activity-manager.svelte'; @@ -12,9 +13,9 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; - import { imageManager } from '$lib/managers/ImageManager.svelte'; import { getAssetActions } from '$lib/services/asset.service'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; @@ -37,6 +38,7 @@ } from '@immich/sdk'; import { CommandPaletteDefaultProvider } from '@immich/ui'; import { onDestroy, onMount, untrack } from 'svelte'; + import type { SwipeCustomEvent } from 'svelte-gestures'; import { t } from 'svelte-i18n'; import { fly } from 'svelte/transition'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; @@ -93,20 +95,19 @@ stopProgress: stopSlideshowProgress, slideshowNavigation, slideshowState, - slideshowTransition, slideshowRepeat, } = slideshowStore; const stackThumbnailSize = 60; const stackSelectedThumbnailSize = 65; - const asset = $derived(cursor.current); + let previewStackedAsset: AssetResponseDto | undefined = $state(); + let stack: StackResponseDto | null = $state(null); + + const asset = $derived(previewStackedAsset ?? cursor.current); const nextAsset = $derived(cursor.nextAsset); const previousAsset = $derived(cursor.previousAsset); let sharedLink = getSharedLink(); - let previewStackedAsset: AssetResponseDto | undefined = $state(); let fullscreenElement = $state(); - let unsubscribes: (() => void)[] = []; - let stack: StackResponseDto | null = $state(null); let playOriginalVideo = $state($alwaysLoadOriginalVideo); let slideshowStartAssetId = $state(); @@ -116,7 +117,7 @@ }; const refreshStack = async () => { - if (authManager.isSharedLink) { + if (authManager.isSharedLink || !withStacked) { return; } @@ -127,19 +128,17 @@ if (!stack?.assets.some(({ id }) => id === asset.id)) { stack = null; } - - untrack(() => { - imageManager.preload(stack?.assets[1]); - }); }; const handleFavorite = async () => { - if (album && album.isActivityEnabled) { - try { - await activityManager.toggleLike(); - } catch (error) { - handleError(error, $t('errors.unable_to_change_favorite')); - } + if (!album || !album.isActivityEnabled) { + return; + } + + try { + await activityManager.toggleLike(); + } catch (error) { + handleError(error, $t('errors.unable_to_change_favorite')); } }; @@ -151,33 +150,34 @@ onMount(() => { syncAssetViewerOpenClass(true); - unsubscribes.push( - slideshowState.subscribe((value) => { - if (value === SlideshowState.PlaySlideshow) { - slideshowHistory.reset(); - slideshowHistory.queue(toTimelineAsset(asset)); - handlePromiseError(handlePlaySlideshow()); - } else if (value === SlideshowState.StopSlideshow) { - handlePromiseError(handleStopSlideshow()); - } - }), - slideshowNavigation.subscribe((value) => { - if (value === SlideshowNavigation.Shuffle) { - slideshowHistory.reset(); - slideshowHistory.queue(toTimelineAsset(asset)); - } - }), - ); + const slideshowStateUnsubscribe = slideshowState.subscribe((value) => { + if (value === SlideshowState.PlaySlideshow) { + slideshowHistory.reset(); + slideshowHistory.queue(toTimelineAsset(asset)); + handlePromiseError(handlePlaySlideshow()); + } else if (value === SlideshowState.StopSlideshow) { + handlePromiseError(handleStopSlideshow()); + } + }); + + const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => { + if (value === SlideshowNavigation.Shuffle) { + slideshowHistory.reset(); + slideshowHistory.queue(toTimelineAsset(asset)); + } + }); + + return () => { + slideshowStateUnsubscribe(); + slideshowNavigationUnsubscribe(); + }; }); onDestroy(() => { - for (const unsubscribe of unsubscribes) { - unsubscribe(); - } - activityManager.reset(); assetViewerManager.closeEditor(); syncAssetViewerOpenClass(false); + preloadManager.destroy(); }); const closeViewer = () => { @@ -194,8 +194,7 @@ }; const tracker = new InvocationTracker(); - - const navigateAsset = (order?: 'previous' | 'next', e?: Event) => { + const navigateAsset = (order?: 'previous' | 'next') => { if (!order) { if ($slideshowState === SlideshowState.PlaySlideshow) { order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next'; @@ -204,16 +203,19 @@ } } - e?.stopPropagation(); - imageManager.cancel(asset); + preloadManager.cancelBeforeNavigation(order); + if (tracker.isActive()) { return; } void tracker.invoke(async () => { + const isShuffle = + $slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle; + let hasNext: boolean; - if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { + if (isShuffle) { hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); if (!hasNext) { const asset = await onRandom?.(); @@ -227,17 +229,22 @@ order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset); } - if ($slideshowState === SlideshowState.PlaySlideshow) { - if (hasNext) { - $restartSlideshowProgress = true; - } else if ($slideshowRepeat && slideshowStartAssetId) { - // Loop back to starting asset - await setAssetId(slideshowStartAssetId); - $restartSlideshowProgress = true; - } else { - await handleStopSlideshow(); - } + if ($slideshowState !== SlideshowState.PlaySlideshow) { + return; } + + if (hasNext) { + $restartSlideshowProgress = true; + return; + } + + if ($slideshowRepeat && slideshowStartAssetId) { + await setAssetId(slideshowStartAssetId); + $restartSlideshowProgress = true; + return; + } + + await handleStopSlideshow(); }, $t('error_while_navigating')); }; @@ -281,12 +288,14 @@ } }; - const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => { - previewStackedAsset = isMouseOver ? asset : undefined; + const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => { + previewStackedAsset = isMouseOver ? stackedAsset : undefined; }; + const handlePreAction = (action: Action) => { preAction?.(action); }; + const handleAction = async (action: Action) => { switch (action.type) { case AssetAction.DELETE: @@ -359,17 +368,31 @@ await ocrManager.getAssetOcr(asset.id); } }; + $effect(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions asset; untrack(() => handlePromiseError(refresh())); - imageManager.preload(cursor.nextAsset); - imageManager.preload(cursor.previousAsset); + }); + + let lastCursor = $state(); + + $effect(() => { + if (cursor.current.id === lastCursor?.current.id) { + return; + } + if (lastCursor) { + preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink); + } + if (!lastCursor) { + preloadManager.initializePreloads(cursor, sharedLink); + } + lastCursor = cursor; }); const viewerKind = $derived.by(() => { if (previewStackedAsset) { - return previewStackedAsset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer'; + return previewStackedAsset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer'; } if (asset.type === AssetTypeEnum.Video) { return 'VideoViewer'; @@ -410,6 +433,24 @@ assetViewerManager.isShowDetailPanel && !assetViewerManager.isShowEditor, ); + + const onSwipe = (event: SwipeCustomEvent) => { + if (assetViewerManager.zoom > 1) { + return; + } + + if (ocrManager.showOverlay) { + return; + } + + if (event.detail.direction === 'left') { + navigateAsset('next'); + } + + if (event.detail.direction === 'right') { + navigateAsset('previous'); + } + }; @@ -456,23 +497,15 @@
{/if} - {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset} + {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
navigateAsset('previous')} />
{/if} -
- {#if viewerKind === 'StackPhotoViewer'} - navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - haveFadeTransition={false} - {sharedLink} - /> - {:else if viewerKind === 'StackVideoViewer'} +
+ {#if viewerKind === 'StackVideoViewer'} {:else if viewerKind === 'PhotoViewer'} - navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - {sharedLink} - haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition} - /> + {:else if viewerKind === 'VideoViewer'} - {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset} + {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
navigateAsset('next')} />
diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index 39088b23de..e84bc9fa0c 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -3,7 +3,7 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { getPeopleThumbnailUrl } from '$lib/utils'; - import { getContentMetrics, getNaturalSize } from '$lib/utils/container-utils'; + import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils'; import { handleError } from '$lib/utils/handle-error'; import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk'; import { Button, Input, modalManager, toastManager } from '@immich/ui'; @@ -81,15 +81,20 @@ await getPeople(); }); - $effect(() => { - const metrics = getContentMetrics(htmlElement); - - const imageBoundingBox = { - top: metrics.offsetY, - left: metrics.offsetX, - width: metrics.contentWidth, - height: metrics.contentHeight, + const imageContentMetrics = $derived.by(() => { + const natural = getNaturalSize(htmlElement); + const container = { width: containerWidth, height: containerHeight }; + const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container); + return { + contentWidth, + contentHeight, + offsetX: (containerWidth - contentWidth) / 2, + offsetY: (containerHeight - contentHeight) / 2, }; + }); + + $effect(() => { + const { offsetX, offsetY } = imageContentMetrics; if (!canvas) { return; @@ -105,8 +110,8 @@ } faceRect.set({ - top: imageBoundingBox.top + 200, - left: imageBoundingBox.left + 200, + top: offsetY + 200, + left: offsetX + 200, }); faceRect.setCoords(); @@ -214,13 +219,13 @@ } const { left, top, width, height } = faceRect.getBoundingRect(); - const metrics = getContentMetrics(htmlElement); + const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics; const natural = getNaturalSize(htmlElement); - const scaleX = natural.width / metrics.contentWidth; - const scaleY = natural.height / metrics.contentHeight; - const imageX = (left - metrics.offsetX) * scaleX; - const imageY = (top - metrics.offsetY) * scaleY; + const scaleX = natural.width / contentWidth; + const scaleY = natural.height / contentHeight; + const imageX = (left - offsetX) * scaleX; + const imageY = (top - offsetY) * scaleY; return { imageWidth: natural.width, diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 411c9f3ee3..55c765ce22 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -1,66 +1,56 @@ -
- +
+ +
transformManager.handleMouseDownOn(e, ResizeBoundary.None)} + >
+ + {#each edges as edge (edge)} + {@const rotatedEdge = rotateBoundary(edges, edge, transformManager.normalizedRotation / 90)} + + {/each} + + {#each corners as corner (corner)} + {@const rotatedCorner = rotateBoundary(corners, corner, transformManager.normalizedRotation / 90)} + + {/each} +
+
diff --git a/web/src/lib/managers/edit/transform-manager.svelte.ts b/web/src/lib/managers/edit/transform-manager.svelte.ts index 77290d3e6d..652cd0bee9 100644 --- a/web/src/lib/managers/edit/transform-manager.svelte.ts +++ b/web/src/lib/managers/edit/transform-manager.svelte.ts @@ -1,9 +1,10 @@ -import { editManager, type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte'; +import { type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte'; import { getAssetMediaUrl } from '$lib/utils'; import { getDimensions } from '$lib/utils/asset-utils'; import { normalizeTransformEdits } from '$lib/utils/editor'; import { handleError } from '$lib/utils/handle-error'; import { AssetEditAction, AssetMediaSize, MirrorAxis, type AssetResponseDto, type CropParameters } from '@immich/sdk'; +import { clamp } from 'lodash-es'; import { tick } from 'svelte'; export type CropAspectRatio = @@ -37,17 +38,27 @@ type RegionConvertParams = { to: ImageDimensions; }; +export enum ResizeBoundary { + None = 'none', + TopLeft = 'top-left', + TopRight = 'top-right', + BottomLeft = 'bottom-left', + BottomRight = 'bottom-right', + Left = 'left', + Right = 'right', + Top = 'top', + Bottom = 'bottom', +} + class TransformManager implements EditToolManager { canReset: boolean = $derived.by(() => this.checkEdits()); hasChanges: boolean = $state(false); - darkenLevel = $state(0.65); isInteracting = $state(false); isDragging = $state(false); animationFrame = $state | null>(null); - canvasCursor = $state('default'); - dragOffset = $state({ x: 0, y: 0 }); - resizeSide = $state(''); + dragAnchor = $state({ x: 0, y: 0 }); + resizeSide = $state(ResizeBoundary.None); imgElement = $state(null); cropAreaEl = $state(null); overlayEl = $state(null); @@ -69,7 +80,6 @@ class TransformManager implements EditToolManager { const newAngle = this.imageRotation % 360; return newAngle < 0 ? newAngle + 360 : newAngle; }); - orientationChanged = $derived.by(() => this.normalizedRotation % 180 > 0); edits = $derived.by(() => this.getEdits()); @@ -81,9 +91,9 @@ class TransformManager implements EditToolManager { return; } - const newCrop = transformManager.recalculateCrop(aspectRatio); + const newCrop = this.recalculateCrop(aspectRatio); if (newCrop) { - transformManager.animateCropChange(this.cropAreaEl, this.region, newCrop); + this.animateCropChange(newCrop); this.region = newCrop; } } @@ -216,17 +226,11 @@ class TransformManager implements EditToolManager { } reset() { - this.darkenLevel = 0.65; this.isInteracting = false; this.animationFrame = null; - this.canvasCursor = 'default'; - this.dragOffset = { x: 0, y: 0 }; - this.resizeSide = ''; + this.dragAnchor = { x: 0, y: 0 }; + this.resizeSide = ResizeBoundary.None; this.imgElement = null; - if (this.cropAreaEl) { - this.cropAreaEl.style.cursor = ''; - } - document.body.style.cursor = ''; this.cropAreaEl = null; this.isDragging = false; this.overlayEl = null; @@ -295,12 +299,12 @@ class TransformManager implements EditToolManager { }; } - animateCropChange(element: HTMLElement, from: Region, to: Region, duration = 100) { - const cropFrame = element.querySelector('.crop-frame') as HTMLElement; - if (!cropFrame) { + animateCropChange(to: Region, duration = 100) { + if (!this.cropFrame) { return; } + const from = this.region; const startTime = performance.now(); const initialCrop = { ...from }; @@ -334,28 +338,6 @@ class TransformManager implements EditToolManager { return { newWidth, newHeight }; } - // Calculate constrained dimensions based on aspect ratio and limits - getConstrainedDimensions( - desiredWidth: number, - desiredHeight: number, - maxWidth: number, - maxHeight: number, - minSize = 50, - ) { - const { newWidth, newHeight } = this.adjustDimensions( - desiredWidth, - desiredHeight, - this.cropAspectRatio, - maxWidth, - maxHeight, - minSize, - ); - return { - width: Math.max(minSize, Math.min(newWidth, maxWidth)), - height: Math.max(minSize, Math.min(newHeight, maxHeight)), - }; - } - adjustDimensions( newWidth: number, newHeight: number, @@ -364,49 +346,45 @@ class TransformManager implements EditToolManager { yLimit: number, minSize: number, ) { + if (aspectRatio === 'free') { + return { + newWidth: clamp(newWidth, minSize, xLimit), + newHeight: clamp(newHeight, minSize, yLimit), + }; + } + let w = newWidth; let h = newHeight; - let aspectMultiplier: number; + const [ratioWidth, ratioHeight] = aspectRatio.split(':').map(Number); + const aspectMultiplier = ratioWidth && ratioHeight ? ratioWidth / ratioHeight : w / h; - if (aspectRatio === 'free') { - aspectMultiplier = newWidth / newHeight; - } else { - const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number); - aspectMultiplier = widthRatio && heightRatio ? widthRatio / heightRatio : newWidth / newHeight; - } - - if (aspectRatio !== 'free') { - h = w / aspectMultiplier; + h = w / aspectMultiplier; + // When dragging a corner, use the biggest region that fits 'inside' the mouse location. + if (h < newHeight) { + h = newHeight; + w = h * aspectMultiplier; } if (w > xLimit) { w = xLimit; - if (aspectRatio !== 'free') { - h = w / aspectMultiplier; - } + h = w / aspectMultiplier; } if (h > yLimit) { h = yLimit; - if (aspectRatio !== 'free') { - w = h * aspectMultiplier; - } + w = h * aspectMultiplier; } if (w < minSize) { w = minSize; - if (aspectRatio !== 'free') { - h = w / aspectMultiplier; - } + h = w / aspectMultiplier; } if (h < minSize) { h = minSize; - if (aspectRatio !== 'free') { - w = h * aspectMultiplier; - } + w = h * aspectMultiplier; } - if (aspectRatio !== 'free' && w / h !== aspectMultiplier) { + if (w / h !== aspectMultiplier) { if (w < minSize) { h = w / aspectMultiplier; } @@ -428,10 +406,6 @@ class TransformManager implements EditToolManager { this.cropFrame.style.width = `${crop.width}px`; this.cropFrame.style.height = `${crop.height}px`; - this.drawOverlay(crop); - } - - drawOverlay(crop: Region) { if (!this.overlayEl) { return; } @@ -465,7 +439,6 @@ class TransformManager implements EditToolManager { const cropFrameEl = this.cropFrame; cropFrameEl?.classList.add('transition'); this.region = this.normalizeCropArea(scale); - cropFrameEl?.classList.add('transition'); cropFrameEl?.addEventListener('transitionend', () => cropFrameEl?.classList.remove('transition'), { passive: true, }); @@ -540,7 +513,7 @@ class TransformManager implements EditToolManager { normalizeCropArea(scale: number) { const img = this.imgElement; if (!img) { - return { ...this.region }; + return this.region; } const scaleRatio = scale / this.cropImageScale; @@ -576,38 +549,17 @@ class TransformManager implements EditToolManager { this.draw(); } - handleMouseDown(e: MouseEvent) { - const canvas = this.cropAreaEl; - if (!canvas) { + handleMouseDownOn(e: MouseEvent, resizeBoundary: ResizeBoundary) { + if (e.button !== 0) { return; } - const { mouseX, mouseY } = this.getMousePosition(e); - - const { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - } = this.isOnCropBoundary(mouseX, mouseY); - - if ( - onTopLeftCorner || - onTopRightCorner || - onBottomLeftCorner || - onBottomRightCorner || - onLeftBoundary || - onRightBoundary || - onTopBoundary || - onBottomBoundary - ) { - this.setResizeSide(mouseX, mouseY); - } else if (this.isInCropArea(mouseX, mouseY)) { - this.startDragging(mouseX, mouseY); + this.isInteracting = true; + this.resizeSide = resizeBoundary; + if (resizeBoundary === ResizeBoundary.None) { + this.isDragging = true; + const { mouseX, mouseY } = this.getMousePosition(e); + this.dragAnchor = { x: mouseX - this.region.x, y: mouseY - this.region.y }; } document.body.style.userSelect = 'none'; @@ -615,20 +567,16 @@ class TransformManager implements EditToolManager { } handleMouseMove(e: MouseEvent) { - const canvas = this.cropAreaEl; - if (!canvas) { + if (!this.cropAreaEl) { return; } - const resizeSideValue = this.resizeSide; const { mouseX, mouseY } = this.getMousePosition(e); if (this.isDragging) { this.moveCrop(mouseX, mouseY); - } else if (resizeSideValue) { + } else if (this.resizeSide !== ResizeBoundary.None) { this.resizeCrop(mouseX, mouseY); - } else { - this.updateCursor(mouseX, mouseY); } } @@ -638,131 +586,42 @@ class TransformManager implements EditToolManager { this.isInteracting = false; this.isDragging = false; - this.resizeSide = ''; - this.fadeOverlay(true); // Darken the background + this.resizeSide = ResizeBoundary.None; } getMousePosition(e: MouseEvent) { - let offsetX = e.clientX; - let offsetY = e.clientY; - const clienRect = this.cropAreaEl?.getBoundingClientRect(); - const rotateDeg = this.normalizedRotation; - - if (rotateDeg == 90) { - offsetX = e.clientY - (clienRect?.top ?? 0); - offsetY = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0)); - } else if (rotateDeg == 180) { - offsetX = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0)); - offsetY = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0)); - } else if (rotateDeg == 270) { - offsetX = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0)); - offsetY = e.clientX - (clienRect?.left ?? 0); - } else if (rotateDeg == 0) { - offsetX -= clienRect?.left ?? 0; - offsetY -= clienRect?.top ?? 0; + if (!this.cropAreaEl) { + throw new Error('Crop area is undefined'); } - return { mouseX: offsetX, mouseY: offsetY }; - } + const clientRect = this.cropAreaEl.getBoundingClientRect(); - // Boundary detection helpers - private isInRange(value: number, target: number, sensitivity: number): boolean { - return value >= target - sensitivity && value <= target + sensitivity; - } - - private isWithinBounds(value: number, min: number, max: number): boolean { - return value >= min && value <= max; - } - - isOnCropBoundary(mouseX: number, mouseY: number) { - const { x, y, width, height } = this.region; - const sensitivity = 10; - const cornerSensitivity = 15; - const { width: imgWidth, height: imgHeight } = this.previewImageSize; - - const outOfBound = mouseX > imgWidth || mouseY > imgHeight || mouseX < 0 || mouseY < 0; - if (outOfBound) { - return { - onLeftBoundary: false, - onRightBoundary: false, - onTopBoundary: false, - onBottomBoundary: false, - onTopLeftCorner: false, - onTopRightCorner: false, - onBottomLeftCorner: false, - onBottomRightCorner: false, - }; + switch (this.normalizedRotation) { + case 90: { + return { + mouseX: e.clientY - clientRect.top, + mouseY: -e.clientX + clientRect.right, + }; + } + case 180: { + return { + mouseX: -e.clientX + clientRect.right, + mouseY: -e.clientY + clientRect.bottom, + }; + } + case 270: { + return { + mouseX: -e.clientY + clientRect.bottom, + mouseY: e.clientX - clientRect.left, + }; + } + // also case 0: + default: { + return { + mouseX: e.clientX - clientRect.left, + mouseY: e.clientY - clientRect.top, + }; + } } - - const onLeftBoundary = this.isInRange(mouseX, x, sensitivity) && this.isWithinBounds(mouseY, y, y + height); - const onRightBoundary = - this.isInRange(mouseX, x + width, sensitivity) && this.isWithinBounds(mouseY, y, y + height); - const onTopBoundary = this.isInRange(mouseY, y, sensitivity) && this.isWithinBounds(mouseX, x, x + width); - const onBottomBoundary = - this.isInRange(mouseY, y + height, sensitivity) && this.isWithinBounds(mouseX, x, x + width); - - const onTopLeftCorner = - this.isInRange(mouseX, x, cornerSensitivity) && this.isInRange(mouseY, y, cornerSensitivity); - const onTopRightCorner = - this.isInRange(mouseX, x + width, cornerSensitivity) && this.isInRange(mouseY, y, cornerSensitivity); - const onBottomLeftCorner = - this.isInRange(mouseX, x, cornerSensitivity) && this.isInRange(mouseY, y + height, cornerSensitivity); - const onBottomRightCorner = - this.isInRange(mouseX, x + width, cornerSensitivity) && this.isInRange(mouseY, y + height, cornerSensitivity); - - return { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - }; - } - - isInCropArea(mouseX: number, mouseY: number) { - const { x, y, width, height } = this.region; - return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; - } - - setResizeSide(mouseX: number, mouseY: number) { - const { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - } = this.isOnCropBoundary(mouseX, mouseY); - - if (onTopLeftCorner) { - this.resizeSide = 'top-left'; - } else if (onTopRightCorner) { - this.resizeSide = 'top-right'; - } else if (onBottomLeftCorner) { - this.resizeSide = 'bottom-left'; - } else if (onBottomRightCorner) { - this.resizeSide = 'bottom-right'; - } else if (onLeftBoundary) { - this.resizeSide = 'left'; - } else if (onRightBoundary) { - this.resizeSide = 'right'; - } else if (onTopBoundary) { - this.resizeSide = 'top'; - } else if (onBottomBoundary) { - this.resizeSide = 'bottom'; - } - } - - startDragging(mouseX: number, mouseY: number) { - this.isDragging = true; - const crop = this.region; - this.isInteracting = true; - this.dragOffset = { x: mouseX - crop.x, y: mouseY - crop.y }; - this.fadeOverlay(false); } moveCrop(mouseX: number, mouseY: number) { @@ -772,102 +631,116 @@ class TransformManager implements EditToolManager { } this.hasChanges = true; - const newX = Math.max(0, Math.min(mouseX - this.dragOffset.x, cropArea.clientWidth - this.region.width)); - const newY = Math.max(0, Math.min(mouseY - this.dragOffset.y, cropArea.clientHeight - this.region.height)); - - this.region = { - ...this.region, - x: newX, - y: newY, - }; + this.region.x = clamp(mouseX - this.dragAnchor.x, 0, cropArea.clientWidth - this.region.width); + this.region.y = clamp(mouseY - this.dragAnchor.y, 0, cropArea.clientHeight - this.region.height); this.draw(); } resizeCrop(mouseX: number, mouseY: number) { const canvas = this.cropAreaEl; - const crop = this.region; - const resizeSideValue = this.resizeSide; - if (!canvas || !resizeSideValue) { + const currentCrop = this.region; + if (!canvas) { return; } - this.fadeOverlay(false); + this.isInteracting = true; this.hasChanges = true; - const { x, y, width, height } = crop; + const { x, y, width, height } = currentCrop; const minSize = 50; - let newRegion = { ...crop }; + let newRegion = { ...currentCrop }; - switch (resizeSideValue) { - case 'left': { - const desiredWidth = width + (x - mouseX); - if (desiredWidth >= minSize && mouseX >= 0) { - const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); - const finalWidth = Math.max(minSize, Math.min(w, canvas.clientWidth)); - const finalHeight = Math.max(minSize, Math.min(h, canvas.clientHeight)); - newRegion = { - x: Math.max(0, x + width - finalWidth), - y, - width: finalWidth, - height: finalHeight, - }; - } + let desiredWidth = width; + let desiredHeight = height; + + // Width + switch (this.resizeSide) { + case ResizeBoundary.Left: + case ResizeBoundary.TopLeft: + case ResizeBoundary.BottomLeft: { + desiredWidth = Math.max(minSize, width + (x - Math.max(mouseX, 0))); break; } - case 'right': { - const desiredWidth = mouseX - x; - if (desiredWidth >= minSize && mouseX <= canvas.clientWidth) { - const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); - newRegion = { - ...newRegion, - width: Math.max(minSize, Math.min(w, canvas.clientWidth - x)), - height: Math.max(minSize, Math.min(h, canvas.clientHeight)), - }; - } + case ResizeBoundary.Right: + case ResizeBoundary.TopRight: + case ResizeBoundary.BottomRight: { + desiredWidth = Math.max(minSize, Math.max(mouseX, 0) - x); break; } - case 'top': { - const desiredHeight = height + (y - mouseY); - if (desiredHeight >= minSize && mouseY >= 0) { - const { newWidth: w, newHeight: h } = this.adjustDimensions( - width, - desiredHeight, - this.cropAspectRatio, - canvas.clientWidth, - canvas.clientHeight, - minSize, - ); - newRegion = { - x, - y: Math.max(0, y + height - h), - width: w, - height: h, - }; - } + } + + // Height + switch (this.resizeSide) { + case ResizeBoundary.Top: + case ResizeBoundary.TopLeft: + case ResizeBoundary.TopRight: { + desiredHeight = Math.max(minSize, height + (y - Math.max(mouseY, 0))); break; } - case 'bottom': { - const desiredHeight = mouseY - y; - if (desiredHeight >= minSize && mouseY <= canvas.clientHeight) { - const { newWidth: w, newHeight: h } = this.adjustDimensions( - width, - desiredHeight, - this.cropAspectRatio, - canvas.clientWidth, - canvas.clientHeight - y, - minSize, - ); - newRegion = { - ...newRegion, - width: w, - height: h, - }; - } + case ResizeBoundary.Bottom: + case ResizeBoundary.BottomLeft: + case ResizeBoundary.BottomRight: { + desiredHeight = Math.max(minSize, Math.max(mouseY, 0) - y); break; } - case 'top-left': { - const desiredWidth = width + (x - Math.max(mouseX, 0)); - const desiredHeight = height + (y - Math.max(mouseY, 0)); + } + + // Old + switch (this.resizeSide) { + case ResizeBoundary.Left: { + const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); + const finalWidth = clamp(w, minSize, canvas.clientWidth); + newRegion = { + x: Math.max(0, x + width - finalWidth), + y, + width: finalWidth, + height: clamp(h, minSize, canvas.clientHeight), + }; + break; + } + case ResizeBoundary.Right: { + const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); + newRegion = { + ...newRegion, + width: clamp(w, minSize, canvas.clientWidth - x), + height: clamp(h, minSize, canvas.clientHeight), + }; + break; + } + case ResizeBoundary.Top: { + const { newWidth: w, newHeight: h } = this.adjustDimensions( + desiredWidth, + desiredHeight, + this.cropAspectRatio, + canvas.clientWidth, + canvas.clientHeight, + minSize, + ); + newRegion = { + x, + y: Math.max(0, y + height - h), + width: w, + height: h, + }; + break; + } + case ResizeBoundary.Bottom: { + const { newWidth: w, newHeight: h } = this.adjustDimensions( + desiredWidth, + desiredHeight, + this.cropAspectRatio, + canvas.clientWidth, + canvas.clientHeight - y, + minSize, + ); + newRegion = { + ...newRegion, + width: w, + height: h, + }; + break; + } + case ResizeBoundary.TopLeft: { const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredWidth, desiredHeight, @@ -884,9 +757,7 @@ class TransformManager implements EditToolManager { }; break; } - case 'top-right': { - const desiredWidth = Math.max(mouseX, 0) - x; - const desiredHeight = height + (y - Math.max(mouseY, 0)); + case ResizeBoundary.TopRight: { const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredWidth, desiredHeight, @@ -903,9 +774,7 @@ class TransformManager implements EditToolManager { }; break; } - case 'bottom-left': { - const desiredWidth = width + (x - Math.max(mouseX, 0)); - const desiredHeight = Math.max(mouseY, 0) - y; + case ResizeBoundary.BottomLeft: { const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredWidth, desiredHeight, @@ -922,9 +791,7 @@ class TransformManager implements EditToolManager { }; break; } - case 'bottom-right': { - const desiredWidth = Math.max(mouseX, 0) - x; - const desiredHeight = Math.max(mouseY, 0) - y; + case ResizeBoundary.BottomRight: { const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredWidth, desiredHeight, @@ -952,95 +819,6 @@ class TransformManager implements EditToolManager { this.draw(); } - updateCursor(mouseX: number, mouseY: number) { - if (!this.cropAreaEl) { - return; - } - - let { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - } = this.isOnCropBoundary(mouseX, mouseY); - - if (this.normalizedRotation == 90) { - [onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [ - onLeftBoundary, - onTopBoundary, - onRightBoundary, - onBottomBoundary, - ]; - - [onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [ - onBottomLeftCorner, - onTopLeftCorner, - onTopRightCorner, - onBottomRightCorner, - ]; - } else if (this.normalizedRotation == 180) { - [onTopBoundary, onBottomBoundary] = [onBottomBoundary, onTopBoundary]; - [onLeftBoundary, onRightBoundary] = [onRightBoundary, onLeftBoundary]; - - [onTopLeftCorner, onBottomRightCorner] = [onBottomRightCorner, onTopLeftCorner]; - [onTopRightCorner, onBottomLeftCorner] = [onBottomLeftCorner, onTopRightCorner]; - } else if (this.normalizedRotation == 270) { - [onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [ - onRightBoundary, - onBottomBoundary, - onLeftBoundary, - onTopBoundary, - ]; - - [onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [ - onTopRightCorner, - onBottomRightCorner, - onBottomLeftCorner, - onTopLeftCorner, - ]; - } - - let cursorName: string; - if (onTopLeftCorner || onBottomRightCorner) { - cursorName = 'nwse-resize'; - } else if (onTopRightCorner || onBottomLeftCorner) { - cursorName = 'nesw-resize'; - } else if (onLeftBoundary || onRightBoundary) { - cursorName = 'ew-resize'; - } else if (onTopBoundary || onBottomBoundary) { - cursorName = 'ns-resize'; - } else if (this.isInCropArea(mouseX, mouseY)) { - cursorName = 'move'; - } else { - cursorName = 'default'; - } - - if (this.canvasCursor != cursorName && this.cropAreaEl && !editManager.isShowingConfirmDialog) { - this.canvasCursor = cursorName; - document.body.style.cursor = cursorName; - this.cropAreaEl.style.cursor = cursorName; - } - } - - fadeOverlay(toDark: boolean) { - const overlay = this.overlayEl; - const cropFrame = document.querySelector('.crop-frame'); - - if (toDark) { - overlay?.classList.remove('light'); - cropFrame?.classList.remove('resizing'); - } else { - overlay?.classList.add('light'); - cropFrame?.classList.add('resizing'); - } - - this.isInteracting = !toDark; - } - resetCrop() { this.cropAspectRatio = 'free'; this.region = { From 0ac3d6a83a633ed4832de88f923b95644b825479 Mon Sep 17 00:00:00 2001 From: Snowknight26 Date: Wed, 11 Mar 2026 13:38:08 -0500 Subject: [PATCH 126/150] fix(web): face selection box position resetting on browser resize (#26766) --- .../face-editor/face-editor.svelte | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index e84bc9fa0c..8b3d672bfe 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -74,6 +74,7 @@ canvas.add(faceRect); canvas.setActiveObject(faceRect); + setDefaultFaceRectanglePosition(faceRect); }; onMount(async () => { @@ -93,9 +94,19 @@ }; }); - $effect(() => { + const setDefaultFaceRectanglePosition = (faceRect: Rect) => { const { offsetX, offsetY } = imageContentMetrics; + faceRect.set({ + top: offsetY + 200, + left: offsetX + 200, + }); + + faceRect.setCoords(); + positionFaceSelector(); + }; + + $effect(() => { if (!canvas) { return; } @@ -109,15 +120,21 @@ return; } - faceRect.set({ - top: offsetY + 200, - left: offsetX + 200, - }); - - faceRect.setCoords(); - positionFaceSelector(); + if (!isFaceRectIntersectingCanvas(faceRect, canvas)) { + setDefaultFaceRectanglePosition(faceRect); + } }); + const isFaceRectIntersectingCanvas = (faceRect: Rect, canvas: Canvas) => { + const faceBox = faceRect.getBoundingRect(); + return !( + 0 > faceBox.left + faceBox.width || + 0 > faceBox.top + faceBox.height || + canvas.width < faceBox.left || + canvas.height < faceBox.top + ); + }; + const cancel = () => { isFaceEditMode.value = false; }; From d49d9956112a52a6a7f3b142fb1837a084590c04 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:03:19 +0100 Subject: [PATCH 127/150] chore(deps): update dependency exiftool-vendored to v35.13.1 (#26813) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7853a3000b..69e2da45f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -248,7 +248,7 @@ importers: version: 63.0.0(eslint@10.0.2(jiti@2.6.1)) exiftool-vendored: specifier: ^35.0.0 - version: 35.10.1 + version: 35.13.1 globals: specifier: ^17.0.0 version: 17.4.0 @@ -456,7 +456,7 @@ importers: version: 4.4.0 exiftool-vendored: specifier: ^35.0.0 - version: 35.10.1 + version: 35.13.1 express: specifier: ^5.1.0 version: 5.2.1 @@ -3919,8 +3919,8 @@ packages: peerDependencies: '@photo-sphere-viewer/core': 5.14.1 - '@photostructure/tz-lookup@11.4.0': - resolution: {integrity: sha512-yrFaDbQQZVJIzpCTnoghWO8Rttu22Hg7/JkfP3CM8UKniXYzD80cuv4UAsFkzP5Z6XWceWNsQTqUJHKyGNXzLg==} + '@photostructure/tz-lookup@11.5.0': + resolution: {integrity: sha512-0DVFriinZ7TeOnm9ytXeSL3NMFU87ZqMjgbPNkd8LgHFLcPg1BDyM1eewFYs+pPM+62S4fSP9Mtgijmn+6y95w==} '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} @@ -7210,17 +7210,17 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} - exiftool-vendored.exe@13.51.0: - resolution: {integrity: sha512-Q49J2c4e+XSGYDJf9PYMVI/IUfUkHLRsPUeDJ2ZekEBVLuw2g7ye9x0vQGWZKwEeZTlnXol7SeBJB0wtAmzM9w==} + exiftool-vendored.exe@13.52.0: + resolution: {integrity: sha512-8KSHKluRebjm2FL4S8rtwMLMELn/64CTI5BV3zmIdLnpS5N+aJEh6t9Y7aB7YBn5CwUao0T9/rxv4BMQqusukg==} os: [win32] - exiftool-vendored.pl@13.51.0: - resolution: {integrity: sha512-RhDM10w4kv5YNCvECj0aLXZXi0UWyzVo2OS4P/hpmyCHL+NGCkZ6N9z/Yc3ek0cEfCj4AiLhe8C96pnz/Fw9Yg==} + exiftool-vendored.pl@13.52.0: + resolution: {integrity: sha512-DXsMRRNdjordn1Ckcp1h9OQJRQy9VDDOcs60H+3IP+W9zRnpSU3HqQMhAVKyHR4FzioiGDbREN9BI/M1oDNoEw==} os: ['!win32'] hasBin: true - exiftool-vendored@35.10.1: - resolution: {integrity: sha512-orD61HdNcdlegfD80wI+3JE/n+iobYPztpFqv2drLHb1rb2QEKR1QY62r+O0wZHHNIf3Bje+xjweS1hxWignQA==} + exiftool-vendored@35.13.1: + resolution: {integrity: sha512-RiXz8RrJSBQ5jiZA1yMicmE/FgEFK/4QkU2KsqmlvTvouOOgANsNWv0f0uZbf098Ee933BE4bec5YAOBT0DuIQ==} engines: {node: '>=20.0.0'} expect-type@1.3.0: @@ -15989,7 +15989,7 @@ snapshots: '@photo-sphere-viewer/core': 5.14.1 three: 0.182.0 - '@photostructure/tz-lookup@11.4.0': {} + '@photostructure/tz-lookup@11.5.0': {} '@pkgjs/parseargs@0.11.0': optional: true @@ -19617,21 +19617,21 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 - exiftool-vendored.exe@13.51.0: + exiftool-vendored.exe@13.52.0: optional: true - exiftool-vendored.pl@13.51.0: {} + exiftool-vendored.pl@13.52.0: {} - exiftool-vendored@35.10.1: + exiftool-vendored@35.13.1: dependencies: - '@photostructure/tz-lookup': 11.4.0 + '@photostructure/tz-lookup': 11.5.0 '@types/luxon': 3.7.1 batch-cluster: 17.3.1 - exiftool-vendored.pl: 13.51.0 + exiftool-vendored.pl: 13.52.0 he: 1.2.0 luxon: 3.7.2 optionalDependencies: - exiftool-vendored.exe: 13.51.0 + exiftool-vendored.exe: 13.52.0 expect-type@1.3.0: {} From 4773788a8833ba45058c06094de20451c11e6b6c Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Wed, 11 Mar 2026 20:04:26 +0100 Subject: [PATCH 128/150] chore: more unused release workflow cleanup (#26817) --- .github/workflows/release.yml | 149 ---------------------------------- 1 file changed, 149 deletions(-) delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 30e9c1c7ca..0000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,149 +0,0 @@ -name: release.yml -on: - pull_request: - types: [closed] - paths: - - CHANGELOG.md - -jobs: - # Maybe double check PR source branch? - - merge_translations: - uses: ./.github/workflows/merge-translations.yml - permissions: - pull-requests: write - secrets: - PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }} - PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }} - - build_mobile: - uses: ./.github/workflows/build-mobile.yml - needs: merge_translations - permissions: - contents: read - secrets: - KEY_JKS: ${{ secrets.KEY_JKS }} - ALIAS: ${{ secrets.ALIAS }} - ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} - ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }} - # iOS secrets - APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} - APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} - APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }} - IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }} - IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} - IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }} - IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl - IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }} - FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }} - with: - ref: main - environment: production - - prepare_release: - runs-on: ubuntu-latest - needs: build_mobile - permissions: - actions: read # To download the app artifact - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 - with: - app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} - private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - token: ${{ steps.generate-token.outputs.token }} - persist-credentials: false - ref: main - - - name: Extract changelog - id: changelog - run: | - CHANGELOG_PATH=$RUNNER_TEMP/changelog.md - sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH - echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT - VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH) - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Download APK - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - name: release-apk-signed - github-token: ${{ steps.generate-token.outputs.token }} - - - name: Create draft release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 - with: - tag_name: ${{ steps.version.outputs.result }} - token: ${{ steps.generate-token.outputs.token }} - body_path: ${{ steps.changelog.outputs.path }} - draft: true - files: | - docker/docker-compose.yml - docker/docker-compose.rootless.yml - docker/example.env - docker/hwaccel.ml.yml - docker/hwaccel.transcoding.yml - docker/prometheus.yml - *.apk - - - name: Rename Outline document - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - continue-on-error: true - env: - OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }} - VERSION: ${{ steps.changelog.outputs.version }} - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - const outlineKey = process.env.OUTLINE_API_KEY; - const version = process.env.VERSION; - const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'; - const baseUrl = 'https://outline.immich.cloud'; - - const listResponse = await fetch(`${baseUrl}/api/documents.list`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${outlineKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ parentDocumentId }) - }); - - if (!listResponse.ok) { - throw new Error(`Outline list failed: ${listResponse.statusText}`); - } - - const listData = await listResponse.json(); - const allDocuments = listData.data || []; - const document = allDocuments.find(doc => doc.title === 'next'); - - if (document) { - console.log(`Found document 'next', renaming to '${version}'...`); - - const updateResponse = await fetch(`${baseUrl}/api/documents.update`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${outlineKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - id: document.id, - title: version - }) - }); - - if (!updateResponse.ok) { - throw new Error(`Failed to rename document: ${updateResponse.statusText}`); - } - } else { - console.log('No document titled "next" found to rename'); - } From 471c27cd33adf01ef40145de59a0e6e46a3ff231 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:15:18 +0000 Subject: [PATCH 129/150] chore(mobile): remove background from asset viewer back button (#26851) We recently changed the asset viewer to use a gradient. The circle button looks out of place now. --- .../widgets/asset_viewer/viewer_top_app_bar.widget.dart | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index 397cd98ace..ae7dd85396 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -113,17 +113,14 @@ class _AppBarBackButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); - final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black; - final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white; - return Padding( padding: const EdgeInsets.only(left: 12.0), child: ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: backgroundColor, + backgroundColor: showingDetails ? context.colorScheme.surface : Colors.transparent, shape: const CircleBorder(), iconSize: 22, - iconColor: foregroundColor, + iconColor: showingDetails ? context.colorScheme.onSurface : Colors.white, padding: EdgeInsets.zero, elevation: showingDetails ? 4 : 0, ), From 6c531e0a5a34af52b676bf0c5d1e2be8bee8f985 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 11 Mar 2026 14:15:31 -0500 Subject: [PATCH 130/150] chore: add shadow to video play/pause icon shadow (#26836) --- .../asset_viewer/animated_play_pause.dart | 35 +++++++++++++++---- .../widgets/asset_viewer/video_controls.dart | 19 +++++----- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/mobile/lib/widgets/asset_viewer/animated_play_pause.dart b/mobile/lib/widgets/asset_viewer/animated_play_pause.dart index e7ceac6105..4be7f49b5a 100644 --- a/mobile/lib/widgets/asset_viewer/animated_play_pause.dart +++ b/mobile/lib/widgets/asset_viewer/animated_play_pause.dart @@ -1,12 +1,15 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; /// A widget that animates implicitly between a play and a pause icon. class AnimatedPlayPause extends StatefulWidget { - const AnimatedPlayPause({super.key, required this.playing, this.size, this.color}); + const AnimatedPlayPause({super.key, required this.playing, this.size, this.color, this.shadows}); final double? size; final bool playing; final Color? color; + final List? shadows; @override State createState() => AnimatedPlayPauseState(); @@ -39,12 +42,32 @@ class AnimatedPlayPauseState extends State with SingleTickerP @override Widget build(BuildContext context) { + final icon = AnimatedIcon( + color: widget.color, + size: widget.size, + icon: AnimatedIcons.play_pause, + progress: animationController, + ); + return Center( - child: AnimatedIcon( - color: widget.color, - size: widget.size, - icon: AnimatedIcons.play_pause, - progress: animationController, + child: Stack( + alignment: Alignment.center, + children: [ + for (final shadow in widget.shadows ?? const []) + Transform.translate( + offset: shadow.offset, + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: shadow.blurRadius / 2, sigmaY: shadow.blurRadius / 2), + child: AnimatedIcon( + color: shadow.color, + size: widget.size, + icon: AnimatedIcons.play_pause, + progress: animationController, + ), + ), + ), + icon, + ], ), ); } diff --git a/mobile/lib/widgets/asset_viewer/video_controls.dart b/mobile/lib/widgets/asset_viewer/video_controls.dart index 4eed3903c9..85707c82ea 100644 --- a/mobile/lib/widgets/asset_viewer/video_controls.dart +++ b/mobile/lib/widgets/asset_viewer/video_controls.dart @@ -72,17 +72,14 @@ class VideoControls extends HookConsumerWidget { children: [ Row( children: [ - IconTheme( - data: const IconThemeData(shadows: _controlShadows), - child: IconButton( - iconSize: 32, - padding: const EdgeInsets.all(12), - constraints: const BoxConstraints(), - icon: isFinished - ? const Icon(Icons.replay, color: Colors.white, size: 32) - : AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying), - onPressed: () => _toggle(ref, isCasting), - ), + IconButton( + iconSize: 32, + padding: const EdgeInsets.all(12), + constraints: const BoxConstraints(), + icon: isFinished + ? const Icon(Icons.replay, color: Colors.white, size: 32, shadows: _controlShadows) + : AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying, shadows: _controlShadows), + onPressed: () => _toggle(ref, isCasting), ), const Spacer(), Text( From 5c3777ab467cfc634e66abefdc58a68121efb0cf Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 12 Mar 2026 10:37:29 -0400 Subject: [PATCH 131/150] fix(web): fix zoom touch event handling (#26866) fix(web): fix zoom touch event handling and add clarifying comments - Suppress Safari's synthetic dblclick on double-tap which conflicts with zoom-image's touchstart-based zoom - Add comment explaining pointer-events-none on zoom transform wrapper - Add comments for touchAction and overflow style overrides --- web/src/lib/actions/zoom-image.ts | 20 ++++++++++++++++++++ web/src/lib/components/AdaptiveImage.svelte | 3 ++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index 66659997d2..35c3d3a106 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -23,7 +23,25 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea node.addEventListener('wheel', onInteractionStart, { capture: true }); node.addEventListener('pointerdown', onInteractionStart, { capture: true }); + // Suppress Safari's synthetic dblclick on double-tap. Without this, zoom-image's touchstart + // handler zooms to maxZoom (10x), then Safari's synthetic dblclick triggers photo-viewer's + // handler which conflicts. Chrome does not fire synthetic dblclick on touch. + let lastPointerWasTouch = false; + const trackPointerType = (event: PointerEvent) => { + lastPointerWasTouch = event.pointerType === 'touch'; + }; + const suppressTouchDblClick = (event: MouseEvent) => { + if (lastPointerWasTouch) { + event.stopImmediatePropagation(); + } + }; + node.addEventListener('pointerdown', trackPointerType, { capture: true }); + node.addEventListener('dblclick', suppressTouchDblClick, { capture: true }); + + // Allow zoomed content to render outside the container bounds node.style.overflow = 'visible'; + // Prevent browser handling of touch gestures so zoom-image can manage them + node.style.touchAction = 'none'; return { update(newOptions?: { disabled?: boolean }) { options = newOptions; @@ -34,6 +52,8 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea } node.removeEventListener('wheel', onInteractionStart, { capture: true }); node.removeEventListener('pointerdown', onInteractionStart, { capture: true }); + node.removeEventListener('pointerdown', trackPointerType, { capture: true }); + node.removeEventListener('dblclick', suppressTouchDblClick, { capture: true }); zoomInstance.cleanup(); }, }; diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte index 92e3fad2d3..fad4d49d1b 100644 --- a/web/src/lib/components/AdaptiveImage.svelte +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -162,8 +162,9 @@
{@render backdrop?.()} +
From 3bd37ebbfbf4dfacbe98ca3f20a79b5cb1c6efb3 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 12 Mar 2026 10:53:46 -0400 Subject: [PATCH 132/150] refactor: clean class (#26879) --- pnpm-lock.yaml | 20 +++++++---- web/package.json | 2 ++ web/src/lib/components/AlphaBackground.svelte | 5 +-- web/src/lib/components/LoadingDots.svelte | 3 +- web/src/lib/components/QueueCard.svelte | 11 +++++-- web/src/lib/components/QueueCardBadge.svelte | 26 ++++++++------- web/src/lib/components/QueueCardButton.svelte | 33 +++++++++---------- web/src/lib/components/QueueGraph.svelte | 5 +-- web/src/lib/index.spec.ts | 15 +++++++++ web/src/lib/index.ts | 16 +++++++++ 10 files changed, 93 insertions(+), 43 deletions(-) create mode 100644 web/src/lib/index.spec.ts create mode 100644 web/src/lib/index.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69e2da45f7..9d47ba73f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -845,6 +845,12 @@ importers: tabbable: specifier: ^6.2.0 version: 6.4.0 + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + tailwind-variants: + specifier: ^3.2.2 + version: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1) thumbhash: specifier: ^0.1.1 version: 0.1.1 @@ -11252,8 +11258,8 @@ packages: tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} - tailwind-merge@3.4.0: - resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} tailwind-variants@3.2.2: resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==} @@ -14959,8 +14965,8 @@ snapshots: simple-icons: 16.9.0 svelte: 5.53.7 svelte-highlight: 7.9.0 - tailwind-merge: 3.4.0 - tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1) + tailwind-merge: 3.5.0 + tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1) tailwindcss: 4.2.1 transitivePeerDependencies: - '@sveltejs/kit' @@ -24554,13 +24560,13 @@ snapshots: tabbable@6.4.0: {} - tailwind-merge@3.4.0: {} + tailwind-merge@3.5.0: {} - tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1): + tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1): dependencies: tailwindcss: 4.2.1 optionalDependencies: - tailwind-merge: 3.4.0 + tailwind-merge: 3.5.0 tailwindcss-email-variants@3.0.5(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)): dependencies: diff --git a/web/package.json b/web/package.json index 67460e87e5..9c63b2e5f5 100644 --- a/web/package.json +++ b/web/package.json @@ -60,6 +60,8 @@ "svelte-maplibre": "^1.2.5", "svelte-persisted-store": "^0.12.0", "tabbable": "^6.2.0", + "tailwind-merge": "^3.5.0", + "tailwind-variants": "^3.2.2", "thumbhash": "^0.1.1", "transformation-matrix": "^3.1.0", "uplot": "^1.6.32" diff --git a/web/src/lib/components/AlphaBackground.svelte b/web/src/lib/components/AlphaBackground.svelte index c0d8536a2f..5c3869d587 100644 --- a/web/src/lib/components/AlphaBackground.svelte +++ b/web/src/lib/components/AlphaBackground.svelte @@ -1,11 +1,12 @@ -
+
diff --git a/web/src/lib/components/LoadingDots.svelte b/web/src/lib/components/LoadingDots.svelte index 3dcfcb8122..7e6692021f 100644 --- a/web/src/lib/components/LoadingDots.svelte +++ b/web/src/lib/components/LoadingDots.svelte @@ -1,4 +1,5 @@ -
+
{#each [0, 1, 2] as i (i)} diff --git a/web/src/lib/components/QueueCard.svelte b/web/src/lib/components/QueueCard.svelte index b7cde7b8f1..448558ed9f 100644 --- a/web/src/lib/components/QueueCard.svelte +++ b/web/src/lib/components/QueueCard.svelte @@ -1,4 +1,5 @@ - -
+
{@render children?.()}
diff --git a/web/src/lib/components/QueueCardButton.svelte b/web/src/lib/components/QueueCardButton.svelte index f71d8a3e44..9964b8fd1a 100644 --- a/web/src/lib/components/QueueCardButton.svelte +++ b/web/src/lib/components/QueueCardButton.svelte @@ -4,6 +4,7 @@ - diff --git a/web/src/lib/components/QueueGraph.svelte b/web/src/lib/components/QueueGraph.svelte index f2a23216df..01327643a1 100644 --- a/web/src/lib/components/QueueGraph.svelte +++ b/web/src/lib/components/QueueGraph.svelte @@ -1,4 +1,5 @@ -
+
{#if data[0].length === 0} {/if} diff --git a/web/src/lib/index.spec.ts b/web/src/lib/index.spec.ts new file mode 100644 index 0000000000..bda5a9e722 --- /dev/null +++ b/web/src/lib/index.spec.ts @@ -0,0 +1,15 @@ +import { cleanClass } from '$lib'; + +describe('cleanClass', () => { + it('should return a string of class names', () => { + expect(cleanClass('class1', 'class2', 'class3')).toBe('class1 class2 class3'); + }); + + it('should filter out undefined, null, and false values', () => { + expect(cleanClass('class1', undefined, 'class2', null, 'class3', false)).toBe('class1 class2 class3'); + }); + + it('should unnest arrays', () => { + expect(cleanClass('class1', ['class2', 'class3'])).toBe('class1 class2 class3'); + }); +}); diff --git a/web/src/lib/index.ts b/web/src/lib/index.ts new file mode 100644 index 0000000000..b4fc195626 --- /dev/null +++ b/web/src/lib/index.ts @@ -0,0 +1,16 @@ +import { twMerge } from 'tailwind-merge'; + +export const cleanClass = (...classNames: unknown[]) => { + return twMerge( + classNames + .flatMap((className) => (Array.isArray(className) ? className : [className])) + .filter((className) => { + if (!className || typeof className === 'boolean') { + return false; + } + + return typeof className === 'string'; + }) + .join(' '), + ); +}; From d4605b21d99fa7f9bc21689e932d26ef55870874 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 12 Mar 2026 10:55:33 -0400 Subject: [PATCH 133/150] refactor: external links (#26880) --- .../admin-settings/AuthSettings.svelte | 11 ++------- .../admin-settings/BackupSettings.svelte | 10 +++----- .../admin-settings/FFmpegSettings.svelte | 14 ++++------- .../admin-settings/LibrarySettings.svelte | 8 +++---- .../admin-settings/MapSettings.svelte | 10 ++------ .../StorageTemplateSettings.svelte | 20 ++++------------ .../onboarding-page/onboarding-backup.svelte | 24 +++++-------------- .../AuthDisableLoginConfirmModal.svelte | 11 ++------- 8 files changed, 26 insertions(+), 82 deletions(-) diff --git a/web/src/lib/components/admin-settings/AuthSettings.svelte b/web/src/lib/components/admin-settings/AuthSettings.svelte index aec1761998..25af7bf2c1 100644 --- a/web/src/lib/components/admin-settings/AuthSettings.svelte +++ b/web/src/lib/components/admin-settings/AuthSettings.svelte @@ -11,7 +11,7 @@ import AuthDisableLoginConfirmModal from '$lib/modals/AuthDisableLoginConfirmModal.svelte'; import { handleError } from '$lib/utils/handle-error'; import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin } from '@immich/sdk'; - import { Button, modalManager, Text, toastManager } from '@immich/ui'; + import { Button, Link, modalManager, Text, toastManager } from '@immich/ui'; import { mdiRestart } from '@mdi/js'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -75,14 +75,7 @@ {#snippet children({ message })} - - {message} - + {message} {/snippet} diff --git a/web/src/lib/components/admin-settings/BackupSettings.svelte b/web/src/lib/components/admin-settings/BackupSettings.svelte index fc374ddd6f..7fd22a2b6d 100644 --- a/web/src/lib/components/admin-settings/BackupSettings.svelte +++ b/web/src/lib/components/admin-settings/BackupSettings.svelte @@ -7,6 +7,7 @@ import FormatMessage from '$lib/elements/FormatMessage.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; + import { Link } from '@immich/ui'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -52,15 +53,10 @@

{#snippet children({ message })} - + {message}
-
+ {/snippet}

diff --git a/web/src/lib/components/admin-settings/FFmpegSettings.svelte b/web/src/lib/components/admin-settings/FFmpegSettings.svelte index e062b616b3..95aa9d74f2 100644 --- a/web/src/lib/components/admin-settings/FFmpegSettings.svelte +++ b/web/src/lib/components/admin-settings/FFmpegSettings.svelte @@ -18,7 +18,7 @@ VideoCodec, VideoContainer, } from '@immich/sdk'; - import { Icon } from '@immich/ui'; + import { Icon, Link } from '@immich/ui'; import { mdiHelpCircleOutline } from '@mdi/js'; import { isEqual, sortBy } from 'lodash-es'; import { t } from 'svelte-i18n'; @@ -38,17 +38,11 @@ {#snippet children({ tag, message })} {#if tag === 'h264-link'} - - {message} - + {message} {:else if tag === 'hevc-link'} - - {message} - + {message} {:else if tag === 'vp9-link'} - - {message} - + {message} {/if} {/snippet} diff --git a/web/src/lib/components/admin-settings/LibrarySettings.svelte b/web/src/lib/components/admin-settings/LibrarySettings.svelte index a91a5eb97a..52c2eb8d4f 100644 --- a/web/src/lib/components/admin-settings/LibrarySettings.svelte +++ b/web/src/lib/components/admin-settings/LibrarySettings.svelte @@ -8,6 +8,7 @@ import FormatMessage from '$lib/elements/FormatMessage.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; + import { Link } from '@immich/ui'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -73,14 +74,11 @@

{#snippet children({ message })} - {message} - + {/snippet}

diff --git a/web/src/lib/components/admin-settings/MapSettings.svelte b/web/src/lib/components/admin-settings/MapSettings.svelte index 692a5cfcf5..5888c82611 100644 --- a/web/src/lib/components/admin-settings/MapSettings.svelte +++ b/web/src/lib/components/admin-settings/MapSettings.svelte @@ -7,6 +7,7 @@ import FormatMessage from '$lib/elements/FormatMessage.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; + import { Link } from '@immich/ui'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -54,14 +55,7 @@

{#snippet children({ message })} - - {message} - + {message} {/snippet}

diff --git a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte index 7018bc5d04..8ccb3f7781 100644 --- a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte +++ b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte @@ -12,7 +12,7 @@ import { handleSystemConfigSave } from '$lib/services/system-config.service'; import { user } from '$lib/stores/user.store'; import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk'; - import { Heading, LoadingSpinner, Text } from '@immich/ui'; + import { Heading, Link, LoadingSpinner, Text } from '@immich/ui'; import handlebar from 'handlebars'; import * as luxon from 'luxon'; import { onDestroy } from 'svelte'; @@ -112,23 +112,11 @@ {#snippet children({ tag, message })} {#if tag === 'template-link'} - - {message} - + {message} {:else if tag === 'implications-link'} - + {message} - + {/if} {/snippet} diff --git a/web/src/lib/components/onboarding-page/onboarding-backup.svelte b/web/src/lib/components/onboarding-page/onboarding-backup.svelte index 146661884b..7d7f51c392 100644 --- a/web/src/lib/components/onboarding-page/onboarding-backup.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-backup.svelte @@ -1,6 +1,6 @@
diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte index b4772cc1c4..76956fbb26 100644 --- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte @@ -1,4 +1,5 @@ @@ -19,7 +20,7 @@ (isBroken = true)} - class="size-full rounded-xl object-cover aspect-square {className}" + class={cleanClass('size-full rounded-xl object-cover aspect-square', className)} data-testid="album-image" draggable="false" loading={preload ? 'eager' : 'lazy'} diff --git a/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte index 1e09c6bcfa..319a5e7f9e 100644 --- a/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte +++ b/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte @@ -1,16 +1,18 @@ Date: Thu, 12 Mar 2026 19:48:00 +0100 Subject: [PATCH 135/150] fix(server): restrict individual shared link asset removal to owners (#26868) * fix(server): restrict individual shared link asset removal to owners * make open-api --- .../specs/server/api/shared-link.e2e-spec.ts | 10 ++++++++ e2e/src/specs/web/shared-link.e2e-spec.ts | 24 +++++++++++++++++++ mobile/openapi/lib/api/shared_links_api.dart | 21 +++------------- open-api/immich-openapi-specs.json | 17 +------------ open-api/typescript-sdk/src/fetch-client.ts | 9 ++----- .../shared-link.controller.spec.ts | 15 +++++++++++- .../src/controllers/shared-link.controller.ts | 2 +- web/src/lib/services/shared-link.service.ts | 2 -- 8 files changed, 55 insertions(+), 45 deletions(-) diff --git a/e2e/src/specs/server/api/shared-link.e2e-spec.ts b/e2e/src/specs/server/api/shared-link.e2e-spec.ts index 80232beb75..00c455d6cb 100644 --- a/e2e/src/specs/server/api/shared-link.e2e-spec.ts +++ b/e2e/src/specs/server/api/shared-link.e2e-spec.ts @@ -438,6 +438,16 @@ describe('/shared-links', () => { expect(body).toEqual(errorDto.badRequest('Invalid shared link type')); }); + it('should reject guests removing assets from an individual shared link', async () => { + const { status, body } = await request(app) + .delete(`/shared-links/${linkWithAssets.id}/assets`) + .query({ key: linkWithAssets.key }) + .send({ assetIds: [asset1.id] }); + + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + it('should remove assets from a shared link (individual)', async () => { const { status, body } = await request(app) .delete(`/shared-links/${linkWithAssets.id}/assets`) diff --git a/e2e/src/specs/web/shared-link.e2e-spec.ts b/e2e/src/specs/web/shared-link.e2e-spec.ts index f6d1ec98d4..8380840935 100644 --- a/e2e/src/specs/web/shared-link.e2e-spec.ts +++ b/e2e/src/specs/web/shared-link.e2e-spec.ts @@ -12,15 +12,18 @@ import { asBearerAuth, utils } from 'src/utils'; test.describe('Shared Links', () => { let admin: LoginResponseDto; let asset: AssetMediaResponseDto; + let asset2: AssetMediaResponseDto; let album: AlbumResponseDto; let sharedLink: SharedLinkResponseDto; let sharedLinkPassword: SharedLinkResponseDto; + let individualSharedLink: SharedLinkResponseDto; test.beforeAll(async () => { utils.initSdk(); await utils.resetDatabase(); admin = await utils.adminSetup(); asset = await utils.createAsset(admin.accessToken); + asset2 = await utils.createAsset(admin.accessToken); album = await createAlbum( { createAlbumDto: { @@ -39,6 +42,10 @@ test.describe('Shared Links', () => { albumId: album.id, password: 'test-password', }); + individualSharedLink = await utils.createSharedLink(admin.accessToken, { + type: SharedLinkType.Individual, + assetIds: [asset.id, asset2.id], + }); }); test('download from a shared link', async ({ page }) => { @@ -109,4 +116,21 @@ test.describe('Shared Links', () => { await page.waitForURL('/photos'); await page.locator(`[data-asset-id="${asset.id}"]`).waitFor(); }); + + test('owner can remove assets from an individual shared link', async ({ context, page }) => { + await utils.setAuthCookies(context, admin.accessToken); + + await page.goto(`/share/${individualSharedLink.key}`); + await page.locator(`[data-asset="${asset.id}"]`).waitFor(); + await expect(page.locator(`[data-asset]`)).toHaveCount(2); + + await page.locator(`[data-asset="${asset.id}"]`).hover(); + await page.locator(`[data-asset="${asset.id}"] [role="checkbox"]`).click(); + + await page.getByRole('button', { name: 'Remove from shared link' }).click(); + await page.getByRole('button', { name: 'Remove', exact: true }).click(); + + await expect(page.locator(`[data-asset="${asset.id}"]`)).toHaveCount(0); + await expect(page.locator(`[data-asset="${asset2.id}"]`)).toHaveCount(1); + }); }); diff --git a/mobile/openapi/lib/api/shared_links_api.dart b/mobile/openapi/lib/api/shared_links_api.dart index 37eeffcf46..084662ace8 100644 --- a/mobile/openapi/lib/api/shared_links_api.dart +++ b/mobile/openapi/lib/api/shared_links_api.dart @@ -427,11 +427,7 @@ class SharedLinksApi { /// * [String] id (required): /// /// * [AssetIdsDto] assetIdsDto (required): - /// - /// * [String] key: - /// - /// * [String] slug: - Future removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async { + Future removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async { // ignore: prefer_const_declarations final apiPath = r'/shared-links/{id}/assets' .replaceAll('{id}', id); @@ -443,13 +439,6 @@ class SharedLinksApi { final headerParams = {}; final formParams = {}; - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - if (slug != null) { - queryParams.addAll(_queryParams('', 'slug', slug)); - } - const contentTypes = ['application/json']; @@ -473,12 +462,8 @@ class SharedLinksApi { /// * [String] id (required): /// /// * [AssetIdsDto] assetIdsDto (required): - /// - /// * [String] key: - /// - /// * [String] slug: - Future?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async { - final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto, key: key, slug: slug, ); + Future?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto,) async { + final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d2eb322009..2227273535 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11605,22 +11605,6 @@ "format": "uuid", "type": "string" } - }, - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "slug", - "required": false, - "in": "query", - "schema": { - "type": "string" - } } ], "requestBody": { @@ -11677,6 +11661,7 @@ "state": "Stable" } ], + "x-immich-permission": "sharedLink.update", "x-immich-state": "Stable" }, "put": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 5c8ac6dbc1..5a47cf2707 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -5987,19 +5987,14 @@ export function updateSharedLink({ id, sharedLinkEditDto }: { /** * Remove assets from a shared link */ -export function removeSharedLinkAssets({ id, key, slug, assetIdsDto }: { +export function removeSharedLinkAssets({ id, assetIdsDto }: { id: string; - key?: string; - slug?: string; assetIdsDto: AssetIdsDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetIdsResponseDto[]; - }>(`/shared-links/${encodeURIComponent(id)}/assets${QS.query(QS.explode({ - key, - slug - }))}`, oazapfts.json({ + }>(`/shared-links/${encodeURIComponent(id)}/assets`, oazapfts.json({ ...opts, method: "DELETE", body: assetIdsDto diff --git a/server/src/controllers/shared-link.controller.spec.ts b/server/src/controllers/shared-link.controller.spec.ts index 96c84040ca..d8b89d0029 100644 --- a/server/src/controllers/shared-link.controller.spec.ts +++ b/server/src/controllers/shared-link.controller.spec.ts @@ -1,7 +1,8 @@ import { SharedLinkController } from 'src/controllers/shared-link.controller'; -import { SharedLinkType } from 'src/enum'; +import { Permission, SharedLinkType } from 'src/enum'; import { SharedLinkService } from 'src/services/shared-link.service'; import request from 'supertest'; +import { factory } from 'test/small.factory'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; describe(SharedLinkController.name, () => { @@ -31,4 +32,16 @@ describe(SharedLinkController.name, () => { expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null })); }); }); + + describe('DELETE /shared-links/:id/assets', () => { + it('should require shared link update permission', async () => { + await request(ctx.getHttpServer()).delete(`/shared-links/${factory.uuid()}/assets`).send({ assetIds: [] }); + + expect(ctx.authenticate).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ permission: Permission.SharedLinkUpdate, sharedLinkRoute: false }), + }), + ); + }); + }); }); diff --git a/server/src/controllers/shared-link.controller.ts b/server/src/controllers/shared-link.controller.ts index 1f91409e80..c7ba589a9f 100644 --- a/server/src/controllers/shared-link.controller.ts +++ b/server/src/controllers/shared-link.controller.ts @@ -180,7 +180,7 @@ export class SharedLinkController { } @Delete(':id/assets') - @Authenticated({ sharedLink: true }) + @Authenticated({ permission: Permission.SharedLinkUpdate }) @Endpoint({ summary: 'Remove assets from a shared link', description: diff --git a/web/src/lib/services/shared-link.service.ts b/web/src/lib/services/shared-link.service.ts index fc4bbe11c0..135c67b95a 100644 --- a/web/src/lib/services/shared-link.service.ts +++ b/web/src/lib/services/shared-link.service.ts @@ -1,5 +1,4 @@ import { goto } from '$app/navigation'; -import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import QrCodeModal from '$lib/modals/QrCodeModal.svelte'; @@ -138,7 +137,6 @@ export const handleRemoveSharedLinkAssets = async (sharedLink: SharedLinkRespons try { const results = await removeSharedLinkAssets({ - ...authManager.params, id: sharedLink.id, assetIdsDto: { assetIds }, }); From 001d7d083f0d7d541a54a9495961746992e212a8 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:48:49 +0100 Subject: [PATCH 136/150] refactor: small test factories (#26862) --- server/src/services/activity.service.spec.ts | 45 +++-- server/src/services/api-key.service.spec.ts | 77 ++++---- server/src/services/asset.service.spec.ts | 9 +- server/src/services/auth.service.spec.ts | 184 +++++++++--------- server/src/services/cli.service.spec.ts | 10 +- server/src/services/map.service.spec.ts | 4 +- server/src/services/partner.service.spec.ts | 53 ++--- server/src/services/session.service.spec.ts | 13 +- server/src/services/sync.service.spec.ts | 3 +- .../src/services/user-admin.service.spec.ts | 7 +- server/src/services/user.service.spec.ts | 21 +- server/test/factories/activity.factory.ts | 42 ++++ server/test/factories/api-key.factory.ts | 42 ++++ server/test/factories/auth.factory.ts | 17 +- server/test/factories/partner.factory.ts | 50 +++++ server/test/factories/session.factory.ts | 35 ++++ server/test/factories/types.ts | 8 + server/test/small.factory.ts | 183 +---------------- 18 files changed, 414 insertions(+), 389 deletions(-) create mode 100644 server/test/factories/activity.factory.ts create mode 100644 server/test/factories/api-key.factory.ts create mode 100644 server/test/factories/partner.factory.ts create mode 100644 server/test/factories/session.factory.ts diff --git a/server/src/services/activity.service.spec.ts b/server/src/services/activity.service.spec.ts index d1a9f53a20..03cd0132c1 100644 --- a/server/src/services/activity.service.spec.ts +++ b/server/src/services/activity.service.spec.ts @@ -1,8 +1,10 @@ import { BadRequestException } from '@nestjs/common'; import { ReactionType } from 'src/dtos/activity.dto'; import { ActivityService } from 'src/services/activity.service'; +import { ActivityFactory } from 'test/factories/activity.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; import { getForActivity } from 'test/mappers'; -import { factory, newUuid, newUuids } from 'test/small.factory'; +import { newUuid, newUuids } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(ActivityService.name, () => { @@ -24,7 +26,7 @@ describe(ActivityService.name, () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.activity.search.mockResolvedValue([]); - await expect(sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId })).resolves.toEqual([]); + await expect(sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId })).resolves.toEqual([]); expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: undefined }); }); @@ -36,7 +38,7 @@ describe(ActivityService.name, () => { mocks.activity.search.mockResolvedValue([]); await expect( - sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId, type: ReactionType.LIKE }), + sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId, type: ReactionType.LIKE }), ).resolves.toEqual([]); expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: true }); @@ -48,7 +50,9 @@ describe(ActivityService.name, () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.activity.search.mockResolvedValue([]); - await expect(sut.getAll(factory.auth(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual([]); + await expect(sut.getAll(AuthFactory.create(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual( + [], + ); expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: false }); }); @@ -61,7 +65,10 @@ describe(ActivityService.name, () => { mocks.activity.getStatistics.mockResolvedValue({ comments: 1, likes: 3 }); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); - await expect(sut.getStatistics(factory.auth(), { assetId, albumId })).resolves.toEqual({ comments: 1, likes: 3 }); + await expect(sut.getStatistics(AuthFactory.create(), { assetId, albumId })).resolves.toEqual({ + comments: 1, + likes: 3, + }); }); }); @@ -70,18 +77,18 @@ describe(ActivityService.name, () => { const [albumId, assetId] = newUuids(); await expect( - sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), + sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), ).rejects.toBeInstanceOf(BadRequestException); }); it('should create a comment', async () => { const [albumId, assetId, userId] = newUuids(); - const activity = factory.activity({ albumId, assetId, userId }); + const activity = ActivityFactory.create({ albumId, assetId, userId }); mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); mocks.activity.create.mockResolvedValue(getForActivity(activity)); - await sut.create(factory.auth({ user: { id: userId } }), { + await sut.create(AuthFactory.create({ id: userId }), { albumId, assetId, type: ReactionType.COMMENT, @@ -99,38 +106,38 @@ describe(ActivityService.name, () => { it('should fail because activity is disabled for the album', async () => { const [albumId, assetId] = newUuids(); - const activity = factory.activity({ albumId, assetId }); + const activity = ActivityFactory.create({ albumId, assetId }); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.activity.create.mockResolvedValue(getForActivity(activity)); await expect( - sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), + sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), ).rejects.toBeInstanceOf(BadRequestException); }); it('should create a like', async () => { const [albumId, assetId, userId] = newUuids(); - const activity = factory.activity({ userId, albumId, assetId, isLiked: true }); + const activity = ActivityFactory.create({ userId, albumId, assetId, isLiked: true }); mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); mocks.activity.create.mockResolvedValue(getForActivity(activity)); mocks.activity.search.mockResolvedValue([]); - await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE }); + await sut.create(AuthFactory.create({ id: userId }), { albumId, assetId, type: ReactionType.LIKE }); expect(mocks.activity.create).toHaveBeenCalledWith({ userId: activity.userId, albumId, assetId, isLiked: true }); }); it('should skip if like exists', async () => { const [albumId, assetId] = newUuids(); - const activity = factory.activity({ albumId, assetId, isLiked: true }); + const activity = ActivityFactory.create({ albumId, assetId, isLiked: true }); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); mocks.activity.search.mockResolvedValue([getForActivity(activity)]); - await sut.create(factory.auth(), { albumId, assetId, type: ReactionType.LIKE }); + await sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.LIKE }); expect(mocks.activity.create).not.toHaveBeenCalled(); }); @@ -138,29 +145,29 @@ describe(ActivityService.name, () => { describe('delete', () => { it('should require access', async () => { - await expect(sut.delete(factory.auth(), newUuid())).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.delete(AuthFactory.create(), newUuid())).rejects.toBeInstanceOf(BadRequestException); expect(mocks.activity.delete).not.toHaveBeenCalled(); }); it('should let the activity owner delete a comment', async () => { - const activity = factory.activity(); + const activity = ActivityFactory.create(); mocks.access.activity.checkOwnerAccess.mockResolvedValue(new Set([activity.id])); mocks.activity.delete.mockResolvedValue(); - await sut.delete(factory.auth(), activity.id); + await sut.delete(AuthFactory.create(), activity.id); expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id); }); it('should let the album owner delete a comment', async () => { - const activity = factory.activity(); + const activity = ActivityFactory.create(); mocks.access.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set([activity.id])); mocks.activity.delete.mockResolvedValue(); - await sut.delete(factory.auth(), activity.id); + await sut.delete(AuthFactory.create(), activity.id); expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id); }); diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 3a31dbbea1..68165d642f 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -1,7 +1,10 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { Permission } from 'src/enum'; import { ApiKeyService } from 'src/services/api-key.service'; -import { factory, newUuid } from 'test/small.factory'; +import { ApiKeyFactory } from 'test/factories/api-key.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { SessionFactory } from 'test/factories/session.factory'; +import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(ApiKeyService.name, () => { @@ -14,8 +17,8 @@ describe(ApiKeyService.name, () => { describe('create', () => { it('should create a new key', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.All] }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.All] }); const key = 'super-secret'; mocks.crypto.randomBytesAsText.mockReturnValue(key); @@ -34,8 +37,8 @@ describe(ApiKeyService.name, () => { }); it('should not require a name', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); const key = 'super-secret'; mocks.crypto.randomBytesAsText.mockReturnValue(key); @@ -54,7 +57,9 @@ describe(ApiKeyService.name, () => { }); it('should throw an error if the api key does not have sufficient permissions', async () => { - const auth = factory.auth({ apiKey: { permissions: [Permission.AssetRead] } }); + const auth = AuthFactory.from() + .apiKey({ permissions: [Permission.AssetRead] }) + .build(); await expect(sut.create(auth, { permissions: [Permission.AssetUpdate] })).rejects.toBeInstanceOf( BadRequestException, @@ -65,7 +70,7 @@ describe(ApiKeyService.name, () => { describe('update', () => { it('should throw an error if the key is not found', async () => { const id = newUuid(); - const auth = factory.auth(); + const auth = AuthFactory.create(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -77,8 +82,8 @@ describe(ApiKeyService.name, () => { }); it('should update a key', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); const newName = 'New name'; mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -93,8 +98,8 @@ describe(ApiKeyService.name, () => { }); it('should update permissions', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); const newPermissions = [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate]; mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -111,8 +116,8 @@ describe(ApiKeyService.name, () => { describe('api key auth', () => { it('should prevent adding Permission.all', async () => { const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead]; - const auth = factory.auth({ apiKey: { permissions } }); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions }); + const auth = AuthFactory.from().apiKey({ permissions }).build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -125,8 +130,8 @@ describe(ApiKeyService.name, () => { it('should prevent adding a new permission', async () => { const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead]; - const auth = factory.auth({ apiKey: { permissions } }); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions }); + const auth = AuthFactory.from().apiKey({ permissions }).build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -138,8 +143,10 @@ describe(ApiKeyService.name, () => { }); it('should allow removing permissions', async () => { - const auth = factory.auth({ apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] } }); - const apiKey = factory.apiKey({ + const auth = AuthFactory.from() + .apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] }) + .build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.AssetRead, Permission.AssetDelete], }); @@ -158,10 +165,10 @@ describe(ApiKeyService.name, () => { }); it('should allow adding new permissions', async () => { - const auth = factory.auth({ - apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] }, - }); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.AssetRead] }); + const auth = AuthFactory.from() + .apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] }) + .build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.AssetRead] }); mocks.apiKey.getById.mockResolvedValue(apiKey); mocks.apiKey.update.mockResolvedValue(apiKey); @@ -183,7 +190,7 @@ describe(ApiKeyService.name, () => { describe('delete', () => { it('should throw an error if the key is not found', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); const id = newUuid(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -194,8 +201,8 @@ describe(ApiKeyService.name, () => { }); it('should delete a key', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getById.mockResolvedValue(apiKey); mocks.apiKey.delete.mockResolvedValue(); @@ -208,8 +215,8 @@ describe(ApiKeyService.name, () => { describe('getMine', () => { it('should not work with a session token', async () => { - const session = factory.session(); - const auth = factory.auth({ session }); + const session = SessionFactory.create(); + const auth = AuthFactory.from().session(session).build(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -219,8 +226,8 @@ describe(ApiKeyService.name, () => { }); it('should throw an error if the key is not found', async () => { - const apiKey = factory.authApiKey(); - const auth = factory.auth({ apiKey }); + const apiKey = ApiKeyFactory.create(); + const auth = AuthFactory.from().apiKey(apiKey).build(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -230,8 +237,8 @@ describe(ApiKeyService.name, () => { }); it('should get a key by id', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -243,7 +250,7 @@ describe(ApiKeyService.name, () => { describe('getById', () => { it('should throw an error if the key is not found', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); const id = newUuid(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -254,8 +261,8 @@ describe(ApiKeyService.name, () => { }); it('should get a key by id', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -267,8 +274,8 @@ describe(ApiKeyService.name, () => { describe('getAll', () => { it('should return all the keys for a user', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getByUserId.mockResolvedValue([apiKey]); diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 7da0452d45..718ec00f1d 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -7,6 +7,7 @@ import { AssetStats } from 'src/repositories/asset.repository'; import { AssetService } from 'src/services/asset.service'; import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { getForAsset, getForAssetDeletion, getForPartner } from 'test/mappers'; import { factory, newUuid } from 'test/small.factory'; @@ -80,8 +81,8 @@ describe(AssetService.name, () => { }); it('should not include partner assets if not in timeline', async () => { - const partner = factory.partner({ inTimeline: false }); - const auth = factory.auth({ user: { id: partner.sharedWithId } }); + const partner = PartnerFactory.create({ inTimeline: false }); + const auth = AuthFactory.create({ id: partner.sharedWithId }); mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]); mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]); @@ -92,8 +93,8 @@ describe(AssetService.name, () => { }); it('should include partner assets if in timeline', async () => { - const partner = factory.partner({ inTimeline: true }); - const auth = factory.auth({ user: { id: partner.sharedWithId } }); + const partner = PartnerFactory.create({ inTimeline: true }); + const auth = AuthFactory.create({ id: partner.sharedWithId }); mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]); mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]); diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 81f601da0a..f2cc3ada95 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -6,9 +6,13 @@ import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { AuthType, Permission } from 'src/enum'; import { AuthService } from 'src/services/auth.service'; import { UserMetadataItem } from 'src/types'; +import { ApiKeyFactory } from 'test/factories/api-key.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { SessionFactory } from 'test/factories/session.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { factory, newUuid } from 'test/small.factory'; +import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; const oauthResponse = ({ @@ -91,8 +95,8 @@ describe(AuthService.name, () => { }); it('should successfully log the user in', async () => { - const user = { ...(factory.user() as UserAdmin), password: 'immich_password' }; - const session = factory.session(); + const user = UserFactory.create({ password: 'immich_password' }); + const session = SessionFactory.create(); mocks.user.getByEmail.mockResolvedValue(user); mocks.session.create.mockResolvedValue(session); @@ -113,8 +117,8 @@ describe(AuthService.name, () => { describe('changePassword', () => { it('should change the password', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password' }; mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' }); @@ -132,8 +136,8 @@ describe(AuthService.name, () => { }); it('should throw when password does not match existing password', async () => { - const user = factory.user(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password' }; mocks.crypto.compareBcrypt.mockReturnValue(false); @@ -144,8 +148,8 @@ describe(AuthService.name, () => { }); it('should throw when user does not have a password', async () => { - const user = factory.user(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password' }; mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: '' }); @@ -154,8 +158,8 @@ describe(AuthService.name, () => { }); it('should change the password and logout other sessions', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password', invalidateSessions: true }; mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' }); @@ -175,7 +179,7 @@ describe(AuthService.name, () => { describe('logout', () => { it('should return the end session endpoint', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); @@ -186,7 +190,7 @@ describe(AuthService.name, () => { }); it('should return the default redirect', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({ successful: true, @@ -262,11 +266,11 @@ describe(AuthService.name, () => { }); it('should validate using authorization header', async () => { - const session = factory.session(); + const session = SessionFactory.create(); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), pinExpiresAt: null, appVersion: null, }; @@ -340,7 +344,7 @@ describe(AuthService.name, () => { }); it('should accept a base64url key', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); const sharedLink = { ...sharedLinkStub.valid, user } as any; mocks.sharedLink.getByKey.mockResolvedValue(sharedLink); @@ -361,7 +365,7 @@ describe(AuthService.name, () => { }); it('should accept a hex key', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); const sharedLink = { ...sharedLinkStub.valid, user } as any; mocks.sharedLink.getByKey.mockResolvedValue(sharedLink); @@ -396,7 +400,7 @@ describe(AuthService.name, () => { }); it('should accept a valid slug', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); const sharedLink = { ...sharedLinkStub.valid, slug: 'slug-123', user } as any; mocks.sharedLink.getBySlug.mockResolvedValue(sharedLink); @@ -428,11 +432,11 @@ describe(AuthService.name, () => { }); it('should return an auth dto', async () => { - const session = factory.session(); + const session = SessionFactory.create(); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), pinExpiresAt: null, appVersion: null, }; @@ -455,11 +459,11 @@ describe(AuthService.name, () => { }); it('should throw if admin route and not an admin', async () => { - const session = factory.session(); + const session = SessionFactory.create(); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), isPendingSyncReset: false, pinExpiresAt: null, appVersion: null, @@ -477,11 +481,11 @@ describe(AuthService.name, () => { }); it('should update when access time exceeds an hour', async () => { - const session = factory.session({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() }); + const session = SessionFactory.create({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() }); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), isPendingSyncReset: false, pinExpiresAt: null, appVersion: null, @@ -517,8 +521,8 @@ describe(AuthService.name, () => { }); it('should throw an error if api key has insufficient permissions', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.create({ permissions: [] }); mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); @@ -533,8 +537,8 @@ describe(AuthService.name, () => { }); it('should default to requiring the all permission when omitted', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [Permission.AssetRead] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.create({ permissions: [Permission.AssetRead] }); mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); @@ -548,10 +552,12 @@ describe(AuthService.name, () => { }); it('should not require any permission when metadata is set to `false`', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [Permission.ActivityRead] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.from({ permissions: [Permission.ActivityRead] }) + .user(authUser) + .build(); - mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); + mocks.apiKey.getKey.mockResolvedValue(authApiKey); const result = sut.authenticate({ headers: { 'x-api-key': 'auth_token' }, @@ -562,10 +568,12 @@ describe(AuthService.name, () => { }); it('should return an auth dto', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [Permission.All] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.from({ permissions: [Permission.All] }) + .user(authUser) + .build(); - mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); + mocks.apiKey.getKey.mockResolvedValue(authApiKey); await expect( sut.authenticate({ @@ -629,12 +637,12 @@ describe(AuthService.name, () => { }); it('should link an existing user', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.user.getByEmail.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -649,7 +657,7 @@ describe(AuthService.name, () => { }); it('should not link to a user with a different oauth sub', async () => { - const user = factory.userAdmin({ isAdmin: true, oauthId: 'existing-sub' }); + const user = UserFactory.create({ isAdmin: true, oauthId: 'existing-sub' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.user.getByEmail.mockResolvedValueOnce(user); @@ -669,13 +677,13 @@ describe(AuthService.name, () => { }); it('should allow auto registering by default', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -690,13 +698,13 @@ describe(AuthService.name, () => { }); it('should throw an error if user should be auto registered but the email claim does not exist', async () => { - const user = factory.userAdmin({ isAdmin: true }); + const user = UserFactory.create({ isAdmin: true }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getAdmin.mockResolvedValue(user); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined }); await expect( @@ -717,11 +725,11 @@ describe(AuthService.name, () => { 'app.immich:///oauth-callback?code=abc123', ]) { it(`should use the mobile redirect override for a url of ${url}`, async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); mocks.user.getByOAuthId.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails); @@ -735,13 +743,13 @@ describe(AuthService.name, () => { } it('should use the default quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -755,14 +763,14 @@ describe(AuthService.name, () => { }); it('should ignore an invalid storage quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 'abc' }); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -776,14 +784,14 @@ describe(AuthService.name, () => { }); it('should ignore a negative quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: -5 }); mocks.user.getAdmin.mockResolvedValue(user); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -797,14 +805,14 @@ describe(AuthService.name, () => { }); it('should set quota for 0 quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 0 }); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -825,15 +833,15 @@ describe(AuthService.name, () => { }); it('should use a valid storage quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 5 }); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -855,7 +863,7 @@ describe(AuthService.name, () => { it('should sync the profile picture', async () => { const fileId = newUuid(); - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg'; mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); @@ -871,7 +879,7 @@ describe(AuthService.name, () => { data: new Uint8Array([1, 2, 3, 4, 5]).buffer, }); mocks.user.update.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -889,7 +897,7 @@ describe(AuthService.name, () => { }); it('should not sync the profile picture if the user already has one', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id', profileImagePath: 'not-empty' }); + const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.oauth.getProfile.mockResolvedValue({ @@ -899,7 +907,7 @@ describe(AuthService.name, () => { }); mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -914,15 +922,15 @@ describe(AuthService.name, () => { }); it('should only allow "admin" and "user" for the role claim', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'foo' }); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -943,14 +951,14 @@ describe(AuthService.name, () => { }); it('should create an admin user if the role claim is set to admin', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'admin' }); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -971,7 +979,7 @@ describe(AuthService.name, () => { }); it('should accept a custom role claim', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue({ oauth: { ...systemConfigStub.oauthWithAutoRegister, roleClaim: 'my_role' }, @@ -980,7 +988,7 @@ describe(AuthService.name, () => { mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -1003,8 +1011,8 @@ describe(AuthService.name, () => { describe('link', () => { it('should link an account', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ apiKey: { permissions: [] }, user }); + const user = UserFactory.create(); + const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.update.mockResolvedValue(user); @@ -1019,8 +1027,8 @@ describe(AuthService.name, () => { }); it('should not link an already linked oauth.sub', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.create({ permissions: [] }); const auth = { user: authUser, apiKey: authApiKey }; mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); @@ -1036,8 +1044,8 @@ describe(AuthService.name, () => { describe('unlink', () => { it('should unlink an account', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user, apiKey: { permissions: [] } }); + const user = UserFactory.create(); + const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.update.mockResolvedValue(user); @@ -1050,8 +1058,8 @@ describe(AuthService.name, () => { describe('setupPinCode', () => { it('should setup a PIN code', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { pinCode: '123456' }; mocks.user.getForPinCode.mockResolvedValue({ pinCode: null, password: '' }); @@ -1065,8 +1073,8 @@ describe(AuthService.name, () => { }); it('should fail if the user already has a PIN code', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); @@ -1076,8 +1084,8 @@ describe(AuthService.name, () => { describe('changePinCode', () => { it('should change the PIN code', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { pinCode: '123456', newPinCode: '012345' }; mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); @@ -1091,37 +1099,37 @@ describe(AuthService.name, () => { }); it('should fail if the PIN code does not match', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); await expect( - sut.changePinCode(factory.auth({ user }), { pinCode: '000000', newPinCode: '012345' }), + sut.changePinCode(AuthFactory.create(user), { pinCode: '000000', newPinCode: '012345' }), ).rejects.toThrow('Wrong PIN code'); }); }); describe('resetPinCode', () => { it('should reset the PIN code', async () => { - const currentSession = factory.session(); - const user = factory.userAdmin(); + const currentSession = SessionFactory.create(); + const user = UserFactory.create(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); mocks.session.lockAll.mockResolvedValue(void 0); mocks.session.update.mockResolvedValue(currentSession); - await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' }); + await sut.resetPinCode(AuthFactory.create(user), { pinCode: '123456' }); expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null }); expect(mocks.session.lockAll).toHaveBeenCalledWith(user.id); }); it('should throw if the PIN code does not match', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); - await expect(sut.resetPinCode(factory.auth({ user }), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code'); + await expect(sut.resetPinCode(AuthFactory.create(user), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code'); }); }); }); diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index 36a3d2eb2c..347d9eef00 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -1,7 +1,7 @@ import { jwtVerify } from 'jose'; import { MaintenanceAction, SystemMetadataKey } from 'src/enum'; import { CliService } from 'src/services/cli.service'; -import { factory } from 'test/small.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { newTestService, ServiceMocks } from 'test/utils'; import { describe, it } from 'vitest'; @@ -15,7 +15,7 @@ describe(CliService.name, () => { describe('listUsers', () => { it('should list users', async () => { - mocks.user.getList.mockResolvedValue([factory.userAdmin({ isAdmin: true })]); + mocks.user.getList.mockResolvedValue([UserFactory.create({ isAdmin: true })]); await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]); expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true }); }); @@ -32,10 +32,10 @@ describe(CliService.name, () => { }); it('should default to a random password', async () => { - const admin = factory.userAdmin({ isAdmin: true }); + const admin = UserFactory.create({ isAdmin: true }); mocks.user.getAdmin.mockResolvedValue(admin); - mocks.user.update.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true })); const ask = vitest.fn().mockImplementation(() => {}); @@ -50,7 +50,7 @@ describe(CliService.name, () => { }); it('should use the supplied password', async () => { - const admin = factory.userAdmin({ isAdmin: true }); + const admin = UserFactory.create({ isAdmin: true }); mocks.user.getAdmin.mockResolvedValue(admin); mocks.user.update.mockResolvedValue(admin); diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts index 287c5c7c63..fdf7aee68b 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -2,9 +2,9 @@ import { MapService } from 'src/services/map.service'; import { AlbumFactory } from 'test/factories/album.factory'; import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; import { userStub } from 'test/fixtures/user.stub'; import { getForAlbum, getForPartner } from 'test/mappers'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(MapService.name, () => { @@ -40,7 +40,7 @@ describe(MapService.name, () => { it('should include partner assets', async () => { const auth = AuthFactory.create(); - const partner = factory.partner({ sharedWithId: auth.user.id }); + const partner = PartnerFactory.create({ sharedWithId: auth.user.id }); const asset = AssetFactory.from() .exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' }) diff --git a/server/src/services/partner.service.spec.ts b/server/src/services/partner.service.spec.ts index 0f80ca84f1..029462a865 100644 --- a/server/src/services/partner.service.spec.ts +++ b/server/src/services/partner.service.spec.ts @@ -1,9 +1,10 @@ import { BadRequestException } from '@nestjs/common'; import { PartnerDirection } from 'src/repositories/partner.repository'; import { PartnerService } from 'src/services/partner.service'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; import { UserFactory } from 'test/factories/user.factory'; -import { getDehydrated, getForPartner } from 'test/mappers'; -import { factory } from 'test/small.factory'; +import { getForPartner } from 'test/mappers'; import { newTestService, ServiceMocks } from 'test/utils'; describe(PartnerService.name, () => { @@ -22,15 +23,9 @@ describe(PartnerService.name, () => { it("should return a list of partners with whom I've shared my library", async () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); - const sharedWithUser2 = factory.partner({ - sharedBy: getDehydrated(user1), - sharedWith: getDehydrated(user2), - }); - const sharedWithUser1 = factory.partner({ - sharedBy: getDehydrated(user2), - sharedWith: getDehydrated(user1), - }); - const auth = factory.auth({ user: { id: user1.id } }); + const sharedWithUser2 = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const sharedWithUser1 = PartnerFactory.from().sharedBy(user2).sharedWith(user1).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]); @@ -41,15 +36,9 @@ describe(PartnerService.name, () => { it('should return a list of partners who have shared their libraries with me', async () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); - const sharedWithUser2 = factory.partner({ - sharedBy: getDehydrated(user1), - sharedWith: getDehydrated(user2), - }); - const sharedWithUser1 = factory.partner({ - sharedBy: getDehydrated(user2), - sharedWith: getDehydrated(user1), - }); - const auth = factory.auth({ user: { id: user1.id } }); + const sharedWithUser2 = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const sharedWithUser1 = PartnerFactory.from().sharedBy(user2).sharedWith(user1).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]); await expect(sut.search(auth, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined(); @@ -61,8 +50,8 @@ describe(PartnerService.name, () => { it('should create a new partner', async () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); - const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) }); - const auth = factory.auth({ user: { id: user1.id } }); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.partner.get.mockResolvedValue(void 0); mocks.partner.create.mockResolvedValue(getForPartner(partner)); @@ -78,8 +67,8 @@ describe(PartnerService.name, () => { it('should throw an error when the partner already exists', async () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); - const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) }); - const auth = factory.auth({ user: { id: user1.id } }); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.partner.get.mockResolvedValue(getForPartner(partner)); @@ -93,8 +82,8 @@ describe(PartnerService.name, () => { it('should remove a partner', async () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); - const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) }); - const auth = factory.auth({ user: { id: user1.id } }); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.partner.get.mockResolvedValue(getForPartner(partner)); @@ -104,8 +93,8 @@ describe(PartnerService.name, () => { }); it('should throw an error when the partner does not exist', async () => { - const user2 = factory.user(); - const auth = factory.auth(); + const user2 = UserFactory.create(); + const auth = AuthFactory.create(); mocks.partner.get.mockResolvedValue(void 0); @@ -117,8 +106,8 @@ describe(PartnerService.name, () => { describe('update', () => { it('should require access', async () => { - const user2 = factory.user(); - const auth = factory.auth(); + const user2 = UserFactory.create(); + const auth = AuthFactory.create(); await expect(sut.update(auth, user2.id, { inTimeline: false })).rejects.toBeInstanceOf(BadRequestException); }); @@ -126,8 +115,8 @@ describe(PartnerService.name, () => { it('should update partner', async () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); - const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) }); - const auth = factory.auth({ user: { id: user1.id } }); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set([user2.id])); mocks.partner.update.mockResolvedValue(getForPartner(partner)); diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index 7eacd148ad..8f4409a508 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -1,7 +1,8 @@ import { JobStatus } from 'src/enum'; import { SessionService } from 'src/services/session.service'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { SessionFactory } from 'test/factories/session.factory'; import { authStub } from 'test/fixtures/auth.stub'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe('SessionService', () => { @@ -25,9 +26,9 @@ describe('SessionService', () => { describe('getAll', () => { it('should get the devices', async () => { - const currentSession = factory.session(); - const otherSession = factory.session(); - const auth = factory.auth({ session: currentSession }); + const currentSession = SessionFactory.create(); + const otherSession = SessionFactory.create(); + const auth = AuthFactory.from().session(currentSession).build(); mocks.session.getByUserId.mockResolvedValue([currentSession, otherSession]); @@ -42,8 +43,8 @@ describe('SessionService', () => { describe('logoutDevices', () => { it('should logout all devices', async () => { - const currentSession = factory.session(); - const auth = factory.auth({ session: currentSession }); + const currentSession = SessionFactory.create(); + const auth = AuthFactory.from().session(currentSession).build(); mocks.session.invalidate.mockResolvedValue(); diff --git a/server/src/services/sync.service.spec.ts b/server/src/services/sync.service.spec.ts index 3b7fbfcd95..234e3ac223 100644 --- a/server/src/services/sync.service.spec.ts +++ b/server/src/services/sync.service.spec.ts @@ -1,6 +1,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { SyncService } from 'src/services/sync.service'; import { AssetFactory } from 'test/factories/asset.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { getForAsset, getForPartner } from 'test/mappers'; import { factory } from 'test/small.factory'; @@ -42,7 +43,7 @@ describe(SyncService.name, () => { describe('getChangesForDeltaSync', () => { it('should return a response requiring a full sync when partners are out of sync', async () => { - const partner = factory.partner(); + const partner = PartnerFactory.create(); const auth = factory.auth({ user: { id: partner.sharedWithId } }); mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]); diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index d8e13fcfbd..49aefaa870 100644 --- a/server/src/services/user-admin.service.spec.ts +++ b/server/src/services/user-admin.service.spec.ts @@ -2,9 +2,10 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { mapUserAdmin } from 'src/dtos/user.dto'; import { JobName, UserStatus } from 'src/enum'; import { UserAdminService } from 'src/services/user-admin.service'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; import { describe } from 'vitest'; @@ -126,8 +127,8 @@ describe(UserAdminService.name, () => { }); it('should not allow deleting own account', async () => { - const user = factory.userAdmin({ isAdmin: false }); - const auth = factory.auth({ user }); + const user = UserFactory.create({ isAdmin: false }); + const auth = AuthFactory.create(user); mocks.user.get.mockResolvedValue(user); await expect(sut.delete(auth, user.id, {})).rejects.toBeInstanceOf(ForbiddenException); diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index bd896ffc24..0dc83928fc 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -3,10 +3,11 @@ import { UserAdmin } from 'src/database'; import { CacheControl, JobName, UserMetadataKey } from 'src/enum'; import { UserService } from 'src/services/user.service'; import { ImmichFileResponse } from 'src/utils/file'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; const makeDeletedAt = (daysAgo: number) => { @@ -28,8 +29,8 @@ describe(UserService.name, () => { describe('getAll', () => { it('admin should get all users', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); mocks.user.getList.mockResolvedValue([user]); @@ -39,8 +40,8 @@ describe(UserService.name, () => { }); it('non-admin should get all users when publicUsers enabled', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); mocks.user.getList.mockResolvedValue([user]); @@ -105,7 +106,7 @@ describe(UserService.name, () => { it('should throw an error if the user profile could not be updated with the new image', async () => { const file = { path: '/profile/path' } as Express.Multer.File; - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); mocks.user.get.mockResolvedValue(user); mocks.user.update.mockRejectedValue(new InternalServerErrorException('mocked error')); @@ -113,7 +114,7 @@ describe(UserService.name, () => { }); it('should delete the previous profile image', async () => { - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); const file = { path: '/profile/path' } as Express.Multer.File; const files = [user.profileImagePath]; @@ -149,7 +150,7 @@ describe(UserService.name, () => { }); it('should delete the profile image if user has one', async () => { - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); const files = [user.profileImagePath]; mocks.user.get.mockResolvedValue(user); @@ -178,7 +179,7 @@ describe(UserService.name, () => { }); it('should return the profile picture', async () => { - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); mocks.user.get.mockResolvedValue(user); await expect(sut.getProfileImage(user.id)).resolves.toEqual( @@ -205,7 +206,7 @@ describe(UserService.name, () => { }); it('should queue user ready for deletion', async () => { - const user = factory.user(); + const user = UserFactory.create(); mocks.user.getDeletedAfter.mockResolvedValue([{ id: user.id }]); await sut.handleUserDeleteCheck(); diff --git a/server/test/factories/activity.factory.ts b/server/test/factories/activity.factory.ts new file mode 100644 index 0000000000..861b115158 --- /dev/null +++ b/server/test/factories/activity.factory.ts @@ -0,0 +1,42 @@ +import { Selectable } from 'kysely'; +import { ActivityTable } from 'src/schema/tables/activity.table'; +import { build } from 'test/factories/builder.factory'; +import { ActivityLike, FactoryBuilder, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class ActivityFactory { + #user!: UserFactory; + + private constructor(private value: Selectable) {} + + static create(dto: ActivityLike = {}) { + return ActivityFactory.from(dto).build(); + } + + static from(dto: ActivityLike = {}) { + const userId = dto.userId ?? newUuid(); + return new ActivityFactory({ + albumId: newUuid(), + assetId: null, + comment: null, + createdAt: newDate(), + id: newUuid(), + isLiked: false, + userId, + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }).user({ id: userId }); + } + + user(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#user = build(UserFactory.from(dto), builder); + this.value.userId = this.#user.build().id; + return this; + } + + build() { + return { ...this.value, user: this.#user.build() }; + } +} diff --git a/server/test/factories/api-key.factory.ts b/server/test/factories/api-key.factory.ts new file mode 100644 index 0000000000..d16b50ba57 --- /dev/null +++ b/server/test/factories/api-key.factory.ts @@ -0,0 +1,42 @@ +import { Selectable } from 'kysely'; +import { Permission } from 'src/enum'; +import { ApiKeyTable } from 'src/schema/tables/api-key.table'; +import { build } from 'test/factories/builder.factory'; +import { ApiKeyLike, FactoryBuilder, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class ApiKeyFactory { + #user!: UserFactory; + + private constructor(private value: Selectable) {} + + static create(dto: ApiKeyLike = {}) { + return ApiKeyFactory.from(dto).build(); + } + + static from(dto: ApiKeyLike = {}) { + const userId = dto.userId ?? newUuid(); + return new ApiKeyFactory({ + createdAt: newDate(), + id: newUuid(), + key: Buffer.from('api-key-buffer'), + name: 'API Key', + permissions: [Permission.All], + updatedAt: newDate(), + updateId: newUuidV7(), + userId, + ...dto, + }).user({ id: userId }); + } + + user(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#user = build(UserFactory.from(dto), builder); + this.value.userId = this.#user.build().id; + return this; + } + + build() { + return { ...this.value, user: this.#user.build() }; + } +} diff --git a/server/test/factories/auth.factory.ts b/server/test/factories/auth.factory.ts index 9c738aabac..fd38c42649 100644 --- a/server/test/factories/auth.factory.ts +++ b/server/test/factories/auth.factory.ts @@ -1,12 +1,16 @@ import { AuthDto } from 'src/dtos/auth.dto'; +import { ApiKeyFactory } from 'test/factories/api-key.factory'; import { build } from 'test/factories/builder.factory'; import { SharedLinkFactory } from 'test/factories/shared-link.factory'; -import { FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types'; +import { ApiKeyLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types'; import { UserFactory } from 'test/factories/user.factory'; +import { newUuid } from 'test/small.factory'; export class AuthFactory { #user: UserFactory; #sharedLink?: SharedLinkFactory; + #apiKey?: ApiKeyFactory; + #session?: AuthDto['session']; private constructor(user: UserFactory) { this.#user = user; @@ -20,8 +24,8 @@ export class AuthFactory { return new AuthFactory(UserFactory.from(dto)); } - apiKey() { - // TODO + apiKey(dto: ApiKeyLike = {}, builder?: FactoryBuilder) { + this.#apiKey = build(ApiKeyFactory.from(dto), builder); return this; } @@ -30,6 +34,11 @@ export class AuthFactory { return this; } + session(dto: Partial = {}) { + this.#session = { id: newUuid(), hasElevatedPermission: false, ...dto }; + return this; + } + build(): AuthDto { const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes } = this.#user.build(); @@ -43,6 +52,8 @@ export class AuthFactory { quotaSizeInBytes, }, sharedLink: this.#sharedLink?.build(), + apiKey: this.#apiKey?.build(), + session: this.#session, }; } } diff --git a/server/test/factories/partner.factory.ts b/server/test/factories/partner.factory.ts new file mode 100644 index 0000000000..f631db1eb5 --- /dev/null +++ b/server/test/factories/partner.factory.ts @@ -0,0 +1,50 @@ +import { Selectable } from 'kysely'; +import { PartnerTable } from 'src/schema/tables/partner.table'; +import { build } from 'test/factories/builder.factory'; +import { FactoryBuilder, PartnerLike, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class PartnerFactory { + #sharedWith!: UserFactory; + #sharedBy!: UserFactory; + + private constructor(private value: Selectable) {} + + static create(dto: PartnerLike = {}) { + return PartnerFactory.from(dto).build(); + } + + static from(dto: PartnerLike = {}) { + const sharedById = dto.sharedById ?? newUuid(); + const sharedWithId = dto.sharedWithId ?? newUuid(); + return new PartnerFactory({ + createdAt: newDate(), + createId: newUuidV7(), + inTimeline: true, + sharedById, + sharedWithId, + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }) + .sharedBy({ id: sharedById }) + .sharedWith({ id: sharedWithId }); + } + + sharedWith(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#sharedWith = build(UserFactory.from(dto), builder); + this.value.sharedWithId = this.#sharedWith.build().id; + return this; + } + + sharedBy(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#sharedBy = build(UserFactory.from(dto), builder); + this.value.sharedById = this.#sharedBy.build().id; + return this; + } + + build() { + return { ...this.value, sharedWith: this.#sharedWith.build(), sharedBy: this.#sharedBy.build() }; + } +} diff --git a/server/test/factories/session.factory.ts b/server/test/factories/session.factory.ts new file mode 100644 index 0000000000..8d4cb28727 --- /dev/null +++ b/server/test/factories/session.factory.ts @@ -0,0 +1,35 @@ +import { Selectable } from 'kysely'; +import { SessionTable } from 'src/schema/tables/session.table'; +import { SessionLike } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class SessionFactory { + private constructor(private value: Selectable) {} + + static create(dto: SessionLike = {}) { + return SessionFactory.from(dto).build(); + } + + static from(dto: SessionLike = {}) { + return new SessionFactory({ + appVersion: null, + createdAt: newDate(), + deviceOS: 'android', + deviceType: 'mobile', + expiresAt: null, + id: newUuid(), + isPendingSyncReset: false, + parentId: null, + pinExpiresAt: null, + token: Buffer.from('abc123'), + updateId: newUuidV7(), + updatedAt: newDate(), + userId: newUuid(), + ...dto, + }); + } + + build() { + return { ...this.value }; + } +} diff --git a/server/test/factories/types.ts b/server/test/factories/types.ts index 0e070c1bcc..e2d9e4e1c3 100644 --- a/server/test/factories/types.ts +++ b/server/test/factories/types.ts @@ -1,13 +1,17 @@ import { Selectable } from 'kysely'; +import { ActivityTable } from 'src/schema/tables/activity.table'; import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumTable } from 'src/schema/tables/album.table'; +import { ApiKeyTable } from 'src/schema/tables/api-key.table'; import { AssetEditTable } from 'src/schema/tables/asset-edit.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { MemoryTable } from 'src/schema/tables/memory.table'; +import { PartnerTable } from 'src/schema/tables/partner.table'; import { PersonTable } from 'src/schema/tables/person.table'; +import { SessionTable } from 'src/schema/tables/session.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; import { StackTable } from 'src/schema/tables/stack.table'; import { UserTable } from 'src/schema/tables/user.table'; @@ -26,3 +30,7 @@ export type AssetFaceLike = Partial>; export type PersonLike = Partial>; export type StackLike = Partial>; export type MemoryLike = Partial>; +export type PartnerLike = Partial>; +export type ActivityLike = Partial>; +export type ApiKeyLike = Partial>; +export type SessionLike = Partial>; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index c734fdcb2d..57098e01ee 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -1,26 +1,7 @@ -import { ShallowDehydrateObject } from 'kysely'; -import { - Activity, - Album, - ApiKey, - AuthApiKey, - AuthSharedLink, - AuthUser, - Exif, - Library, - Partner, - Person, - Session, - Tag, - User, - UserAdmin, -} from 'src/database'; +import { AuthApiKey, AuthSharedLink, AuthUser, Exif, Library, UserAdmin } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetEditAction, AssetEditActionItem, MirrorAxis } from 'src/dtos/editing.dto'; import { QueueStatisticsDto } from 'src/dtos/queue.dto'; -import { AssetFileType, AssetOrder, Permission, UserMetadataKey, UserStatus } from 'src/enum'; -import { UserMetadataItem } from 'src/types'; -import { UserFactory } from 'test/factories/user.factory'; +import { AssetFileType, Permission, UserStatus } from 'src/enum'; import { v4, v7 } from 'uuid'; export const newUuid = () => v4(); @@ -109,49 +90,6 @@ const authUserFactory = (authUser: Partial = {}) => { return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes }; }; -const partnerFactory = ({ - sharedBy: sharedByProvided, - sharedWith: sharedWithProvided, - ...partner -}: Partial = {}) => { - const hydrateUser = (user: Partial>) => ({ - ...user, - profileChangedAt: user.profileChangedAt ? new Date(user.profileChangedAt) : undefined, - }); - const sharedBy = UserFactory.create(sharedByProvided ? hydrateUser(sharedByProvided) : {}); - const sharedWith = UserFactory.create(sharedWithProvided ? hydrateUser(sharedWithProvided) : {}); - - return { - sharedById: sharedBy.id, - sharedBy, - sharedWithId: sharedWith.id, - sharedWith, - createId: newUuidV7(), - createdAt: newDate(), - updatedAt: newDate(), - updateId: newUuidV7(), - inTimeline: true, - ...partner, - }; -}; - -const sessionFactory = (session: Partial = {}) => ({ - id: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - updateId: newUuidV7(), - deviceOS: 'android', - deviceType: 'mobile', - token: Buffer.from('abc123'), - parentId: null, - expiresAt: null, - userId: newUuid(), - pinExpiresAt: newDate(), - isPendingSyncReset: false, - appVersion: session.appVersion ?? null, - ...session, -}); - const queueStatisticsFactory = (dto?: Partial) => ({ active: 0, completed: 0, @@ -162,22 +100,6 @@ const queueStatisticsFactory = (dto?: Partial) => ({ ...dto, }); -const userFactory = (user: Partial = {}) => ({ - id: newUuid(), - name: 'Test User', - email: 'test@immich.cloud', - avatarColor: null, - profileImagePath: '', - profileChangedAt: newDate(), - metadata: [ - { - key: UserMetadataKey.Onboarding, - value: 'true', - }, - ] as UserMetadataItem[], - ...user, -}); - const userAdminFactory = (user: Partial = {}) => { const { id = newUuid(), @@ -219,34 +141,6 @@ const userAdminFactory = (user: Partial = {}) => { }; }; -const activityFactory = (activity: Omit, 'user'> = {}) => { - const userId = activity.userId || newUuid(); - return { - id: newUuid(), - comment: null, - isLiked: false, - userId, - user: UserFactory.create({ id: userId }), - assetId: newUuid(), - albumId: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - updateId: newUuidV7(), - ...activity, - }; -}; - -const apiKeyFactory = (apiKey: Partial = {}) => ({ - id: newUuid(), - userId: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - updateId: newUuidV7(), - name: 'Api Key', - permissions: [Permission.All], - ...apiKey, -}); - const libraryFactory = (library: Partial = {}) => ({ id: newUuid(), createdAt: newDate(), @@ -328,88 +222,15 @@ const assetOcrFactory = ( ...ocr, }); -const tagFactory = (tag: Partial): Tag => ({ - id: newUuid(), - color: null, - createdAt: newDate(), - parentId: null, - updatedAt: newDate(), - value: `tag-${newUuid()}`, - ...tag, -}); - -const assetEditFactory = (edit?: Partial): AssetEditActionItem => { - switch (edit?.action) { - case AssetEditAction.Crop: { - return { action: AssetEditAction.Crop, parameters: { height: 42, width: 42, x: 0, y: 10 }, ...edit }; - } - case AssetEditAction.Mirror: { - return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal }, ...edit }; - } - case AssetEditAction.Rotate: { - return { action: AssetEditAction.Rotate, parameters: { angle: 90 }, ...edit }; - } - default: { - return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } }; - } - } -}; - -const personFactory = (person?: Partial): Person => ({ - birthDate: newDate(), - color: null, - createdAt: newDate(), - faceAssetId: null, - id: newUuid(), - isFavorite: false, - isHidden: false, - name: 'person', - ownerId: newUuid(), - thumbnailPath: '/path/to/person/thumbnail.jpg', - updatedAt: newDate(), - updateId: newUuidV7(), - ...person, -}); - -const albumFactory = (album?: Partial>) => ({ - albumName: 'My Album', - albumThumbnailAssetId: null, - albumUsers: [], - assets: [], - createdAt: newDate(), - deletedAt: null, - description: 'Album description', - id: newUuid(), - isActivityEnabled: false, - order: AssetOrder.Desc, - ownerId: newUuid(), - sharedLinks: [], - updatedAt: newDate(), - updateId: newUuidV7(), - ...album, -}); - export const factory = { - activity: activityFactory, - apiKey: apiKeyFactory, assetOcr: assetOcrFactory, auth: authFactory, - authApiKey: authApiKeyFactory, - authUser: authUserFactory, library: libraryFactory, - partner: partnerFactory, queueStatistics: queueStatisticsFactory, - session: sessionFactory, - user: userFactory, - userAdmin: userAdminFactory, versionHistory: versionHistoryFactory, jobAssets: { sidecarWrite: assetSidecarWriteFactory, }, - person: personFactory, - assetEdit: assetEditFactory, - tag: tagFactory, - album: albumFactory, uuid: newUuid, buffer: () => Buffer.from('this is a fake buffer'), date: newDate, From 990aff441bb52642dd6c1e66d652d993c5f3845e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 12 Mar 2026 16:10:55 -0400 Subject: [PATCH 137/150] fix: add to shared link (#26886) --- .../src/repositories/shared-link.repository.ts | 16 +++++++++++++++- server/src/services/asset-media.service.ts | 9 +++++++++ server/src/services/shared-link.service.ts | 6 ++++++ .../share-page/individual-shared-viewer.svelte | 16 +++------------- 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 48afcf7d92..bc81e75c81 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -4,7 +4,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import _ from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; import { Album, columns } from 'src/database'; -import { DummyValue, GenerateSql } from 'src/decorators'; +import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { SharedLinkType } from 'src/enum'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; @@ -249,6 +249,20 @@ export class SharedLinkRepository { await this.db.deleteFrom('shared_link').where('shared_link.id', '=', id).execute(); } + @ChunkedArray({ paramIndex: 1 }) + async addAssets(id: string, assetIds: string[]) { + if (assetIds.length === 0) { + return []; + } + + return await this.db + .insertInto('shared_link_asset') + .values(assetIds.map((assetId) => ({ assetId, sharedLinkId: id }))) + .onConflict((oc) => oc.doNothing()) + .returning(['shared_link_asset.assetId']) + .execute(); + } + @GenerateSql({ params: [DummyValue.UUID] }) private getSharedLinks(id: string) { return this.db diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 020bda4df7..3c981ea61e 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -151,6 +151,10 @@ export class AssetMediaService extends BaseService { } const asset = await this.create(auth.user.id, dto, file, sidecarFile); + if (auth.sharedLink) { + await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [asset.id]); + } + await this.userRepository.updateUsage(auth.user.id, file.size); return { id: asset.id, status: AssetMediaStatus.CREATED }; @@ -341,6 +345,11 @@ export class AssetMediaService extends BaseService { this.logger.error(`Error locating duplicate for checksum constraint`); throw new InternalServerErrorException(); } + + if (auth.sharedLink) { + await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [duplicateId]); + } + return { status: AssetMediaStatus.DUPLICATE, id: duplicateId }; } diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index b942c32326..26b15031ee 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -150,6 +150,12 @@ export class SharedLinkService extends BaseService { } async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise { + if (auth.sharedLink) { + this.logger.deprecate( + 'Assets uploaded using shared link authentication are now automatically added to the shared link during upload and in the next major release this endpoint will no longer accept shared link authentication', + ); + } + const sharedLink = await this.findOrFail(auth.user.id, id); if (sharedLink.type !== SharedLinkType.Individual) { diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index 94e00500fb..64eb98bec0 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -16,7 +16,7 @@ import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import { handleError } from '$lib/utils/handle-error'; import { toTimelineAsset } from '$lib/utils/timeline-util'; - import { addSharedLinkAssets, getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk'; + import { getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk'; import { IconButton, Logo, toastManager } from '@immich/ui'; import { mdiArrowLeft, mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -48,21 +48,11 @@ const handleUploadAssets = async (files: File[] = []) => { try { - let results: (string | undefined)[] = []; - results = await (!files || files.length === 0 || !Array.isArray(files) + await (!files || files.length === 0 || !Array.isArray(files) ? openFileUploadDialog() : fileUploadHandler({ files })); - const data = await addSharedLinkAssets({ - ...authManager.params, - id: sharedLink.id, - assetIdsDto: { - assetIds: results.filter((id) => !!id) as string[], - }, - }); - const added = data.filter((item) => item.success).length; - - toastManager.success($t('assets_added_count', { values: { count: added } })); + toastManager.success(); } catch (error) { handleError(error, $t('errors.unable_to_add_assets_to_shared_link')); } From f3b7cd6198365a7fc38e6b46e18dcb599bfbc448 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Thu, 12 Mar 2026 15:15:21 -0500 Subject: [PATCH 138/150] refactor: move encoded video to asset files table (#26863) * refactor: move encoded video to asset files table * chore: update --- server/src/cores/storage.core.ts | 23 +++++------- server/src/database.ts | 1 - server/src/dtos/asset-response.dto.ts | 1 - server/src/enum.ts | 1 + server/src/queries/asset.job.repository.sql | 36 ++++++++++++++----- server/src/queries/asset.repository.sql | 16 ++++++--- .../src/repositories/asset-job.repository.ts | 24 +++++++++---- server/src/repositories/asset.repository.ts | 21 +++++++++-- .../src/repositories/database.repository.ts | 1 - .../1773242919341-EncodedVideoAssetFiles.ts | 25 +++++++++++++ server/src/schema/tables/asset.table.ts | 3 -- .../src/services/asset-media.service.spec.ts | 17 ++++++--- server/src/services/asset.service.ts | 2 +- server/src/services/media.service.spec.ts | 6 ++-- server/src/services/media.service.ts | 16 ++++++--- server/src/utils/asset.util.ts | 2 ++ server/src/utils/database.ts | 21 +++++++++-- server/test/factories/asset.factory.ts | 1 - server/test/mappers.ts | 1 - 19 files changed, 158 insertions(+), 60 deletions(-) create mode 100644 server/src/schema/migrations/1773242919341-EncodedVideoAssetFiles.ts diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index c6821404dc..3345f6e129 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -154,10 +154,11 @@ export class StorageCore { } async moveAssetVideo(asset: StorageAsset) { + const encodedVideoFile = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false }); return this.moveFile({ entityId: asset.id, pathType: AssetPathType.EncodedVideo, - oldPath: asset.encodedVideoPath, + oldPath: encodedVideoFile?.path || null, newPath: StorageCore.getEncodedVideoPath(asset), }); } @@ -303,21 +304,15 @@ export class StorageCore { case AssetPathType.Original: { return this.assetRepository.update({ id, originalPath: newPath }); } - case AssetFileType.FullSize: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath }); - } - case AssetFileType.Preview: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath }); - } - case AssetFileType.Thumbnail: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Thumbnail, path: newPath }); - } - case AssetPathType.EncodedVideo: { - return this.assetRepository.update({ id, encodedVideoPath: newPath }); - } + + case AssetFileType.FullSize: + case AssetFileType.EncodedVideo: + case AssetFileType.Thumbnail: + case AssetFileType.Preview: case AssetFileType.Sidecar: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath }); + return this.assetRepository.upsertFile({ assetId: id, type: pathType as AssetFileType, path: newPath }); } + case PersonPathType.Face: { return this.personRepository.update({ id, thumbnailPath: newPath }); } diff --git a/server/src/database.ts b/server/src/database.ts index fc790259d1..3e3192c21a 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -154,7 +154,6 @@ export type StorageAsset = { id: string; ownerId: string; files: AssetFile[]; - encodedVideoPath: string | null; }; export type Stack = { diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 644c9caeb8..8b38b2e124 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -153,7 +153,6 @@ export type MapAsset = { duplicateId: string | null; duration: string | null; edits?: ShallowDehydrateObject[]; - encodedVideoPath: string | null; exifInfo?: ShallowDehydrateObject> | null; faces?: ShallowDehydrateObject[]; fileCreatedAt: Date; diff --git a/server/src/enum.ts b/server/src/enum.ts index 887c8fa93c..60f45efd6e 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -45,6 +45,7 @@ export enum AssetFileType { Preview = 'preview', Thumbnail = 'thumbnail', Sidecar = 'sidecar', + EncodedVideo = 'encoded_video', } export enum AlbumUserRole { diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index a9c407782b..cebb9fe95e 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -175,7 +175,6 @@ where select "asset"."id", "asset"."ownerId", - "asset"."encodedVideoPath", ( select coalesce(json_agg(agg), '[]') @@ -463,7 +462,6 @@ select "asset"."libraryId", "asset"."ownerId", "asset"."livePhotoVideoId", - "asset"."encodedVideoPath", "asset"."originalPath", "asset"."isOffline", to_json("asset_exif") as "exifInfo", @@ -521,12 +519,17 @@ select from "asset" where - "asset"."type" = $1 - and ( - "asset"."encodedVideoPath" is null - or "asset"."encodedVideoPath" = $2 + "asset"."type" = 'VIDEO' + and not exists ( + select + "asset_file"."id" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = 'encoded_video' ) - and "asset"."visibility" != $3 + and "asset"."visibility" != 'hidden' and "asset"."deletedAt" is null -- AssetJobRepository.getForVideoConversion @@ -534,12 +537,27 @@ select "asset"."id", "asset"."ownerId", "asset"."originalPath", - "asset"."encodedVideoPath" + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_file"."id", + "asset_file"."path", + "asset_file"."type", + "asset_file"."isEdited" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + ) as agg + ) as "files" from "asset" where "asset"."id" = $1 - and "asset"."type" = $2 + and "asset"."type" = 'VIDEO' -- AssetJobRepository.streamForMetadataExtraction select diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index a74a05f466..a2525c3b17 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -629,13 +629,21 @@ order by -- AssetRepository.getForVideo select - "asset"."encodedVideoPath", - "asset"."originalPath" + "asset"."originalPath", + ( + select + "asset_file"."path" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $1 + ) as "encodedVideoPath" from "asset" where - "asset"."id" = $1 - and "asset"."type" = $2 + "asset"."id" = $2 + and "asset"."type" = $3 -- AssetRepository.getForOcr select diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index a8067473e4..3765cad7ed 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -104,7 +104,7 @@ export class AssetJobRepository { getForMigrationJob(id: string) { return this.db .selectFrom('asset') - .select(['asset.id', 'asset.ownerId', 'asset.encodedVideoPath']) + .select(['asset.id', 'asset.ownerId']) .select(withFiles) .where('asset.id', '=', id) .executeTakeFirst(); @@ -268,7 +268,6 @@ export class AssetJobRepository { 'asset.libraryId', 'asset.ownerId', 'asset.livePhotoVideoId', - 'asset.encodedVideoPath', 'asset.originalPath', 'asset.isOffline', ]) @@ -310,11 +309,21 @@ export class AssetJobRepository { return this.db .selectFrom('asset') .select(['asset.id']) - .where('asset.type', '=', AssetType.Video) + .where('asset.type', '=', sql.lit(AssetType.Video)) .$if(!force, (qb) => qb - .where((eb) => eb.or([eb('asset.encodedVideoPath', 'is', null), eb('asset.encodedVideoPath', '=', '')])) - .where('asset.visibility', '!=', AssetVisibility.Hidden), + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom('asset_file') + .select('asset_file.id') + .whereRef('asset_file.assetId', '=', 'asset.id') + .where('asset_file.type', '=', sql.lit(AssetFileType.EncodedVideo)), + ), + ), + ) + .where('asset.visibility', '!=', sql.lit(AssetVisibility.Hidden)), ) .where('asset.deletedAt', 'is', null) .stream(); @@ -324,9 +333,10 @@ export class AssetJobRepository { getForVideoConversion(id: string) { return this.db .selectFrom('asset') - .select(['asset.id', 'asset.ownerId', 'asset.originalPath', 'asset.encodedVideoPath']) + .select(['asset.id', 'asset.ownerId', 'asset.originalPath']) + .select(withFiles) .where('asset.id', '=', id) - .where('asset.type', '=', AssetType.Video) + .where('asset.type', '=', sql.lit(AssetType.Video)) .executeTakeFirst(); } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 200137a137..2e1d02ef28 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -36,6 +36,7 @@ import { withExif, withFaces, withFacesAndPeople, + withFilePath, withFiles, withLibrary, withOwner, @@ -1019,8 +1020,21 @@ export class AssetRepository { .execute(); } - async deleteFile({ assetId, type }: { assetId: string; type: AssetFileType }): Promise { - await this.db.deleteFrom('asset_file').where('assetId', '=', asUuid(assetId)).where('type', '=', type).execute(); + async deleteFile({ + assetId, + type, + edited, + }: { + assetId: string; + type: AssetFileType; + edited?: boolean; + }): Promise { + await this.db + .deleteFrom('asset_file') + .where('assetId', '=', asUuid(assetId)) + .where('type', '=', type) + .$if(edited !== undefined, (qb) => qb.where('isEdited', '=', edited!)) + .execute(); } async deleteFiles(files: Pick, 'id'>[]): Promise { @@ -1139,7 +1153,8 @@ export class AssetRepository { async getForVideo(id: string) { return this.db .selectFrom('asset') - .select(['asset.encodedVideoPath', 'asset.originalPath']) + .select(['asset.originalPath']) + .select((eb) => withFilePath(eb, AssetFileType.EncodedVideo).as('encodedVideoPath')) .where('asset.id', '=', id) .where('asset.type', '=', AssetType.Video) .executeTakeFirst(); diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 4ffb37c79c..7ae1119bbc 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -431,7 +431,6 @@ export class DatabaseRepository { .updateTable('asset') .set((eb) => ({ originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]), - encodedVideoPath: eb.fn('REGEXP_REPLACE', ['encodedVideoPath', source, target]), })) .execute(); diff --git a/server/src/schema/migrations/1773242919341-EncodedVideoAssetFiles.ts b/server/src/schema/migrations/1773242919341-EncodedVideoAssetFiles.ts new file mode 100644 index 0000000000..4a62a7e842 --- /dev/null +++ b/server/src/schema/migrations/1773242919341-EncodedVideoAssetFiles.ts @@ -0,0 +1,25 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql` + INSERT INTO "asset_file" ("assetId", "type", "path") + SELECT "id", 'encoded_video', "encodedVideoPath" + FROM "asset" + WHERE "encodedVideoPath" IS NOT NULL AND "encodedVideoPath" != ''; + `.execute(db); + + await sql`ALTER TABLE "asset" DROP COLUMN "encodedVideoPath";`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "asset" ADD "encodedVideoPath" character varying DEFAULT '';`.execute(db); + + await sql` + UPDATE "asset" + SET "encodedVideoPath" = af."path" + FROM "asset_file" af + WHERE "asset"."id" = af."assetId" + AND af."type" = 'encoded_video' + AND af."isEdited" = false; + `.execute(db); +} diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index 12e9c36125..8bdaa59bc6 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -92,9 +92,6 @@ export class AssetTable { @Column({ type: 'character varying', nullable: true }) duration!: string | null; - @Column({ type: 'character varying', nullable: true, default: '' }) - encodedVideoPath!: string | null; - @Column({ type: 'bytea', index: true }) checksum!: Buffer; // sha1 checksum diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index f49dd3cb50..1bf8bafdf7 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -163,7 +163,6 @@ const assetEntity = Object.freeze({ fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), updatedAt: new Date('2022-06-19T23:41:36.910Z'), isFavorite: false, - encodedVideoPath: '', duration: '0:00:00.000000', files: [] as AssetFile[], exifInfo: { @@ -711,13 +710,18 @@ describe(AssetMediaService.name, () => { }); it('should return the encoded video path if available', async () => { - const asset = AssetFactory.create({ encodedVideoPath: '/path/to/encoded/video.mp4' }); + const asset = AssetFactory.from() + .file({ type: AssetFileType.EncodedVideo, path: '/path/to/encoded/video.mp4' }) + .build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getForVideo.mockResolvedValue(asset); + mocks.asset.getForVideo.mockResolvedValue({ + originalPath: asset.originalPath, + encodedVideoPath: asset.files[0].path, + }); await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual( new ImmichFileResponse({ - path: asset.encodedVideoPath!, + path: '/path/to/encoded/video.mp4', cacheControl: CacheControl.PrivateWithCache, contentType: 'video/mp4', }), @@ -727,7 +731,10 @@ describe(AssetMediaService.name, () => { it('should fall back to the original path', async () => { const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getForVideo.mockResolvedValue(asset); + mocks.asset.getForVideo.mockResolvedValue({ + originalPath: asset.originalPath, + encodedVideoPath: null, + }); await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual( new ImmichFileResponse({ diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 387b700f01..1e5d23a98d 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -370,7 +370,7 @@ export class AssetService extends BaseService { assetFiles.editedFullsizeFile?.path, assetFiles.editedPreviewFile?.path, assetFiles.editedThumbnailFile?.path, - asset.encodedVideoPath, + assetFiles.encodedVideoFile?.path, ]; if (deleteOnDisk && !asset.isOffline) { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 279d57becd..51a10a39c2 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -2254,7 +2254,9 @@ describe(MediaService.name, () => { }); it('should delete existing transcode if current policy does not require transcoding', async () => { - const asset = AssetFactory.create({ type: AssetType.Video, encodedVideoPath: '/encoded/video/path.mp4' }); + const asset = AssetFactory.from({ type: AssetType.Video }) + .file({ type: AssetFileType.EncodedVideo, path: '/encoded/video/path.mp4' }) + .build(); mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } }); mocks.assetJob.getForVideoConversion.mockResolvedValue(asset); @@ -2264,7 +2266,7 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).not.toHaveBeenCalled(); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FileDelete, - data: { files: [asset.encodedVideoPath] }, + data: { files: ['/encoded/video/path.mp4'] }, }); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 8158ade192..ea0b1e9142 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -39,7 +39,7 @@ import { VideoInterfaces, VideoStreamInfo, } from 'src/types'; -import { getDimensions } from 'src/utils/asset.util'; +import { getAssetFile, getDimensions } from 'src/utils/asset.util'; import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; @@ -605,10 +605,11 @@ export class MediaService extends BaseService { let { ffmpeg } = await this.getConfig({ withCache: true }); const target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream); if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) { - if (asset.encodedVideoPath) { + const encodedVideo = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false }); + if (encodedVideo) { this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`); - await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [asset.encodedVideoPath] } }); - await this.assetRepository.update({ id: asset.id, encodedVideoPath: null }); + await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [encodedVideo.path] } }); + await this.assetRepository.deleteFiles([encodedVideo]); } else { this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`); } @@ -656,7 +657,12 @@ export class MediaService extends BaseService { this.logger.log(`Successfully encoded ${asset.id}`); - await this.assetRepository.update({ id: asset.id, encodedVideoPath: output }); + await this.assetRepository.upsertFile({ + assetId: asset.id, + type: AssetFileType.EncodedVideo, + path: output, + isEdited: false, + }); return JobStatus.Success; } diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index d6ab825028..5420e60361 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -26,6 +26,8 @@ export const getAssetFiles = (files: AssetFile[]) => ({ editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }), editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }), editedThumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: true }), + + encodedVideoFile: getAssetFile(files, AssetFileType.EncodedVideo, { isEdited: false }), }); export const addAssets = async ( diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 2e22a9f479..03998d9462 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -355,7 +355,16 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!))) .$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!))) .$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!))) - .$if(!!options.encodedVideoPath, (qb) => qb.where('asset.encodedVideoPath', '=', options.encodedVideoPath!)) + .$if(!!options.encodedVideoPath, (qb) => + qb + .innerJoin('asset_file', (join) => + join + .onRef('asset.id', '=', 'asset_file.assetId') + .on('asset_file.type', '=', AssetFileType.EncodedVideo) + .on('asset_file.isEdited', '=', false), + ) + .where('asset_file.path', '=', options.encodedVideoPath!), + ) .$if(!!options.originalPath, (qb) => qb.where(sql`f_unaccent(asset."originalPath")`, 'ilike', sql`'%' || f_unaccent(${options.originalPath}) || '%'`), ) @@ -380,7 +389,15 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) .$if(options.isOffline !== undefined, (qb) => qb.where('asset.isOffline', '=', options.isOffline!)) .$if(options.isEncoded !== undefined, (qb) => - qb.where('asset.encodedVideoPath', options.isEncoded ? 'is not' : 'is', null), + qb.where((eb) => { + const exists = eb.exists((eb) => + eb + .selectFrom('asset_file') + .whereRef('assetId', '=', 'asset.id') + .where('type', '=', AssetFileType.EncodedVideo), + ); + return options.isEncoded ? exists : eb.not(exists); + }), ) .$if(options.isMotion !== undefined, (qb) => qb.where('asset.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null), diff --git a/server/test/factories/asset.factory.ts b/server/test/factories/asset.factory.ts index 4d54ba820b..ec596dc86e 100644 --- a/server/test/factories/asset.factory.ts +++ b/server/test/factories/asset.factory.ts @@ -55,7 +55,6 @@ export class AssetFactory { deviceId: '', duplicateId: null, duration: null, - encodedVideoPath: null, fileCreatedAt: newDate(), fileModifiedAt: newDate(), isExternal: false, diff --git a/server/test/mappers.ts b/server/test/mappers.ts index 73c1bcd6d7..7f324663be 100644 --- a/server/test/mappers.ts +++ b/server/test/mappers.ts @@ -183,7 +183,6 @@ export const getForAssetDeletion = (asset: ReturnType) => libraryId: asset.libraryId, ownerId: asset.ownerId, livePhotoVideoId: asset.livePhotoVideoId, - encodedVideoPath: asset.encodedVideoPath, originalPath: asset.originalPath, isOffline: asset.isOffline, exifInfo: asset.exifInfo ? getDehydrated(asset.exifInfo) : null, From c91d8745b460736fa2bb878857e26cf4d3891457 Mon Sep 17 00:00:00 2001 From: luis15pt <100942871+luis15pt@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:27:44 +0000 Subject: [PATCH 139/150] fix: use correct original URL for 360 video panorama playback (#26831) Co-authored-by: Claude Opus 4.6 --- web/src/lib/utils.spec.ts | 26 ++++++++++++++++++++++++++ web/src/lib/utils.ts | 4 +++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/web/src/lib/utils.spec.ts b/web/src/lib/utils.spec.ts index 3bc8665279..221fc38568 100644 --- a/web/src/lib/utils.spec.ts +++ b/web/src/lib/utils.spec.ts @@ -74,6 +74,32 @@ describe('utils', () => { expect(url).toContain(asset.id); }); + it('should return original URL for video assets with forceOriginal', () => { + const asset = assetFactory.build({ + originalPath: 'video.mp4', + originalMimeType: 'video/mp4', + type: AssetTypeEnum.Video, + }); + + const url = getAssetUrl({ asset, forceOriginal: true }); + + expect(url).toContain('/original'); + expect(url).toContain(asset.id); + }); + + it('should return thumbnail URL for video assets without forceOriginal', () => { + const asset = assetFactory.build({ + originalPath: 'video.mp4', + originalMimeType: 'video/mp4', + type: AssetTypeEnum.Video, + }); + + const url = getAssetUrl({ asset }); + + expect(url).toContain('/thumbnail'); + expect(url).toContain(asset.id); + }); + it('should return thumbnail URL for static images in shared link even with download and showMetadata permissions', () => { const asset = assetFactory.build({ originalPath: 'image.gif', diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 8b6665bf94..9d0c32ae94 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -200,7 +200,9 @@ const forceUseOriginal = (asset: AssetResponseDto) => { export const targetImageSize = (asset: AssetResponseDto, forceOriginal: boolean) => { if (forceOriginal || get(alwaysLoadOriginalFile) || forceUseOriginal(asset)) { - return isWebCompatibleImage(asset) ? AssetMediaSize.Original : AssetMediaSize.Fullsize; + return asset.type === AssetTypeEnum.Video || isWebCompatibleImage(asset) + ? AssetMediaSize.Original + : AssetMediaSize.Fullsize; } return AssetMediaSize.Preview; }; From 754f072ef9bb1fcebe4676bc5bb6e3b92ba1880d Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:37:51 +0100 Subject: [PATCH 140/150] fix(web): disable drag and drop for internal items (#26897) --- .../drag-and-drop-upload-overlay.svelte | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte index 77178aa992..b37b8c0739 100644 --- a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte +++ b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte @@ -13,6 +13,7 @@ let isInLockedFolder = $derived(isLockedFolderRoute(page.route.id)); let dragStartTarget: EventTarget | null = $state(null); + let isInternalDrag = false; const onDragEnter = (e: DragEvent) => { if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { @@ -133,7 +134,19 @@ } }; + const ondragstart = () => { + isInternalDrag = true; + }; + + const ondragend = () => { + isInternalDrag = false; + }; + const ondragenter = (e: DragEvent) => { + if (isInternalDrag) { + return; + } + e.preventDefault(); e.stopPropagation(); onDragEnter(e); @@ -146,6 +159,10 @@ }; const ondrop = async (e: DragEvent) => { + if (isInternalDrag) { + return; + } + e.preventDefault(); e.stopPropagation(); await onDrop(e); @@ -159,7 +176,7 @@ - + {#if dragStartTarget} From 226b9390dbf810d3c0c2961b672383fdb803e7af Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:38:21 -0500 Subject: [PATCH 141/150] fix(mobile): video auth (#26887) * fix video auth * update commit --- mobile/android/app/build.gradle | 2 + .../app/alextran/immich/MainActivity.kt | 2 + .../alextran/immich/core/HttpClientManager.kt | 66 +++++++++++++++++++ .../immich/images/RemoteImagesImpl.kt | 57 ++++------------ mobile/ios/Runner/AppDelegate.swift | 2 + mobile/pubspec.lock | 12 ++-- mobile/pubspec.yaml | 2 +- 7 files changed, 90 insertions(+), 53 deletions(-) diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index bd90986f60..103cf79e4e 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -113,6 +113,8 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "com.squareup.okhttp3:okhttp:$okhttp_version" implementation 'org.chromium.net:cronet-embedded:143.7445.0' + implementation("androidx.media3:media3-datasource-okhttp:1.9.2") + implementation("androidx.media3:media3-datasource-cronet:1.9.2") implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" implementation "androidx.work:work-runtime-ktx:$work_version" implementation "androidx.concurrent:concurrent-futures:$concurrent_version" diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index a85929a0e9..06649de8f0 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -12,6 +12,7 @@ import app.alextran.immich.connectivity.ConnectivityApiImpl import app.alextran.immich.core.HttpClientManager import app.alextran.immich.core.ImmichPlugin import app.alextran.immich.core.NetworkApiPlugin +import me.albemala.native_video_player.NativeVideoPlayerPlugin import app.alextran.immich.images.LocalImageApi import app.alextran.immich.images.LocalImagesImpl import app.alextran.immich.images.RemoteImageApi @@ -31,6 +32,7 @@ class MainActivity : FlutterFragmentActivity() { companion object { fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { HttpClientManager.initialize(ctx) + NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory flutterEngine.plugins.add(NetworkApiPlugin()) val messenger = flutterEngine.dartExecutor.binaryMessenger diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt index 180ae4735d..5b53b2a49a 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt @@ -3,7 +3,13 @@ package app.alextran.immich.core import android.content.Context import android.content.SharedPreferences import android.security.KeyChain +import androidx.annotation.OptIn import androidx.core.content.edit +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.ResolvingDataSource +import androidx.media3.datasource.cronet.CronetDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource import app.alextran.immich.BuildConfig import app.alextran.immich.NativeBuffer import okhttp3.Cache @@ -16,6 +22,7 @@ import okhttp3.Headers import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient +import org.chromium.net.CronetEngine import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import java.io.ByteArrayInputStream @@ -25,6 +32,8 @@ import java.security.KeyStore import java.security.Principal import java.security.PrivateKey import java.security.cert.X509Certificate +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext @@ -56,6 +65,7 @@ private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) { */ object HttpClientManager { private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB + const val MEDIA_CACHE_SIZE_BYTES = 1024L * 1024 * 1024 // 1GiB private const val KEEP_ALIVE_CONNECTIONS = 10 private const val KEEP_ALIVE_DURATION_MINUTES = 5L private const val MAX_REQUESTS_PER_HOST = 64 @@ -67,6 +77,11 @@ object HttpClientManager { private lateinit var appContext: Context private lateinit var prefs: SharedPreferences + var cronetEngine: CronetEngine? = null + private set + private lateinit var cronetStorageDir: File + val cronetExecutor: ExecutorService = Executors.newFixedThreadPool(4) + private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } var keyChainAlias: String? = null @@ -107,6 +122,10 @@ object HttpClientManager { val cacheDir = File(File(context.cacheDir, "okhttp"), "api") client = build(cacheDir) + + cronetStorageDir = File(context.cacheDir, "cronet").apply { mkdirs() } + cronetEngine = buildCronetEngine() + initialized = true } } @@ -223,6 +242,53 @@ object HttpClientManager { ?.joinToString("; ") { "${it.name}=${it.value}" } } + fun getAuthHeaders(url: String): Map { + val result = mutableMapOf() + headers.forEach { (key, value) -> result[key] = value } + loadCookieHeader(url)?.let { result["Cookie"] = it } + url.toHttpUrlOrNull()?.let { httpUrl -> + if (httpUrl.username.isNotEmpty()) { + result["Authorization"] = Credentials.basic(httpUrl.username, httpUrl.password) + } + } + return result + } + + fun rebuildCronetEngine(): CronetEngine { + val old = cronetEngine!! + cronetEngine = buildCronetEngine() + return old + } + + val cronetStoragePath: File get() = cronetStorageDir + + @OptIn(UnstableApi::class) + fun createDataSourceFactory(headers: Map): DataSource.Factory { + return if (isMtls) { + OkHttpDataSource.Factory(client.newBuilder().cache(null).build()) + } else { + ResolvingDataSource.Factory( + CronetDataSource.Factory(cronetEngine!!, cronetExecutor) + ) { dataSpec -> + val newHeaders = dataSpec.httpRequestHeaders.toMutableMap() + newHeaders.putAll(getAuthHeaders(dataSpec.uri.toString())) + newHeaders["Cache-Control"] = "no-store" + dataSpec.buildUpon().setHttpRequestHeaders(newHeaders).build() + } + } + } + + private fun buildCronetEngine(): CronetEngine { + return CronetEngine.Builder(appContext) + .enableHttp2(true) + .enableQuic(true) + .enableBrotli(true) + .setStoragePath(cronetStorageDir.absolutePath) + .setUserAgent(USER_AGENT) + .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, MEDIA_CACHE_SIZE_BYTES) + .build() + } + private fun build(cacheDir: File): OkHttpClient { val connectionPool = ConnectionPool( maxIdleConnections = KEEP_ALIVE_CONNECTIONS, diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index b820b45425..8e9fc3f6d5 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -7,7 +7,6 @@ import app.alextran.immich.INITIAL_BUFFER_SIZE import app.alextran.immich.NativeBuffer import app.alextran.immich.NativeByteBuffer import app.alextran.immich.core.HttpClientManager -import app.alextran.immich.core.USER_AGENT import kotlinx.coroutines.* import okhttp3.Cache import okhttp3.Call @@ -15,9 +14,6 @@ import okhttp3.Callback import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import okhttp3.Credentials -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.chromium.net.CronetEngine import org.chromium.net.CronetException import org.chromium.net.UrlRequest import org.chromium.net.UrlResponseInfo @@ -31,10 +27,6 @@ import java.nio.file.Path import java.nio.file.SimpleFileVisitor import java.nio.file.attribute.BasicFileAttributes import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Executors - - -private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024 private class RemoteRequest(val cancellationSignal: CancellationSignal) @@ -101,7 +93,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { } private object ImageFetcherManager { - private lateinit var appContext: Context private lateinit var cacheDir: File private lateinit var fetcher: ImageFetcher private var initialized = false @@ -110,7 +101,6 @@ private object ImageFetcherManager { if (initialized) return synchronized(this) { if (initialized) return - appContext = context.applicationContext cacheDir = context.cacheDir fetcher = build() HttpClientManager.addClientChangedListener(::invalidate) @@ -143,7 +133,7 @@ private object ImageFetcherManager { return if (HttpClientManager.isMtls) { OkHttpImageFetcher.create(cacheDir) } else { - CronetImageFetcher(appContext, cacheDir) + CronetImageFetcher() } } } @@ -161,19 +151,11 @@ private sealed interface ImageFetcher { fun clearCache(onCleared: (Result) -> Unit) } -private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher { - private val ctx = context - private var engine: CronetEngine - private val executor = Executors.newFixedThreadPool(4) +private class CronetImageFetcher : ImageFetcher { private val stateLock = Any() private var activeCount = 0 private var draining = false private var onCacheCleared: ((Result) -> Unit)? = null - private val storageDir = File(cacheDir, "cronet").apply { mkdirs() } - - init { - engine = build(context) - } override fun fetch( url: String, @@ -190,30 +172,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche } val callback = FetchCallback(onSuccess, onFailure, ::onComplete) - val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor) - HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) } - HttpClientManager.loadCookieHeader(url)?.let { requestBuilder.addHeader("Cookie", it) } - url.toHttpUrlOrNull()?.let { httpUrl -> - if (httpUrl.username.isNotEmpty()) { - requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password)) - } + val requestBuilder = HttpClientManager.cronetEngine!! + .newUrlRequestBuilder(url, callback, HttpClientManager.cronetExecutor) + HttpClientManager.getAuthHeaders(url).forEach { (key, value) -> + requestBuilder.addHeader(key, value) } val request = requestBuilder.build() signal.setOnCancelListener(request::cancel) request.start() } - private fun build(ctx: Context): CronetEngine { - return CronetEngine.Builder(ctx) - .enableHttp2(true) - .enableQuic(true) - .enableBrotli(true) - .setStoragePath(storageDir.absolutePath) - .setUserAgent(USER_AGENT) - .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES) - .build() - } - private fun onComplete() { val didDrain = synchronized(stateLock) { activeCount-- @@ -236,19 +204,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche } private fun onDrained() { - engine.shutdown() val onCacheCleared = synchronized(stateLock) { val onCacheCleared = onCacheCleared this.onCacheCleared = null onCacheCleared } - if (onCacheCleared == null) { - executor.shutdown() - } else { + if (onCacheCleared != null) { + val oldEngine = HttpClientManager.rebuildCronetEngine() + oldEngine.shutdown() CoroutineScope(Dispatchers.IO).launch { - val result = runCatching { deleteFolderAndGetSize(storageDir.toPath()) } - // Cronet is very good at self-repair, so it shouldn't fail here regardless of clear result - engine = build(ctx) + val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) } synchronized(stateLock) { draining = false } onCacheCleared(result) } @@ -375,7 +340,7 @@ private class OkHttpImageFetcher private constructor( val dir = File(cacheDir, "okhttp") val client = HttpClientManager.getClient().newBuilder() - .cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES)) + .cache(Cache(File(dir, "thumbnails"), HttpClientManager.MEDIA_CACHE_SIZE_BYTES)) .build() return OkHttpImageFetcher(client) diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index f842285b23..8487db7b48 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,5 +1,6 @@ import BackgroundTasks import Flutter +import native_video_player import network_info_plus import path_provider_foundation import permission_handler_apple @@ -18,6 +19,7 @@ import UIKit UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate } + SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage GeneratedPluginRegistrant.register(with: self) let controller: FlutterViewController = window?.rootViewController as! FlutterViewController AppDelegate.registerPlugins(with: controller.engine, controller: controller) diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index de116abb7e..89a43f328b 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1194,10 +1194,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1218,8 +1218,8 @@ packages: dependency: "direct main" description: path: "." - ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2" - resolved-ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2" + ref: cdf621bdb7edaf996e118a58a48f6441187d79c6 + resolved-ref: cdf621bdb7edaf996e118a58a48f6441187d79c6 url: "https://github.com/immich-app/native_video_player" source: git version: "1.3.1" @@ -1897,10 +1897,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" thumbhash: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 3a075d67ff..77955c06ab 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -56,7 +56,7 @@ dependencies: native_video_player: git: url: https://github.com/immich-app/native_video_player - ref: '0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2' + ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6' network_info_plus: ^6.1.3 octo_image: ^2.1.0 openapi: From c2a279e49ea585065f5a4e21450544862dc668c4 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:40:04 +0100 Subject: [PATCH 142/150] fix(web): keep header fixed on individual shared links (#26892) --- .../individual-shared-viewer.svelte | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index 64eb98bec0..0bf1a2f7f2 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -74,8 +74,12 @@ }; -
- {#if sharedLink?.allowUpload || assets.length > 1} +{#if sharedLink?.allowUpload || assets.length > 1} +
+ +
+ +
{#if assetInteraction.selectionActive} {/if} -
- -
- {:else if assets.length === 1} - {#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset} - {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} - - {/await} +
+{:else if assets.length === 1} + {#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset} + {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} + {/await} - {/if} -
+ {/await} +{/if} From e322d44f9553e51da46ed85568aaa3dc9951b7d2 Mon Sep 17 00:00:00 2001 From: Nathaniel Hourt Date: Fri, 13 Mar 2026 09:41:50 -0500 Subject: [PATCH 143/150] fix: SMTP over TLS (#26893) Final step on #22833 PReq #22833 is about adding support for SMTP-over-TLS rather than just STARTTLS when sending emails. That PReq adds almost everything; it just forgot to actually pass the flag to Nodemailer at the end. This adds that last line of code and makes it work correctly (for me, anyways!). Co-authored-by: Nathaniel --- server/src/repositories/email.repository.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/repositories/email.repository.ts b/server/src/repositories/email.repository.ts index 1bc4f0981a..a0cc23661a 100644 --- a/server/src/repositories/email.repository.ts +++ b/server/src/repositories/email.repository.ts @@ -162,6 +162,7 @@ export class EmailRepository { host: options.host, port: options.port, tls: { rejectUnauthorized: !options.ignoreCert }, + secure: options.secure, auth: options.username || options.password ? { From 10fa928abeee45e994ab29f9b36178982fb971a0 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 13 Mar 2026 15:43:00 +0100 Subject: [PATCH 144/150] feat: require pull requests to follow template (#26902) * feat: require pull requests to follow template * fix: persist-credentials: false --- .github/workflows/check-pr-template.yml | 80 +++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 .github/workflows/check-pr-template.yml diff --git a/.github/workflows/check-pr-template.yml b/.github/workflows/check-pr-template.yml new file mode 100644 index 0000000000..f60498d269 --- /dev/null +++ b/.github/workflows/check-pr-template.yml @@ -0,0 +1,80 @@ +name: Check PR Template + +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] + types: [opened, edited] + +permissions: {} + +jobs: + parse: + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.head.repo.fork == true }} + permissions: + contents: read + outputs: + uses_template: ${{ steps.check.outputs.uses_template }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: .github/pull_request_template.md + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Check required sections + id: check + env: + BODY: ${{ github.event.pull_request.body }} + run: | + OK=true + while IFS= read -r header; do + printf '%s\n' "$BODY" | grep -qF "$header" || OK=false + done < <(grep "^## " .github/pull_request_template.md) + echo "uses_template=$OK" >> "$GITHUB_OUTPUT" + + act: + runs-on: ubuntu-latest + needs: parse + permissions: + pull-requests: write + steps: + - name: Close PR + if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }} + env: + GH_TOKEN: ${{ github.token }} + NODE_ID: ${{ github.event.pull_request.node_id }} + run: | + gh api graphql \ + -f prId="$NODE_ID" \ + -f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \ + -f query=' + mutation CommentAndClosePR($prId: ID!, $body: String!) { + addComment(input: { + subjectId: $prId, + body: $body + }) { + __typename + } + closePullRequest(input: { + pullRequestId: $prId + }) { + __typename + } + }' + + - name: Reopen PR (sections now present, PR closed) + if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }} + env: + GH_TOKEN: ${{ github.token }} + NODE_ID: ${{ github.event.pull_request.node_id }} + run: | + gh api graphql \ + -f prId="$NODE_ID" \ + -f query=' + mutation ReopenPR($prId: ID!) { + reopenPullRequest(input: { + pullRequestId: $prId + }) { + __typename + } + }' From 55513cd59f74a48aecce7ac73c252009d6ab81da Mon Sep 17 00:00:00 2001 From: Belnadifia Date: Fri, 13 Mar 2026 22:14:45 +0100 Subject: [PATCH 145/150] feat(server): support IDPs that only send the userinfo in the ID token (#26717) Co-authored-by: irouply Co-authored-by: Daniel Dietzler --- e2e-auth-server/auth-server.ts | 29 ++++++++++++++++++--- e2e/src/specs/server/api/oauth.e2e-spec.ts | 19 ++++++++++++++ server/src/repositories/oauth.repository.ts | 11 +++++++- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/e2e-auth-server/auth-server.ts b/e2e-auth-server/auth-server.ts index a190ecd023..9aef56510d 100644 --- a/e2e-auth-server/auth-server.ts +++ b/e2e-auth-server/auth-server.ts @@ -10,6 +10,7 @@ export enum OAuthClient { export enum OAuthUser { NO_EMAIL = 'no-email', NO_NAME = 'no-name', + ID_TOKEN_CLAIMS = 'id-token-claims', WITH_QUOTA = 'with-quota', WITH_USERNAME = 'with-username', WITH_ROLE = 'with-role', @@ -52,12 +53,25 @@ const withDefaultClaims = (sub: string) => ({ email_verified: true, }); -const getClaims = (sub: string) => claims.find((user) => user.sub === sub) || withDefaultClaims(sub); +const getClaims = (sub: string, use?: string) => { + if (sub === OAuthUser.ID_TOKEN_CLAIMS) { + return { + sub, + email: `oauth-${sub}@immich.app`, + email_verified: true, + name: use === 'id_token' ? 'ID Token User' : 'Userinfo User', + }; + } + return claims.find((user) => user.sub === sub) || withDefaultClaims(sub); +}; const setup = async () => { const { privateKey, publicKey } = await generateKeyPair('RS256'); - const redirectUris = ['http://127.0.0.1:2285/auth/login', 'https://photos.immich.app/oauth/mobile-redirect']; + const redirectUris = [ + 'http://127.0.0.1:2285/auth/login', + 'https://photos.immich.app/oauth/mobile-redirect', + ]; const port = 2286; const host = '0.0.0.0'; const oidc = new Provider(`http://${host}:${port}`, { @@ -66,7 +80,10 @@ const setup = async () => { console.error(error); ctx.body = 'Internal Server Error'; }, - findAccount: (ctx, sub) => ({ accountId: sub, claims: () => getClaims(sub) }), + findAccount: (ctx, sub) => ({ + accountId: sub, + claims: (use) => getClaims(sub, use), + }), scopes: ['openid', 'email', 'profile'], claims: { openid: ['sub'], @@ -94,6 +111,7 @@ const setup = async () => { state: 'oidc.state', }, }, + conformIdTokenClaims: false, pkce: { required: () => false, }, @@ -125,7 +143,10 @@ const setup = async () => { ], }); - const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`); + const onStart = () => + console.log( + `[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`, + ); const app = oidc.listen(port, host, onStart); return () => app.close(); }; diff --git a/e2e/src/specs/server/api/oauth.e2e-spec.ts b/e2e/src/specs/server/api/oauth.e2e-spec.ts index cbd68c003a..ae9064375f 100644 --- a/e2e/src/specs/server/api/oauth.e2e-spec.ts +++ b/e2e/src/specs/server/api/oauth.e2e-spec.ts @@ -380,4 +380,23 @@ describe(`/oauth`, () => { }); }); }); + + describe('idTokenClaims', () => { + it('should use claims from the ID token if IDP includes them', async () => { + await setupOAuth(admin.accessToken, { + enabled: true, + clientId: OAuthClient.DEFAULT, + clientSecret: OAuthClient.DEFAULT, + }); + const callbackParams = await loginWithOAuth(OAuthUser.ID_TOKEN_CLAIMS); + const { status, body } = await request(app).post('/oauth/callback').send(callbackParams); + expect(status).toBe(201); + expect(body).toMatchObject({ + accessToken: expect.any(String), + name: 'ID Token User', + userEmail: 'oauth-id-token-claims@immich.app', + userId: expect.any(String), + }); + }); + }); }); diff --git a/server/src/repositories/oauth.repository.ts b/server/src/repositories/oauth.repository.ts index a42955ba10..5af5163f8f 100644 --- a/server/src/repositories/oauth.repository.ts +++ b/server/src/repositories/oauth.repository.ts @@ -70,7 +70,16 @@ export class OAuthRepository { try { const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier }); - const profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck); + + let profile: OAuthProfile; + const tokenClaims = tokens.claims(); + if (tokenClaims && 'email' in tokenClaims) { + this.logger.debug('Using ID token claims instead of userinfo endpoint'); + profile = tokenClaims as OAuthProfile; + } else { + profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck); + } + if (!profile.sub) { throw new Error('Unexpected profile response, no `sub`'); } From 2c6d4f3fe1df8b1acba1de90cda5312f61fd4323 Mon Sep 17 00:00:00 2001 From: rthrth-svg <267244824+rthrth-svg@users.noreply.github.com> Date: Fri, 13 Mar 2026 21:27:08 +0000 Subject: [PATCH 146/150] fix(web): copy yearMonth in MonthGroup to avoid shared object reference with asset (#26890) Co-authored-by: Min Idzelis --- .../timeline-manager/month-group.svelte.ts | 2 +- .../timeline-manager.svelte.spec.ts | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/web/src/lib/managers/timeline-manager/month-group.svelte.ts b/web/src/lib/managers/timeline-manager/month-group.svelte.ts index 3b3860eb9c..b41deb5785 100644 --- a/web/src/lib/managers/timeline-manager/month-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/month-group.svelte.ts @@ -60,7 +60,7 @@ export class MonthGroup { this.#initialCount = initialCount; this.#sortOrder = order; - this.yearMonth = yearMonth; + this.yearMonth = { year: yearMonth.year, month: yearMonth.month }; this.monthGroupTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth)); this.loader = new CancellableTask( diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts index 8addc173c4..943b5d12a8 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts @@ -355,6 +355,29 @@ describe('TimelineManager', () => { expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined(); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1); }); + + it('yearMonth is not a shared reference with asset.localDateTime (reference bug)', () => { + const asset = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), + }), + ); + + timelineManager.upsertAssets([asset]); + const januaryMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!; + const monthYearMonth = januaryMonth.yearMonth; + + const originalMonth = monthYearMonth.month; + expect(originalMonth).toEqual(1); + + // Simulating updateObject + asset.localDateTime.month = 3; + asset.localDateTime.day = 20; + + expect(monthYearMonth.month).toEqual(originalMonth); + expect(monthYearMonth.month).toEqual(1); + }); + it('asset is removed during upsert when TimelineManager if visibility changes', async () => { await timelineManager.updateOptions({ visibility: AssetVisibility.Archive, From 0581b497509d0ffe8296be194bdcf1b30b1ca313 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 13 Mar 2026 23:55:00 +0100 Subject: [PATCH 147/150] fix: ignore optional headers in pr template check (#26910) --- .github/workflows/check-pr-template.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-pr-template.yml b/.github/workflows/check-pr-template.yml index f60498d269..4dcdd20f72 100644 --- a/.github/workflows/check-pr-template.yml +++ b/.github/workflows/check-pr-template.yml @@ -29,7 +29,7 @@ jobs: OK=true while IFS= read -r header; do printf '%s\n' "$BODY" | grep -qF "$header" || OK=false - done < <(grep "^## " .github/pull_request_template.md) + done < <(sed '//d' .github/pull_request_template.md | grep "^## ") echo "uses_template=$OK" >> "$GITHUB_OUTPUT" act: From 48fe111daaf93d3a80346be05f57a6ebb91aa6d1 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Fri, 13 Mar 2026 23:04:55 -0400 Subject: [PATCH 148/150] feat(web): improve OCR overlay text fitting, reactivity, and accessibility (#26678) - Precise font sizing using canvas measureText instead of character-count heuristic - Fix overlay repositioning on viewport resize by computing metrics from reactive state instead of DOM reads - Fix animation delay on resize by using transition-colors instead of transition-all - Add keyboard accessibility: OCR boxes are focusable via Tab with reading-order sort - Show text on focus (same styling as hover) with proper ARIA attributes --- .../asset-viewer/ocr-bounding-box.svelte | 29 +++- .../asset-viewer/photo-viewer.svelte | 3 +- web/src/lib/utils/ocr-utils.ts | 125 +++++++++++++++++- 3 files changed, 149 insertions(+), 8 deletions(-) diff --git a/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte b/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte index 6f6caad0fc..d5551b9cc5 100644 --- a/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte +++ b/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte @@ -1,6 +1,6 @@
{ocrBox.text}
diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 55c765ce22..4a6a02cb4a 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -73,7 +73,8 @@ } const natural = getNaturalSize(assetViewerManager.imgRef); - const scaled = scaleToFit(natural, container); + const scaled = scaleToFit(natural, { width: containerWidth, height: containerHeight }); + return { contentWidth: scaled.width, contentHeight: scaled.height, diff --git a/web/src/lib/utils/ocr-utils.ts b/web/src/lib/utils/ocr-utils.ts index 3da36cf57a..c483eb9551 100644 --- a/web/src/lib/utils/ocr-utils.ts +++ b/web/src/lib/utils/ocr-utils.ts @@ -1,18 +1,38 @@ import type { OcrBoundingBox } from '$lib/stores/ocr.svelte'; import type { ContentMetrics } from '$lib/utils/container-utils'; +import { clamp } from 'lodash-es'; export type Point = { x: number; y: number; }; +const distance = (p1: Point, p2: Point) => Math.hypot(p2.x - p1.x, p2.y - p1.y); + +export type VerticalMode = 'none' | 'cjk' | 'rotated'; + export interface OcrBox { id: string; points: Point[]; text: string; confidence: number; + verticalMode: VerticalMode; } +const CJK_PATTERN = + /[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uAC00-\uD7AF\uFF00-\uFFEF]/; + +const VERTICAL_ASPECT_RATIO = 1.5; + +const containsCjk = (text: string): boolean => CJK_PATTERN.test(text); + +const getVerticalMode = (width: number, height: number, text: string): VerticalMode => { + if (height / width < VERTICAL_ASPECT_RATIO) { + return 'none'; + } + return containsCjk(text) ? 'cjk' : 'rotated'; +}; + /** * Calculate bounding box transform from OCR points. Result matrix can be used as input for css matrix3d. * @param points - Array of 4 corner points of the bounding box @@ -21,8 +41,6 @@ export interface OcrBox { export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[]; width: number; height: number } => { const [topLeft, topRight, bottomRight, bottomLeft] = points; - // Approximate width and height to prevent text distortion as much as possible - const distance = (p1: Point, p2: Point) => Math.hypot(p2.x - p1.x, p2.y - p1.y); const width = Math.max(distance(topLeft, topRight), distance(bottomLeft, bottomRight)); const height = Math.max(distance(topLeft, bottomLeft), distance(topRight, bottomRight)); @@ -55,6 +73,96 @@ export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[]; return { matrix, width, height }; }; +const BORDER_SIZE = 4; +const HORIZONTAL_PADDING = 16 + BORDER_SIZE; +const VERTICAL_PADDING = 8 + BORDER_SIZE; +const REFERENCE_FONT_SIZE = 100; +const MIN_FONT_SIZE = 8; +const MAX_FONT_SIZE = 96; +const FALLBACK_FONT = `${REFERENCE_FONT_SIZE}px sans-serif`; + +let sharedCanvasContext: CanvasRenderingContext2D | null = null; +let resolvedFont: string | undefined; + +const getCanvasContext = (): CanvasRenderingContext2D | null => { + if (sharedCanvasContext !== null) { + return sharedCanvasContext; + } + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) { + return null; + } + sharedCanvasContext = context; + return sharedCanvasContext; +}; + +const getReferenceFont = (): string => { + if (resolvedFont !== undefined) { + return resolvedFont; + } + const fontFamily = globalThis.getComputedStyle?.(document.documentElement).getPropertyValue('--font-sans').trim(); + resolvedFont = fontFamily ? `${REFERENCE_FONT_SIZE}px ${fontFamily}` : FALLBACK_FONT; + return resolvedFont; +}; + +export const calculateFittedFontSize = ( + text: string, + boxWidth: number, + boxHeight: number, + verticalMode: VerticalMode, +): number => { + const isVertical = verticalMode === 'cjk' || verticalMode === 'rotated'; + const availableWidth = boxWidth - (isVertical ? VERTICAL_PADDING : HORIZONTAL_PADDING); + const availableHeight = boxHeight - (isVertical ? HORIZONTAL_PADDING : VERTICAL_PADDING); + + const context = getCanvasContext(); + + if (verticalMode === 'cjk') { + if (!context) { + const fontSize = Math.min(availableWidth, availableHeight / text.length); + return clamp(fontSize, MIN_FONT_SIZE, MAX_FONT_SIZE); + } + + // eslint-disable-next-line tscompat/tscompat + context.font = getReferenceFont(); + + let maxCharWidth = 0; + let totalCharHeight = 0; + for (const character of text) { + const metrics = context.measureText(character); + const charWidth = metrics.width; + const charHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; + maxCharWidth = Math.max(maxCharWidth, charWidth); + totalCharHeight += Math.max(charWidth, charHeight); + } + + const scaleFromWidth = (availableWidth / maxCharWidth) * REFERENCE_FONT_SIZE; + const scaleFromHeight = (availableHeight / totalCharHeight) * REFERENCE_FONT_SIZE; + return clamp(Math.min(scaleFromWidth, scaleFromHeight), MIN_FONT_SIZE, MAX_FONT_SIZE); + } + + const fitWidth = verticalMode === 'rotated' ? availableHeight : availableWidth; + const fitHeight = verticalMode === 'rotated' ? availableWidth : availableHeight; + + if (!context) { + return clamp((1.4 * fitWidth) / text.length, MIN_FONT_SIZE, MAX_FONT_SIZE); + } + + // Unsupported in Safari iOS <16.6; falls back to default canvas font, giving less accurate but functional sizing + // eslint-disable-next-line tscompat/tscompat + context.font = getReferenceFont(); + + const metrics = context.measureText(text); + const measuredWidth = metrics.width; + const measuredHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; + + const scaleFromWidth = (fitWidth / measuredWidth) * REFERENCE_FONT_SIZE; + const scaleFromHeight = (fitHeight / measuredHeight) * REFERENCE_FONT_SIZE; + + return clamp(Math.min(scaleFromWidth, scaleFromHeight), MIN_FONT_SIZE, MAX_FONT_SIZE); +}; + export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentMetrics): OcrBox[] => { const boxes: OcrBox[] = []; for (const ocr of ocrData) { @@ -68,13 +176,26 @@ export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentM y: point.y * metrics.contentHeight + metrics.offsetY, })); + const boxWidth = Math.max(distance(points[0], points[1]), distance(points[3], points[2])); + const boxHeight = Math.max(distance(points[0], points[3]), distance(points[1], points[2])); + boxes.push({ id: ocr.id, points, text: ocr.text, confidence: ocr.textScore, + verticalMode: getVerticalMode(boxWidth, boxHeight, ocr.text), }); } + const rowThreshold = metrics.contentHeight * 0.02; + boxes.sort((a, b) => { + const yDifference = a.points[0].y - b.points[0].y; + if (Math.abs(yDifference) < rowThreshold) { + return a.points[0].x - b.points[0].x; + } + return yDifference; + }); + return boxes; }; From ff936f901d0f3c0ea058a25b93a17659105d6be2 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:09:42 -0500 Subject: [PATCH 149/150] fix(mobile): duplicate server urls returned (#26864) remove server url Co-authored-by: Alex --- mobile/lib/services/api.service.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index bc5e46f769..e296ac522d 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -176,10 +176,6 @@ class ApiService { if (serverEndpoint != null && serverEndpoint.isNotEmpty) { urls.add(serverEndpoint); } - final serverUrl = Store.tryGet(StoreKey.serverUrl); - if (serverUrl != null && serverUrl.isNotEmpty) { - urls.add(serverUrl); - } final localEndpoint = Store.tryGet(StoreKey.localEndpoint); if (localEndpoint != null && localEndpoint.isNotEmpty) { urls.add(localEndpoint); From b66c97b785169b3d40fb8b4d8d8027a72a2a5f4f Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:23:07 -0500 Subject: [PATCH 150/150] fix(mobile): use shared auth for background_downloader (#26911) shared client for background_downloader on ios --- .../alextran/immich/core/HttpClientManager.kt | 23 ++++++++ mobile/ios/Runner/AppDelegate.swift | 1 + .../ios/Runner/Core/URLSessionManager.swift | 55 +++++++++++++++++-- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt index 5b53b2a49a..e7268396e8 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt @@ -27,7 +27,11 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import java.io.ByteArrayInputStream import java.io.File +import java.net.Authenticator +import java.net.CookieHandler +import java.net.PasswordAuthentication import java.net.Socket +import java.net.URI import java.security.KeyStore import java.security.Principal import java.security.PrivateKey @@ -104,6 +108,25 @@ object HttpClientManager { keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null) cookieJar.init(prefs) + System.setProperty("http.agent", USER_AGENT) + Authenticator.setDefault(object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication? { + val url = requestingURL ?: return null + if (url.userInfo.isNullOrEmpty()) return null + val parts = url.userInfo.split(":", limit = 2) + return PasswordAuthentication(parts[0], parts.getOrElse(1) { "" }.toCharArray()) + } + }) + CookieHandler.setDefault(object : CookieHandler() { + override fun get(uri: URI, requestHeaders: Map>): Map> { + val httpUrl = uri.toString().toHttpUrlOrNull() ?: return emptyMap() + val cookies = cookieJar.loadForRequest(httpUrl) + if (cookies.isEmpty()) return emptyMap() + return mapOf("Cookie" to listOf(cookies.joinToString("; ") { "${it.name}=${it.value}" })) + } + + override fun put(uri: URI, responseHeaders: Map>) {} + }) val savedHeaders = prefs.getString(PREFS_HEADERS, null) if (savedHeaders != null) { diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 8487db7b48..81af41ab08 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -20,6 +20,7 @@ import UIKit } SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage + URLSessionManager.patchBackgroundDownloader() GeneratedPluginRegistrant.register(with: self) let controller: FlutterViewController = window?.rootViewController as! FlutterViewController AppDelegate.registerPlugins(with: controller.engine, controller: controller) diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift index 9868d4eb59..0b73ed71a6 100644 --- a/mobile/ios/Runner/Core/URLSessionManager.swift +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -51,7 +51,7 @@ class URLSessionManager: NSObject { diskCapacity: 1024 * 1024 * 1024, directory: cacheDir ) - private static let userAgent: String = { + static let userAgent: String = { let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" return "Immich_iOS_\(version)" }() @@ -158,6 +158,49 @@ class URLSessionManager: NSObject { return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) } + + /// Patches background_downloader's URLSession to use shared auth configuration. + /// Must be called before background_downloader creates its session (i.e. early in app startup). + static func patchBackgroundDownloader() { + // Swizzle URLSessionConfiguration.background(withIdentifier:) to inject shared config + let originalSel = NSSelectorFromString("backgroundSessionConfigurationWithIdentifier:") + let swizzledSel = #selector(URLSessionConfiguration.immich_background(withIdentifier:)) + if let original = class_getClassMethod(URLSessionConfiguration.self, originalSel), + let swizzled = class_getClassMethod(URLSessionConfiguration.self, swizzledSel) { + method_exchangeImplementations(original, swizzled) + } + + // Add auth challenge handling to background_downloader's UrlSessionDelegate + guard let targetClass = NSClassFromString("background_downloader.UrlSessionDelegate") else { return } + + let sessionBlock: @convention(block) (AnyObject, URLSession, URLAuthenticationChallenge, + @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void + = { _, session, challenge, completion in + URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion) + } + class_replaceMethod(targetClass, + NSSelectorFromString("URLSession:didReceiveChallenge:completionHandler:"), + imp_implementationWithBlock(sessionBlock), "v@:@@@?") + + let taskBlock: @convention(block) (AnyObject, URLSession, URLSessionTask, URLAuthenticationChallenge, + @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void + = { _, session, task, challenge, completion in + URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion, task: task) + } + class_replaceMethod(targetClass, + NSSelectorFromString("URLSession:task:didReceiveChallenge:completionHandler:"), + imp_implementationWithBlock(taskBlock), "v@:@@@@?") + } +} + +private extension URLSessionConfiguration { + @objc dynamic class func immich_background(withIdentifier id: String) -> URLSessionConfiguration { + // After swizzle, this calls the original implementation + let config = immich_background(withIdentifier: id) + config.httpCookieStorage = URLSessionManager.cookieStorage + config.httpAdditionalHeaders = ["User-Agent": URLSessionManager.userAgent] + return config + } } class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate { @@ -168,7 +211,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb ) { handleChallenge(session, challenge, completionHandler) } - + func urlSession( _ session: URLSession, task: URLSessionTask, @@ -177,7 +220,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb ) { handleChallenge(session, challenge, completionHandler, task: task) } - + func handleChallenge( _ session: URLSession, _ challenge: URLAuthenticationChallenge, @@ -190,7 +233,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb default: completionHandler(.performDefaultHandling, nil) } } - + private func handleClientCertificate( _ session: URLSession, completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void @@ -200,7 +243,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb kSecAttrLabel as String: CLIENT_CERT_LABEL, kSecReturnRef as String: true, ] - + var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) if status == errSecSuccess, let identity = item { @@ -214,7 +257,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb } completion(.performDefaultHandling, nil) } - + private func handleBasicAuth( _ session: URLSession, task: URLSessionTask?,