From 945f7fb9ea43867bddf8e203af93b769bed56233 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 13:38:13 -0500 Subject: [PATCH 001/156] chore(deps): update dependency lodash-es to v4.17.23 [security] (#25453) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59b4846bff..1776c56d7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,7 +36,7 @@ importers: version: 1.20.1 lodash-es: specifier: ^4.17.21 - version: 4.17.22 + version: 4.17.23 micromatch: specifier: ^4.0.8 version: 4.0.8 @@ -802,7 +802,7 @@ importers: version: 4.1.0 lodash-es: specifier: ^4.17.21 - version: 4.17.22 + version: 4.17.23 luxon: specifier: ^3.4.4 version: 3.7.2 @@ -8916,8 +8916,8 @@ packages: lodash-es@4.17.21: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} - lodash-es@4.17.22: - resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==} + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} @@ -19332,7 +19332,7 @@ snapshots: chevrotain-allstar@0.3.1(chevrotain@11.0.3): dependencies: chevrotain: 11.0.3 - lodash-es: 4.17.22 + lodash-es: 4.17.23 chevrotain@11.0.3: dependencies: @@ -20027,7 +20027,7 @@ snapshots: dagre-d3-es@7.0.13: dependencies: d3: 7.9.0 - lodash-es: 4.17.22 + lodash-es: 4.17.23 data-urls@3.0.2: dependencies: @@ -22376,7 +22376,7 @@ snapshots: lodash-es@4.17.21: {} - lodash-es@4.17.22: {} + lodash-es@4.17.23: {} lodash.camelcase@4.3.0: {} @@ -22810,7 +22810,7 @@ snapshots: dompurify: 3.3.1 katex: 0.16.27 khroma: 2.1.0 - lodash-es: 4.17.22 + lodash-es: 4.17.23 marked: 16.4.2 roughjs: 4.6.6 stylis: 4.3.6 @@ -25714,7 +25714,7 @@ snapshots: json-source-map: 0.6.1 jsonpath-plus: 10.3.0 jsonrepair: 3.13.1 - lodash-es: 4.17.22 + lodash-es: 4.17.23 memoize-one: 6.0.0 natural-compare-lite: 1.4.0 sass: 1.97.1 From 4bd01b70ff4f3b128b286b0424efb79792c64672 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Thu, 22 Jan 2026 12:41:01 -0600 Subject: [PATCH 002/156] fix: asset edit sequence (#25457) --- server/src/queries/asset.edit.repository.sql | 2 ++ server/src/repositories/asset-edit.repository.ts | 9 +++++---- .../1769105700133-AddAssetEditSequence.ts | 14 ++++++++++++++ server/src/schema/tables/asset-edit.table.ts | 5 +++++ 4 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 server/src/schema/migrations/1769105700133-AddAssetEditSequence.ts diff --git a/server/src/queries/asset.edit.repository.sql b/server/src/queries/asset.edit.repository.sql index d11bc7fe70..0cf62882db 100644 --- a/server/src/queries/asset.edit.repository.sql +++ b/server/src/queries/asset.edit.repository.sql @@ -15,3 +15,5 @@ from "asset_edit" where "assetId" = $1 +order by + "sequence" asc diff --git a/server/src/repositories/asset-edit.repository.ts b/server/src/repositories/asset-edit.repository.ts index fdfbc4e1d8..088cb1ccff 100644 --- a/server/src/repositories/asset-edit.repository.ts +++ b/server/src/repositories/asset-edit.repository.ts @@ -12,14 +12,14 @@ export class AssetEditRepository { @GenerateSql({ params: [DummyValue.UUID], }) - async replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise { - return await this.db.transaction().execute(async (trx) => { + replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise { + return this.db.transaction().execute(async (trx) => { await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute(); if (edits.length > 0) { return trx .insertInto('asset_edit') - .values(edits.map((edit) => ({ assetId, ...edit }))) + .values(edits.map((edit, i) => ({ assetId, sequence: i, ...edit }))) .returning(['action', 'parameters']) .execute() as Promise; } @@ -31,11 +31,12 @@ export class AssetEditRepository { @GenerateSql({ params: [DummyValue.UUID], }) - async getAll(assetId: string): Promise { + getAll(assetId: string): Promise { return this.db .selectFrom('asset_edit') .select(['action', 'parameters']) .where('assetId', '=', assetId) + .orderBy('sequence', 'asc') .execute() as Promise; } } diff --git a/server/src/schema/migrations/1769105700133-AddAssetEditSequence.ts b/server/src/schema/migrations/1769105700133-AddAssetEditSequence.ts new file mode 100644 index 0000000000..40c1723cd6 --- /dev/null +++ b/server/src/schema/migrations/1769105700133-AddAssetEditSequence.ts @@ -0,0 +1,14 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`DELETE FROM "asset_edit";`.execute(db); + await sql`ALTER TABLE "asset_edit" ADD "sequence" integer NOT NULL;`.execute(db); + await sql`ALTER TABLE "asset_edit" ADD CONSTRAINT "asset_edit_assetId_sequence_uq" UNIQUE ("assetId", "sequence");`.execute( + db, + ); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "asset_edit" DROP CONSTRAINT "asset_edit_assetId_sequence_uq";`.execute(db); + await sql`ALTER TABLE "asset_edit" DROP COLUMN "sequence";`.execute(db); +} diff --git a/server/src/schema/tables/asset-edit.table.ts b/server/src/schema/tables/asset-edit.table.ts index ad0b443b69..886b62dc0b 100644 --- a/server/src/schema/tables/asset-edit.table.ts +++ b/server/src/schema/tables/asset-edit.table.ts @@ -9,6 +9,7 @@ import { Generated, PrimaryGeneratedColumn, Table, + Unique, } from 'src/sql-tools'; @Table('asset_edit') @@ -19,6 +20,7 @@ import { referencingOldTableAs: 'deleted_edit', when: 'pg_trigger_depth() = 0', }) +@Unique({ columns: ['assetId', 'sequence'] }) export class AssetEditTable { @PrimaryGeneratedColumn() id!: Generated; @@ -31,4 +33,7 @@ export class AssetEditTable { @Column({ type: 'jsonb' }) parameters!: AssetEditActionParameter[T]; + + @Column({ type: 'integer' }) + sequence!: number; } From dd72c32c60e4459b9daba8cd57d0028cc8524c61 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 22 Jan 2026 13:44:00 -0500 Subject: [PATCH 003/156] feat: rename parallel tests to ui, split test step into: [e2e, ui] (#25439) --- .github/workflows/test.yml | 7 ++++++- e2e/playwright.config.ts | 14 +++++++------- ...arallel-e2e-spec.ts => asset-viewer.ui-spec.ts} | 0 ...ne.parallel-e2e-spec.ts => timeline.ui-spec.ts} | 0 4 files changed, 13 insertions(+), 8 deletions(-) rename e2e/src/web/specs/asset-viewer/{asset-viewer.parallel-e2e-spec.ts => asset-viewer.ui-spec.ts} (100%) rename e2e/src/web/specs/timeline/{timeline.parallel-e2e-spec.ts => timeline.ui-spec.ts} (100%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2aed8c6da2..fcd0fd8d5e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -502,7 +502,12 @@ jobs: - name: Run e2e tests (web) env: CI: true - run: npx playwright test + run: npx playwright test --project=chromium + if: ${{ !cancelled() }} + - name: Run ui tests (web) + env: + CI: true + run: npx playwright test --project=ui if: ${{ !cancelled() }} - name: Archive test results uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 8b7f289921..58f5997343 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -39,13 +39,13 @@ const config: PlaywrightTestConfig = { testMatch: /.*\.e2e-spec\.ts/, workers: 1, }, - // { - // name: 'parallel tests', - // use: { ...devices['Desktop Chrome'] }, - // testMatch: /.*\.parallel-e2e-spec\.ts/, - // fullyParallel: true, - // workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1), - // }, + { + name: 'ui', + use: { ...devices['Desktop Chrome'] }, + testMatch: /.*\.ui-spec\.ts/, + fullyParallel: true, + workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1), + }, // { // name: 'firefox', diff --git a/e2e/src/web/specs/asset-viewer/asset-viewer.parallel-e2e-spec.ts b/e2e/src/web/specs/asset-viewer/asset-viewer.ui-spec.ts similarity index 100% rename from e2e/src/web/specs/asset-viewer/asset-viewer.parallel-e2e-spec.ts rename to e2e/src/web/specs/asset-viewer/asset-viewer.ui-spec.ts diff --git a/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts b/e2e/src/web/specs/timeline/timeline.ui-spec.ts similarity index 100% rename from e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts rename to e2e/src/web/specs/timeline/timeline.ui-spec.ts From bccad2940e6c257a694c2b67022904e73328da0a Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 23 Jan 2026 03:16:30 +0530 Subject: [PATCH 004/156] fix: incorrect asset viewer scale on image frame update (#25430) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../asset_viewer/asset_viewer.page.dart | 13 +++++------- .../src/controller/photo_view_controller.dart | 10 ++++++++++ .../photo_view/src/photo_view_wrappers.dart | 20 +++++++++++-------- 3 files changed, 27 insertions(+), 16 deletions(-) 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 38b9c54a3e..e89a41481f 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -118,7 +118,6 @@ class _AssetViewerState extends ConsumerState { bool dragInProgress = false; bool shouldPopOnDrag = false; bool assetReloadRequested = false; - double? initialScale; double previousExtent = _kBottomSheetMinimumExtent; Offset dragDownPosition = Offset.zero; int totalAssets = 0; @@ -264,7 +263,6 @@ class _AssetViewerState extends ConsumerState { (context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent); controller.position = Offset(0, -verticalOffset); // Apply the zoom effect when the bottom sheet is showing - initialScale = controller.scale; controller.scale = (controller.scale ?? 1.0) + 0.01; } } @@ -316,7 +314,7 @@ class _AssetViewerState extends ConsumerState { hasDraggedDown = null; viewController?.animateMultiple( position: initialPhotoViewState.position, - scale: initialPhotoViewState.scale, + scale: viewController?.initialScale ?? initialPhotoViewState.scale, rotation: initialPhotoViewState.rotation, ); ref.read(assetViewerProvider.notifier).setOpacity(255); @@ -366,8 +364,9 @@ class _AssetViewerState extends ConsumerState { final maxScaleDistance = ctx.height * 0.5; final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio); double? updatedScale; - if (initialPhotoViewState.scale != null) { - updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction); + double? initialScale = viewController?.initialScale ?? initialPhotoViewState.scale; + if (initialScale != null) { + updatedScale = initialScale * (1.0 - scaleReduction); } final backgroundOpacity = (255 * (1.0 - (scaleReduction / dragRatio))).round(); @@ -481,8 +480,6 @@ class _AssetViewerState extends ConsumerState { void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) { ref.read(assetViewerProvider.notifier).setBottomSheet(true); - initialScale = viewController?.scale; - // viewController?.updateMultiple(scale: (viewController?.scale ?? 1.0) + 0.01); previousExtent = _kBottomSheetMinimumExtent; sheetCloseController = showBottomSheet( context: ctx, @@ -504,7 +501,7 @@ class _AssetViewerState extends ConsumerState { void _handleSheetClose() { viewController?.animateMultiple(position: Offset.zero); - viewController?.updateMultiple(scale: initialScale); + viewController?.updateMultiple(scale: viewController?.initialScale); ref.read(assetViewerProvider.notifier).setBottomSheet(false); sheetCloseController = null; shouldPopOnDrag = false; diff --git a/mobile/lib/widgets/photo_view/src/controller/photo_view_controller.dart b/mobile/lib/widgets/photo_view/src/controller/photo_view_controller.dart index 2c8b406385..b9475a9ee2 100644 --- a/mobile/lib/widgets/photo_view/src/controller/photo_view_controller.dart +++ b/mobile/lib/widgets/photo_view/src/controller/photo_view_controller.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:immich_mobile/widgets/photo_view/src/utils/ignorable_change_notifier.dart'; +import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_utils.dart'; /// The interface in which controllers will be implemented. /// @@ -62,6 +63,9 @@ abstract class PhotoViewControllerBase { /// The scale factor to transform the child (image or a customChild). late double? scale; + double? get initialScale; + ScaleBoundaries? scaleBoundaries; + /// Nevermind this method :D, look away void setScaleInvisibly(double? scale); @@ -141,6 +145,9 @@ class PhotoViewController implements PhotoViewControllerBase _outputCtrl; + @override + ScaleBoundaries? scaleBoundaries; + late void Function(Offset)? _animatePosition; late void Function(double)? _animateScale; late void Function(double)? _animateRotation; @@ -311,4 +318,7 @@ class PhotoViewController implements PhotoViewControllerBase scaleBoundaries?.initialScale ?? initial.scale; } 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 a2ad04e6b5..cd70745703 100644 --- a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart +++ b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart @@ -108,6 +108,17 @@ class _ImageWrapperState extends State { } } + // Should be called only when _imageSize is not null + ScaleBoundaries get scaleBoundaries { + return ScaleBoundaries( + widget.minScale ?? 0.0, + widget.maxScale ?? double.infinity, + widget.initialScale ?? PhotoViewComputedScale.contained, + widget.outerSize, + _imageSize!, + ); + } + // retrieve image from the provider void _resolveImage() { final ImageStream newStream = widget.imageProvider.resolve(const ImageConfiguration()); @@ -133,6 +144,7 @@ class _ImageWrapperState extends State { _lastStack = null; _didLoadSynchronously = synchronousCall; + widget.controller.scaleBoundaries = scaleBoundaries; } synchronousCall && !_didLoadSynchronously ? setupCB() : setState(setupCB); @@ -204,14 +216,6 @@ class _ImageWrapperState extends State { ); } - final scaleBoundaries = ScaleBoundaries( - widget.minScale ?? 0.0, - widget.maxScale ?? double.infinity, - widget.initialScale ?? PhotoViewComputedScale.contained, - widget.outerSize, - _imageSize!, - ); - return PhotoViewCore( imageProvider: widget.imageProvider, backgroundDecoration: widget.backgroundDecoration, From 9b2939d77882823a71d8ac049345a6e186915e18 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 23 Jan 2026 03:21:48 +0530 Subject: [PATCH 005/156] fix(mobile): bring back map settings (#25448) * fix(mobile): bring back map settings * chore: styling --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex --- mobile/lib/domain/models/events.model.dart | 5 + mobile/lib/domain/services/map.service.dart | 4 +- .../lib/domain/services/timeline.service.dart | 5 +- .../repositories/map.repository.dart | 25 ++++- .../repositories/timeline.repository.dart | 67 ++++++++++--- .../presentation/pages/drift_map.page.dart | 30 +++++- .../bottom_sheet/map_bottom_sheet.widget.dart | 11 ++- .../presentation/widgets/map/map.state.dart | 94 +++++++++++++++++-- .../presentation/widgets/map/map.widget.dart | 22 ++++- .../widgets/map/map_settings_sheet.dart | 61 ++++++++++++ .../infrastructure/map.provider.dart | 12 ++- .../map_settings/map_settings_list_tile.dart | 3 +- .../map_settings_time_dropdown.dart | 76 ++++++++------- .../map/map_settings/map_theme_picker.dart | 8 +- 14 files changed, 343 insertions(+), 80 deletions(-) create mode 100644 mobile/lib/presentation/widgets/map/map_settings_sheet.dart diff --git a/mobile/lib/domain/models/events.model.dart b/mobile/lib/domain/models/events.model.dart index b3ab756414..fc9cebc80f 100644 --- a/mobile/lib/domain/models/events.model.dart +++ b/mobile/lib/domain/models/events.model.dart @@ -30,3 +30,8 @@ class MultiSelectToggleEvent extends Event { final bool isEnabled; const MultiSelectToggleEvent(this.isEnabled); } + +// Map Events +class MapMarkerReloadEvent extends Event { + const MapMarkerReloadEvent(); +} diff --git a/mobile/lib/domain/services/map.service.dart b/mobile/lib/domain/services/map.service.dart index 8c50a5aaeb..6c64e2817e 100644 --- a/mobile/lib/domain/services/map.service.dart +++ b/mobile/lib/domain/services/map.service.dart @@ -1,5 +1,6 @@ import 'package:immich_mobile/domain/models/map.model.dart'; import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; typedef MapMarkerSource = Future> Function(LatLngBounds? bounds); @@ -11,7 +12,8 @@ class MapFactory { const MapFactory({required DriftMapRepository mapRepository}) : _mapRepository = mapRepository; - MapService remote(String ownerId) => MapService(_mapRepository.remote(ownerId)); + MapService remote(List ownerIds, TimelineMapOptions options) => + MapService(_mapRepository.remote(ownerIds, options)); } class MapService { diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index e866a965c4..61e114762c 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -11,7 +11,6 @@ import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:immich_mobile/utils/async_mutex.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; typedef TimelineAssetSource = Future> Function(int index, int count); @@ -82,8 +81,8 @@ class TimelineFactory { TimelineService fromAssetsWithBuckets(List assets, TimelineOrigin type) => TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type)); - TimelineService map(String userId, LatLngBounds bounds) => - TimelineService(_timelineRepository.map(userId, bounds, groupBy)); + TimelineService map(List userIds, TimelineMapOptions options) => + TimelineService(_timelineRepository.map(userIds, options, groupBy)); } class TimelineService { diff --git a/mobile/lib/infrastructure/repositories/map.repository.dart b/mobile/lib/infrastructure/repositories/map.repository.dart index 9b8cdcc19d..95e42337fc 100644 --- a/mobile/lib/infrastructure/repositories/map.repository.dart +++ b/mobile/lib/infrastructure/repositories/map.repository.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/services/map.service.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; class DriftMapRepository extends DriftDatabaseRepository { @@ -12,9 +13,27 @@ class DriftMapRepository extends DriftDatabaseRepository { const DriftMapRepository(super._db) : _db = _db; - MapQuery remote(String ownerId) => _mapQueryBuilder( - assetFilter: (row) => - row.deletedAt.isNull() & row.visibility.equalsValue(AssetVisibility.timeline) & row.ownerId.equals(ownerId), + MapQuery remote(List ownerIds, TimelineMapOptions options) => _mapQueryBuilder( + assetFilter: (row) { + Expression condition = + row.deletedAt.isNull() & + row.ownerId.isIn(ownerIds) & + _db.remoteAssetEntity.visibility.isIn([ + AssetVisibility.timeline.index, + if (options.includeArchived) AssetVisibility.archive.index, + ]); + + if (options.onlyFavorites) { + condition = condition & _db.remoteAssetEntity.isFavorite.equals(true); + } + + if (options.relativeDays != 0) { + final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); + condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate); + } + + return condition; + }, ); MapQuery _mapQueryBuilder({Expression Function($RemoteAssetEntityTable row)? assetFilter}) { diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index f57ef04b07..b0548bdd28 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -15,6 +15,22 @@ import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:stream_transform/stream_transform.dart'; +class TimelineMapOptions { + final LatLngBounds bounds; + final bool onlyFavorites; + final bool includeArchived; + final bool withPartners; + final int relativeDays; + + const TimelineMapOptions({ + required this.bounds, + this.onlyFavorites = false, + this.includeArchived = false, + this.withPartners = false, + this.relativeDays = 0, + }); +} + class DriftTimelineRepository extends DriftDatabaseRepository { final Drift _db; @@ -467,15 +483,15 @@ class DriftTimelineRepository extends DriftDatabaseRepository { return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get(); } - TimelineQuery map(String userId, LatLngBounds bounds, GroupAssetsBy groupBy) => ( - bucketSource: () => _watchMapBucket(userId, bounds, groupBy: groupBy), - assetSource: (offset, count) => _getMapBucketAssets(userId, bounds, offset: offset, count: count), + TimelineQuery map(List userIds, TimelineMapOptions options, GroupAssetsBy groupBy) => ( + bucketSource: () => _watchMapBucket(userIds, options, groupBy: groupBy), + assetSource: (offset, count) => _getMapBucketAssets(userIds, options, offset: offset, count: count), origin: TimelineOrigin.map, ); Stream> _watchMapBucket( - String userId, - LatLngBounds bounds, { + List userId, + TimelineMapOptions options, { GroupAssetsBy groupBy = GroupAssetsBy.day, }) { if (groupBy == GroupAssetsBy.none) { @@ -496,14 +512,26 @@ class DriftTimelineRepository extends DriftDatabaseRepository { ), ]) ..where( - _db.remoteAssetEntity.ownerId.equals(userId) & - _db.remoteExifEntity.inBounds(bounds) & - _db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & + _db.remoteAssetEntity.ownerId.isIn(userId) & + _db.remoteExifEntity.inBounds(options.bounds) & + _db.remoteAssetEntity.visibility.isIn([ + AssetVisibility.timeline.index, + if (options.includeArchived) AssetVisibility.archive.index, + ]) & _db.remoteAssetEntity.deletedAt.isNull(), ) ..groupBy([dateExp]) ..orderBy([OrderingTerm.desc(dateExp)]); + if (options.onlyFavorites) { + query.where(_db.remoteAssetEntity.isFavorite.equals(true)); + } + + if (options.relativeDays != 0) { + final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); + query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate)); + } + return query.map((row) { final timeline = row.read(dateExp)!.truncateDate(groupBy); final assetCount = row.read(assetCountExp)!; @@ -512,8 +540,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository { } Future> _getMapBucketAssets( - String userId, - LatLngBounds bounds, { + List userId, + TimelineMapOptions options, { required int offset, required int count, }) { @@ -526,13 +554,26 @@ class DriftTimelineRepository extends DriftDatabaseRepository { ), ]) ..where( - _db.remoteAssetEntity.ownerId.equals(userId) & - _db.remoteExifEntity.inBounds(bounds) & - _db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & + _db.remoteAssetEntity.ownerId.isIn(userId) & + _db.remoteExifEntity.inBounds(options.bounds) & + _db.remoteAssetEntity.visibility.isIn([ + AssetVisibility.timeline.index, + if (options.includeArchived) AssetVisibility.archive.index, + ]) & _db.remoteAssetEntity.deletedAt.isNull(), ) ..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]) ..limit(count, offset: offset); + + if (options.onlyFavorites) { + query.where(_db.remoteAssetEntity.isFavorite.equals(true)); + } + + if (options.relativeDays != 0) { + final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays)); + query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate)); + } + return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get(); } diff --git a/mobile/lib/presentation/pages/drift_map.page.dart b/mobile/lib/presentation/pages/drift_map.page.dart index de8dde7714..96384c97e5 100644 --- a/mobile/lib/presentation/pages/drift_map.page.dart +++ b/mobile/lib/presentation/pages/drift_map.page.dart @@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/map/map.widget.dart'; +import 'package:immich_mobile/presentation/widgets/map/map_settings_sheet.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @RoutePage() @@ -10,6 +11,16 @@ class DriftMapPage extends StatelessWidget { const DriftMapPage({super.key, this.initialLocation}); + void onSettingsPressed(BuildContext context) { + showModalBottomSheet( + elevation: 0.0, + showDragHandle: true, + isScrollControlled: true, + context: context, + builder: (_) => const DriftMapSettingsSheet(), + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -18,8 +29,8 @@ class DriftMapPage extends StatelessWidget { children: [ DriftMap(initialLocation: initialLocation), Positioned( - left: 16, - top: 60, + left: 20, + top: 70, child: IconButton.filled( color: Colors.white, onPressed: () => context.pop(), @@ -32,6 +43,21 @@ class DriftMapPage extends StatelessWidget { ), ), ), + Positioned( + right: 20, + top: 70, + child: IconButton.filled( + color: Colors.white, + onPressed: () => onSettingsPressed(context), + icon: const Icon(Icons.more_vert_rounded), + style: IconButton.styleFrom( + padding: const EdgeInsets.all(8), + backgroundColor: Colors.indigo, + shadowColor: Colors.black26, + elevation: 4, + ), + ), + ), ], ), ); diff --git a/mobile/lib/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart index ac3772a02b..d7ef604718 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart @@ -14,7 +14,7 @@ class MapBottomSheet extends StatelessWidget { Widget build(BuildContext context) { return BaseBottomSheet( initialChildSize: 0.25, - maxChildSize: 0.9, + maxChildSize: 0.75, shouldCloseOnMinExtent: false, resizeOnScroll: false, actions: [], @@ -38,8 +38,13 @@ class _ScopedMapTimeline extends StatelessWidget { throw Exception('User must be logged in to access archive'); } - final bounds = ref.watch(mapStateProvider).bounds; - final timelineService = ref.watch(timelineFactoryProvider).map(user.id, bounds); + final users = ref.watch(mapStateProvider).withPartners + ? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id] + : [user.id]; + + final timelineService = ref + .watch(timelineFactoryProvider) + .map(users, ref.watch(mapStateProvider).toOptions()); ref.onDispose(timelineService.dispose); return timelineService; }), diff --git a/mobile/lib/presentation/widgets/map/map.state.dart b/mobile/lib/presentation/widgets/map/map.state.dart index b849f954ae..bfd3011050 100644 --- a/mobile/lib/presentation/widgets/map/map.state.dart +++ b/mobile/lib/presentation/widgets/map/map.state.dart @@ -1,11 +1,30 @@ +import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/map.provider.dart'; +import 'package:immich_mobile/providers/map/map_state.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; class MapState { + final ThemeMode themeMode; final LatLngBounds bounds; + final bool onlyFavorites; + final bool includeArchived; + final bool withPartners; + final int relativeDays; - const MapState({required this.bounds}); + const MapState({ + this.themeMode = ThemeMode.system, + required this.bounds, + this.onlyFavorites = false, + this.includeArchived = false, + this.withPartners = false, + this.relativeDays = 0, + }); @override bool operator ==(covariant MapState other) { @@ -15,9 +34,31 @@ class MapState { @override int get hashCode => bounds.hashCode; - MapState copyWith({LatLngBounds? bounds}) { - return MapState(bounds: bounds ?? this.bounds); + MapState copyWith({ + LatLngBounds? bounds, + ThemeMode? themeMode, + bool? onlyFavorites, + bool? includeArchived, + bool? withPartners, + int? relativeDays, + }) { + return MapState( + bounds: bounds ?? this.bounds, + themeMode: themeMode ?? this.themeMode, + onlyFavorites: onlyFavorites ?? this.onlyFavorites, + includeArchived: includeArchived ?? this.includeArchived, + withPartners: withPartners ?? this.withPartners, + relativeDays: relativeDays ?? this.relativeDays, + ); } + + TimelineMapOptions toOptions() => TimelineMapOptions( + bounds: bounds, + onlyFavorites: onlyFavorites, + includeArchived: includeArchived, + withPartners: withPartners, + relativeDays: relativeDays, + ); } class MapStateNotifier extends Notifier { @@ -31,11 +72,50 @@ class MapStateNotifier extends Notifier { return true; } + void switchTheme(ThemeMode mode) { + // TODO: Remove this line when map theme provider is removed + // Until then, keep both in sync as MapThemeOverride uses map state provider + // ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapThemeMode, mode.index); + ref.read(mapStateNotifierProvider.notifier).switchTheme(mode); + state = state.copyWith(themeMode: mode); + } + + void switchFavoriteOnly(bool isFavoriteOnly) { + ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapShowFavoriteOnly, isFavoriteOnly); + state = state.copyWith(onlyFavorites: isFavoriteOnly); + EventStream.shared.emit(const MapMarkerReloadEvent()); + } + + void switchIncludeArchived(bool isIncludeArchived) { + ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapIncludeArchived, isIncludeArchived); + state = state.copyWith(includeArchived: isIncludeArchived); + EventStream.shared.emit(const MapMarkerReloadEvent()); + } + + void switchWithPartners(bool isWithPartners) { + ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapwithPartners, isWithPartners); + state = state.copyWith(withPartners: isWithPartners); + EventStream.shared.emit(const MapMarkerReloadEvent()); + } + + void setRelativeTime(int relativeDays) { + ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapRelativeDate, relativeDays); + state = state.copyWith(relativeDays: relativeDays); + EventStream.shared.emit(const MapMarkerReloadEvent()); + } + @override - MapState build() => MapState( - // TODO: set default bounds - bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)), - ); + MapState build() { + final appSettingsService = ref.read(appSettingsServiceProvider); + return MapState( + themeMode: ThemeMode.values[appSettingsService.getSetting(AppSettingsEnum.mapThemeMode)], + onlyFavorites: appSettingsService.getSetting(AppSettingsEnum.mapShowFavoriteOnly), + includeArchived: appSettingsService.getSetting(AppSettingsEnum.mapIncludeArchived), + withPartners: appSettingsService.getSetting(AppSettingsEnum.mapwithPartners), + relativeDays: appSettingsService.getSetting(AppSettingsEnum.mapRelativeDate), + bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)), + ); + } } // This provider watches the markers from the map service and serves the markers. diff --git a/mobile/lib/presentation/widgets/map/map.widget.dart b/mobile/lib/presentation/widgets/map/map.widget.dart index 17dcffdade..72f4e8bda6 100644 --- a/mobile/lib/presentation/widgets/map/map.widget.dart +++ b/mobile/lib/presentation/widgets/map/map.widget.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:geolocator/geolocator.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; @@ -51,11 +53,19 @@ class _DriftMapState extends ConsumerState { final _reloadMutex = AsyncMutex(); final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2)); final ValueNotifier bottomSheetOffset = ValueNotifier(0.25); + StreamSubscription? _eventSubscription; + + @override + void initState() { + super.initState(); + _eventSubscription = EventStream.shared.listen(_onEvent); + } @override void dispose() { _debouncer.dispose(); bottomSheetOffset.dispose(); + _eventSubscription?.cancel(); super.dispose(); } @@ -63,6 +73,8 @@ class _DriftMapState extends ConsumerState { mapController = controller; } + void _onEvent(_) => _debouncer.run(() => setBounds(forceReload: true)); + Future onMapReady() async { final controller = mapController; if (controller == null) { @@ -98,7 +110,7 @@ class _DriftMapState extends ConsumerState { ); } - _debouncer.run(setBounds); + _debouncer.run(() => setBounds(forceReload: true)); controller.addListener(onMapMoved); } @@ -110,7 +122,7 @@ class _DriftMapState extends ConsumerState { _debouncer.run(setBounds); } - Future setBounds() async { + Future setBounds({bool forceReload = false}) async { final controller = mapController; if (controller == null || !mounted) { return; @@ -127,7 +139,7 @@ class _DriftMapState extends ConsumerState { final bounds = await controller.getVisibleRegion(); unawaited( _reloadMutex.run(() async { - if (mounted && ref.read(mapStateProvider.notifier).setBounds(bounds)) { + if (mounted && (ref.read(mapStateProvider.notifier).setBounds(bounds) || forceReload)) { final markers = await ref.read(mapMarkerProvider(bounds).future); await reloadMarkers(markers); } @@ -203,7 +215,7 @@ class _Map extends StatelessWidget { onMapCreated: onMapCreated, onStyleLoadedCallback: onMapReady, attributionButtonPosition: AttributionButtonPosition.topRight, - attributionButtonMargins: Platform.isIOS ? const Point(40, 12) : const Point(40, 72), + attributionButtonMargins: const Point(8, kToolbarHeight), ), ), ); @@ -244,7 +256,7 @@ class _DynamicMyLocationButton extends StatelessWidget { valueListenable: bottomSheetOffset, builder: (context, offset, child) { return Positioned( - right: 16, + right: 20, bottom: context.height * (offset - 0.02) + context.padding.bottom, child: AnimatedOpacity( opacity: offset < 0.8 ? 1 : 0, diff --git a/mobile/lib/presentation/widgets/map/map_settings_sheet.dart b/mobile/lib/presentation/widgets/map/map_settings_sheet.dart new file mode 100644 index 0000000000..c581dd6292 --- /dev/null +++ b/mobile/lib/presentation/widgets/map/map_settings_sheet.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; +import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart'; +import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart'; +import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart'; + +class DriftMapSettingsSheet extends HookConsumerWidget { + const DriftMapSettingsSheet({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mapState = ref.watch(mapStateProvider); + + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.6, + builder: (ctx, scrollController) => SingleChildScrollView( + controller: scrollController, + child: Card( + elevation: 0.0, + shadowColor: Colors.transparent, + color: Colors.transparent, + margin: EdgeInsets.zero, + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + MapThemePicker( + themeMode: mapState.themeMode, + onThemeChange: (mode) => ref.read(mapStateProvider.notifier).switchTheme(mode), + ), + const Divider(height: 30, thickness: 1), + MapSettingsListTile( + title: "map_settings_only_show_favorites".t(context: context), + selected: mapState.onlyFavorites, + onChanged: (favoriteOnly) => ref.read(mapStateProvider.notifier).switchFavoriteOnly(favoriteOnly), + ), + MapSettingsListTile( + title: "map_settings_include_show_archived".t(context: context), + selected: mapState.includeArchived, + onChanged: (includeArchive) => + ref.read(mapStateProvider.notifier).switchIncludeArchived(includeArchive), + ), + MapSettingsListTile( + title: "map_settings_include_show_partners".t(context: context), + selected: mapState.withPartners, + onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners), + ), + MapTimeDropDown( + relativeTime: mapState.relativeDays, + onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time), + ), + const SizedBox(height: 20), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/providers/infrastructure/map.provider.dart b/mobile/lib/providers/infrastructure/map.provider.dart index e774cec756..d9d261521e 100644 --- a/mobile/lib/providers/infrastructure/map.provider.dart +++ b/mobile/lib/providers/infrastructure/map.provider.dart @@ -1,7 +1,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; -import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/domain/services/map.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; +import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; final mapRepositoryProvider = Provider((ref) => DriftMapRepository(ref.watch(driftProvider))); @@ -13,7 +15,11 @@ final mapServiceProvider = Provider( throw Exception('User must be logged in to access map'); } - final mapService = ref.watch(mapFactoryProvider).remote(user.id); + final users = ref.watch(mapStateProvider).withPartners + ? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id] + : [user.id]; + + final mapService = ref.watch(mapFactoryProvider).remote(users, ref.watch(mapStateProvider).toOptions()); return mapService; }, // Empty dependencies to inform the framework that this provider diff --git a/mobile/lib/widgets/map/map_settings/map_settings_list_tile.dart b/mobile/lib/widgets/map/map_settings/map_settings_list_tile.dart index e97875fd90..762c402def 100644 --- a/mobile/lib/widgets/map/map_settings/map_settings_list_tile.dart +++ b/mobile/lib/widgets/map/map_settings/map_settings_list_tile.dart @@ -1,4 +1,3 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -14,7 +13,7 @@ class MapSettingsListTile extends StatelessWidget { Widget build(BuildContext context) { return SwitchListTile.adaptive( activeThumbColor: context.primaryColor, - title: Text(title, style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(), + title: Text(title, style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5)), value: selected, onChanged: onChanged, ); diff --git a/mobile/lib/widgets/map/map_settings/map_settings_time_dropdown.dart b/mobile/lib/widgets/map/map_settings/map_settings_time_dropdown.dart index b601887e1e..2a4dacaff7 100644 --- a/mobile/lib/widgets/map/map_settings/map_settings_time_dropdown.dart +++ b/mobile/lib/widgets/map/map_settings/map_settings_time_dropdown.dart @@ -1,5 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; class MapTimeDropDown extends StatelessWidget { final int relativeTime; @@ -11,41 +13,47 @@ class MapTimeDropDown extends StatelessWidget { Widget build(BuildContext context) { final now = DateTime.now(); - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 20), - child: Text("date_range".tr(), style: const TextStyle(fontWeight: FontWeight.bold)), - ), - LayoutBuilder( - builder: (_, constraints) => DropdownMenu( - width: constraints.maxWidth * 0.9, - enableSearch: false, - enableFilter: false, - initialSelection: relativeTime, - onSelected: (value) => onTimeChange(value!), - dropdownMenuEntries: [ - DropdownMenuEntry(value: 0, label: "all".tr()), - DropdownMenuEntry(value: 1, label: "map_settings_date_range_option_day".tr()), - DropdownMenuEntry(value: 7, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "7"})), - DropdownMenuEntry(value: 30, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "30"})), - DropdownMenuEntry( - value: now - .difference(DateTime(now.year - 1, now.month, now.day, now.hour, now.minute, now.second)) - .inDays, - label: "map_settings_date_range_option_year".tr(), - ), - DropdownMenuEntry( - value: now - .difference(DateTime(now.year - 3, now.month, now.day, now.hour, now.minute, now.second)) - .inDays, - label: "map_settings_date_range_option_years".tr(namedArgs: {'years': "3"}), - ), - ], + return Padding( + padding: const EdgeInsets.only(left: 16, right: 28.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "date_range".t(context: context), + style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5), ), - ), - ], + Flexible( + child: DropdownMenu( + enableSearch: false, + enableFilter: false, + initialSelection: relativeTime, + onSelected: (value) => onTimeChange(value!), + dropdownMenuEntries: [ + DropdownMenuEntry(value: 0, label: "all".t(context: context)), + DropdownMenuEntry(value: 1, label: "map_settings_date_range_option_day".t(context: context)), + DropdownMenuEntry(value: 7, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "7"})), + DropdownMenuEntry( + value: 30, + label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "30"}), + ), + DropdownMenuEntry( + value: now + .difference(DateTime(now.year - 1, now.month, now.day, now.hour, now.minute, now.second)) + .inDays, + label: "map_settings_date_range_option_year".t(context: context), + ), + DropdownMenuEntry( + value: now + .difference(DateTime(now.year - 3, now.month, now.day, now.hour, now.minute, now.second)) + .inDays, + label: "map_settings_date_range_option_years".t(args: {'years': "3"}), + ), + ], + ), + ), + ], + ), ); } } diff --git a/mobile/lib/widgets/map/map_settings/map_theme_picker.dart b/mobile/lib/widgets/map/map_settings/map_theme_picker.dart index 63f35ebe4c..7866c0ecdc 100644 --- a/mobile/lib/widgets/map/map_settings/map_theme_picker.dart +++ b/mobile/lib/widgets/map/map_settings/map_theme_picker.dart @@ -1,6 +1,6 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -18,9 +18,9 @@ class MapThemePicker extends StatelessWidget { padding: const EdgeInsets.only(bottom: 20), child: Center( child: Text( - "map_settings_theme_settings", - style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold), - ).tr(), + "map_settings_theme_settings".t(context: context), + style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5), + ), ), ), Row( From a96a08939e5d2387504f599aa7c331f4baa77249 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 22 Jan 2026 22:11:57 -0500 Subject: [PATCH 006/156] refactor: rename preloadManager to imageManager (#25436) rename: preloadManager to imageManager --- .../openapi/lib/model/asset_media_size.dart | 3 ++ open-api/immich-openapi-specs.json | 1 + open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/dtos/asset-media.dto.ts | 1 + .../album-page/__tests__/album-cover.spec.ts | 6 +-- .../components/album-page/album-cover.svelte | 8 ++-- .../asset-viewer/activity-viewer.svelte | 6 +-- .../asset-viewer/album-list-item.svelte | 4 +- .../asset-viewer/asset-viewer.svelte | 20 ++++----- .../asset-viewer/detail-panel.svelte | 4 +- .../editor/transform-tool/crop-area.svelte | 4 +- .../asset-viewer/image-panorama-viewer.svelte | 11 +---- .../asset-viewer/photo-viewer.svelte | 4 +- .../asset-viewer/video-native-viewer.svelte | 10 +++-- .../asset-viewer/video-panorama-viewer.svelte | 11 ++--- .../asset-viewer/video-wrapper-viewer.svelte | 11 +++-- .../assets/thumbnail/image-thumbnail.svelte | 4 +- .../assets/thumbnail/thumbnail.svelte | 6 +-- .../memory-page/memory-photo-viewer.svelte | 4 +- .../memory-page/memory-video-viewer.svelte | 4 +- .../memory-page/memory-viewer.svelte | 6 +-- .../places-page/places-card-group.svelte | 4 +- .../shared-components/map/map.svelte | 4 +- .../side-bar/recent-albums.svelte | 4 +- .../covers/__tests__/share-cover.spec.ts | 6 +-- .../covers/share-cover.svelte | 4 +- .../duplicates/duplicate-asset.svelte | 4 +- .../workflows/WorkflowPickerItemCard.svelte | 4 +- web/src/lib/managers/ImageManager.svelte.ts | 43 +++++++++++++++++++ web/src/lib/managers/PreloadManager.svelte.ts | 38 ---------------- .../managers/edit/transform-manager.svelte.ts | 4 +- web/src/lib/utils.ts | 40 ++++++----------- web/src/lib/utils/people-utils.ts | 4 +- web/src/lib/utils/shared-links.ts | 4 +- web/src/routes/(user)/explore/+page.svelte | 4 +- .../(user)/photos/[[assetId=id]]/+page.svelte | 4 +- 36 files changed, 148 insertions(+), 152 deletions(-) create mode 100644 web/src/lib/managers/ImageManager.svelte.ts delete mode 100644 web/src/lib/managers/PreloadManager.svelte.ts diff --git a/mobile/openapi/lib/model/asset_media_size.dart b/mobile/openapi/lib/model/asset_media_size.dart index aa7e2a6f5c..087d19da1f 100644 --- a/mobile/openapi/lib/model/asset_media_size.dart +++ b/mobile/openapi/lib/model/asset_media_size.dart @@ -23,12 +23,14 @@ class AssetMediaSize { String toJson() => value; + static const original = AssetMediaSize._(r'original'); static const fullsize = AssetMediaSize._(r'fullsize'); static const preview = AssetMediaSize._(r'preview'); static const thumbnail = AssetMediaSize._(r'thumbnail'); /// List of all possible values in this [enum][AssetMediaSize]. static const values = [ + original, fullsize, preview, thumbnail, @@ -70,6 +72,7 @@ class AssetMediaSizeTypeTransformer { AssetMediaSize? decode(dynamic data, {bool allowNull = true}) { if (data != null) { switch (data) { + case r'original': return AssetMediaSize.original; case r'fullsize': return AssetMediaSize.fullsize; case r'preview': return AssetMediaSize.preview; case r'thumbnail': return AssetMediaSize.thumbnail; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index cb0c8f8a67..137e7045ac 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16301,6 +16301,7 @@ }, "AssetMediaSize": { "enum": [ + "original", "fullsize", "preview", "thumbnail" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 09a0860539..684818d28f 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -5660,6 +5660,7 @@ export enum MirrorAxis { Vertical = "vertical" } export enum AssetMediaSize { + Original = "original", Fullsize = "fullsize", Preview = "preview", Thumbnail = "thumbnail" diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index f5207d3048..3935774f3e 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -7,6 +7,7 @@ import { AssetVisibility } from 'src/enum'; import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation'; export enum AssetMediaSize { + Original = 'original', /** * An full-sized image extracted/converted from non-web-friendly formats like RAW/HIF. * or otherwise the original image itself. diff --git a/web/src/lib/components/album-page/__tests__/album-cover.spec.ts b/web/src/lib/components/album-page/__tests__/album-cover.spec.ts index 5fa8b96008..3d081a3d2d 100644 --- a/web/src/lib/components/album-page/__tests__/album-cover.spec.ts +++ b/web/src/lib/components/album-page/__tests__/album-cover.spec.ts @@ -1,5 +1,5 @@ import AlbumCover from '$lib/components/album-page/album-cover.svelte'; -import { getAssetThumbnailUrl } from '$lib/utils'; +import { getAssetMediaUrl } from '$lib/utils'; import { albumFactory } from '@test-data/factories/album-factory'; import { render } from '@testing-library/svelte'; @@ -7,7 +7,7 @@ vi.mock('$lib/utils'); describe('AlbumCover component', () => { it('renders an image when the album has a thumbnail', () => { - vi.mocked(getAssetThumbnailUrl).mockReturnValue('/asdf'); + vi.mocked(getAssetMediaUrl).mockReturnValue('/asdf'); const component = render(AlbumCover, { album: albumFactory.build({ albumName: 'someName', @@ -21,7 +21,7 @@ describe('AlbumCover component', () => { expect(img.getAttribute('loading')).toBe('lazy'); expect(img.className).toBe('size-full rounded-xl object-cover aspect-square text'); expect(img.getAttribute('src')).toBe('/asdf'); - expect(getAssetThumbnailUrl).toHaveBeenCalledWith({ id: '123' }); + expect(getAssetMediaUrl).toHaveBeenCalledWith({ id: '123' }); }); it('renders an image when the album has no thumbnail', () => { diff --git a/web/src/lib/components/album-page/album-cover.svelte b/web/src/lib/components/album-page/album-cover.svelte index 3f71bbe632..c6242c5fad 100644 --- a/web/src/lib/components/album-page/album-cover.svelte +++ b/web/src/lib/components/album-page/album-cover.svelte @@ -1,8 +1,8 @@ diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index 30ea8ff6bd..4120212165 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -7,7 +7,7 @@ import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { Route } from '$lib/route'; import { locale } from '$lib/stores/preferences.store'; - import { getAssetThumbnailUrl } from '$lib/utils'; + import { getAssetMediaUrl } from '$lib/utils'; import { getAssetType } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { isTenMinutesApart } from '$lib/utils/timesince'; @@ -142,7 +142,7 @@ Profile picture of {reaction.user.name}, who commented on this asset @@ -195,7 +195,7 @@ > Profile picture of {reaction.user.name}, who liked this asset diff --git a/web/src/lib/components/asset-viewer/album-list-item.svelte b/web/src/lib/components/asset-viewer/album-list-item.svelte index da0df21839..35b2ab99ff 100644 --- a/web/src/lib/components/asset-viewer/album-list-item.svelte +++ b/web/src/lib/components/asset-viewer/album-list-item.svelte @@ -1,7 +1,7 @@ {#if projectionType === ProjectionType.EQUIRECTANGULAR} - + {:else} import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; - import { preloadManager } from '$lib/managers/PreloadManager.svelte'; + import { imageManager } from '$lib/managers/ImageManager.svelte'; import { Icon } from '@immich/ui'; import { mdiEyeOffOutline } from '@mdi/js'; import type { ActionReturn } from 'svelte/action'; @@ -60,7 +60,7 @@ onComplete?.(false); } return { - destroy: () => preloadManager.cancelPreloadUrl(url), + destroy: () => imageManager.cancelPreloadUrl(url), }; } diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 63e6f7cc04..cd2bf1d670 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -1,7 +1,7 @@ {#if !imageLoaded} diff --git a/web/src/lib/components/memory-page/memory-video-viewer.svelte b/web/src/lib/components/memory-page/memory-video-viewer.svelte index 06a41e2ab9..45501aff0b 100644 --- a/web/src/lib/components/memory-page/memory-video-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-video-viewer.svelte @@ -2,7 +2,7 @@ import { assetViewerFadeDuration } from '$lib/constants'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { autoPlayVideo } from '$lib/stores/preferences.store'; - import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; + import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils'; import { AssetMediaSize } from '@immich/sdk'; import { onMount } from 'svelte'; import { fade } from 'svelte/transition'; @@ -32,7 +32,7 @@ playsinline class="h-full w-full rounded-2xl object-contain transition-all" src={getAssetPlaybackUrl({ id: asset.id })} - poster={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview })} + poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview })} draggable="false" muted={videoViewerMuted} volume={videoViewerVolume} diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 1b82de6a4b..3c7ec4b0db 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -30,7 +30,7 @@ import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte'; import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store'; import { preferences } from '$lib/stores/user.store'; - import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; + import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; import { cancelMultiselect } from '$lib/utils/asset-utils'; import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk'; @@ -449,7 +449,7 @@ {#if current.previousMemory && current.previousMemory.assets.length > 0} {$t('previous_memory')} @@ -598,7 +598,7 @@ {#if current.nextMemory && current.nextMemory.assets.length > 0} {$t('next_memory')} diff --git a/web/src/lib/components/places-page/places-card-group.svelte b/web/src/lib/components/places-page/places-card-group.svelte index 6675014704..c50f2a0441 100644 --- a/web/src/lib/components/places-page/places-card-group.svelte +++ b/web/src/lib/components/places-page/places-card-group.svelte @@ -1,7 +1,7 @@ From 84679fb2b2cdd5ca53f5d2b9b51f8ab67a061887 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 22 Jan 2026 22:12:56 -0500 Subject: [PATCH 007/156] refactor: use assetCacheManager for OCR data (#25437) --- web/src/lib/stores/ocr.svelte.spec.ts | 2 ++ web/src/lib/stores/ocr.svelte.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/web/src/lib/stores/ocr.svelte.spec.ts b/web/src/lib/stores/ocr.svelte.spec.ts index 516e9f9f92..5220cbb77d 100644 --- a/web/src/lib/stores/ocr.svelte.spec.ts +++ b/web/src/lib/stores/ocr.svelte.spec.ts @@ -1,3 +1,4 @@ +import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte'; import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte'; import { getAssetOcr } from '@immich/sdk'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -30,6 +31,7 @@ describe('OcrManager', () => { beforeEach(() => { // Reset the singleton state before each test ocrManager.clear(); + assetCacheManager.clearOcrCache(); vi.clearAllMocks(); }); diff --git a/web/src/lib/stores/ocr.svelte.ts b/web/src/lib/stores/ocr.svelte.ts index f68e550851..39c42875de 100644 --- a/web/src/lib/stores/ocr.svelte.ts +++ b/web/src/lib/stores/ocr.svelte.ts @@ -1,5 +1,5 @@ +import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte'; import { CancellableTask } from '$lib/utils/cancellable-task'; -import { getAssetOcr } from '@immich/sdk'; export type OcrBoundingBox = { id: string; @@ -38,7 +38,7 @@ class OcrManager { this.#cleared = false; } await this.#ocrLoader.execute(async () => { - this.#data = await getAssetOcr({ id }); + this.#data = await assetCacheManager.getAssetOcr(id); }, false); } From 20dca391430f8a492978d056c423305467bf3094 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:03:57 +0100 Subject: [PATCH 008/156] fix(server): scoped permissions for more endpoints (#25452) --- mobile/openapi/lib/model/permission.dart | 3 +++ open-api/immich-openapi-specs.json | 5 +++++ open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/controllers/asset-media.controller.ts | 2 +- server/src/controllers/asset.controller.ts | 2 +- server/src/controllers/view.controller.ts | 6 +++--- server/src/enum.ts | 2 ++ 7 files changed, 16 insertions(+), 5 deletions(-) diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 37aecc8b9c..01bb689538 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -72,6 +72,7 @@ class Permission { static const facePeriodRead = Permission._(r'face.read'); static const facePeriodUpdate = Permission._(r'face.update'); static const facePeriodDelete = Permission._(r'face.delete'); + static const folderPeriodRead = Permission._(r'folder.read'); static const jobPeriodCreate = Permission._(r'job.create'); static const jobPeriodRead = Permission._(r'job.read'); static const libraryPeriodCreate = Permission._(r'library.create'); @@ -230,6 +231,7 @@ class Permission { facePeriodRead, facePeriodUpdate, facePeriodDelete, + folderPeriodRead, jobPeriodCreate, jobPeriodRead, libraryPeriodCreate, @@ -423,6 +425,7 @@ class PermissionTypeTransformer { case r'face.read': return Permission.facePeriodRead; case r'face.update': return Permission.facePeriodUpdate; case r'face.delete': return Permission.facePeriodDelete; + case r'folder.read': return Permission.folderPeriodRead; case r'job.create': return Permission.jobPeriodCreate; case r'job.read': return Permission.jobPeriodRead; case r'library.create': return Permission.libraryPeriodCreate; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 137e7045ac..28b61c421e 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3173,6 +3173,7 @@ "state": "Stable" } ], + "x-immich-permission": "asset.upload", "x-immich-state": "Stable" } }, @@ -3225,6 +3226,7 @@ "state": "Stable" } ], + "x-immich-permission": "job.create", "x-immich-state": "Stable" } }, @@ -14618,6 +14620,7 @@ "state": "Stable" } ], + "x-immich-permission": "folder.read", "x-immich-state": "Stable" } }, @@ -14670,6 +14673,7 @@ "state": "Stable" } ], + "x-immich-permission": "folder.read", "x-immich-state": "Stable" } }, @@ -18959,6 +18963,7 @@ "face.read", "face.update", "face.delete", + "folder.read", "job.create", "job.read", "library.create", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 684818d28f..c18ae9f475 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -5524,6 +5524,7 @@ export enum Permission { FaceRead = "face.read", FaceUpdate = "face.update", FaceDelete = "face.delete", + FolderRead = "folder.read", JobCreate = "job.create", JobRead = "job.read", LibraryCreate = "library.create", diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index 788ee0c0ed..3ef63ff7f9 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -202,7 +202,7 @@ export class AssetMediaController { } @Post('exist') - @Authenticated() + @Authenticated({ permission: Permission.AssetUpload }) @Endpoint({ summary: 'Check existing assets', description: 'Checks if multiple assets exist on the server and returns all existing - used by background backup', diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 988623360b..8eb3a5ce44 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -66,7 +66,7 @@ export class AssetController { } @Post('jobs') - @Authenticated() + @Authenticated({ permission: Permission.JobCreate }) @HttpCode(HttpStatus.NO_CONTENT) @Endpoint({ summary: 'Run an asset job', diff --git a/server/src/controllers/view.controller.ts b/server/src/controllers/view.controller.ts index 8a977e15bc..b07d83fe58 100644 --- a/server/src/controllers/view.controller.ts +++ b/server/src/controllers/view.controller.ts @@ -3,7 +3,7 @@ import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { ApiTag } from 'src/enum'; +import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { ViewService } from 'src/services/view.service'; @@ -13,7 +13,7 @@ export class ViewController { constructor(private service: ViewService) {} @Get('folder/unique-paths') - @Authenticated() + @Authenticated({ permission: Permission.FolderRead }) @Endpoint({ summary: 'Retrieve unique paths', description: 'Retrieve a list of unique folder paths from asset original paths.', @@ -24,7 +24,7 @@ export class ViewController { } @Get('folder') - @Authenticated() + @Authenticated({ permission: Permission.FolderRead }) @Endpoint({ summary: 'Retrieve assets by original path', description: 'Retrieve assets that are children of a specific folder.', diff --git a/server/src/enum.ts b/server/src/enum.ts index 5a0f6bdbe0..8f509754da 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -146,6 +146,8 @@ export enum Permission { FaceUpdate = 'face.update', FaceDelete = 'face.delete', + FolderRead = 'folder.read', + JobCreate = 'job.create', JobRead = 'job.read', From 6d9dc466196065adbd1dfea22399b129285b3760 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 23 Jan 2026 09:24:25 -0500 Subject: [PATCH 009/156] chore: include sync dtos (#25470) --- open-api/bin/generate-open-api.sh | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 209 ++++++++++++++++++++ web/src/lib/stores/websocket.ts | 3 +- 3 files changed, 212 insertions(+), 2 deletions(-) diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index 43292089d7..522063185f 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -27,7 +27,7 @@ function dart { } function typescript { - pnpm dlx oazapfts --optimistic --argumentStyle=object --useEnumType immich-openapi-specs.json typescript-sdk/src/fetch-client.ts + pnpm dlx oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas immich-openapi-specs.json typescript-sdk/src/fetch-client.ts pnpm --filter @immich/sdk install --frozen-lockfile pnpm --filter @immich/sdk build } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c18ae9f475..41d4f2689d 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1875,6 +1875,210 @@ export type WorkflowUpdateDto = { name?: string; triggerType?: PluginTriggerType; }; +export type SyncAckV1 = {}; +export type SyncAlbumDeleteV1 = { + albumId: string; +}; +export type SyncAlbumToAssetDeleteV1 = { + albumId: string; + assetId: string; +}; +export type SyncAlbumToAssetV1 = { + albumId: string; + assetId: string; +}; +export type SyncAlbumUserDeleteV1 = { + albumId: string; + userId: string; +}; +export type SyncAlbumUserV1 = { + albumId: string; + role: AlbumUserRole; + userId: string; +}; +export type SyncAlbumV1 = { + createdAt: string; + description: string; + id: string; + isActivityEnabled: boolean; + name: string; + order: AssetOrder; + ownerId: string; + thumbnailAssetId: string | null; + updatedAt: string; +}; +export type SyncAssetDeleteV1 = { + assetId: string; +}; +export type SyncAssetExifV1 = { + assetId: string; + city: string | null; + country: string | null; + dateTimeOriginal: string | null; + description: string | null; + exifImageHeight: number | null; + exifImageWidth: number | null; + exposureTime: string | null; + fNumber: number | null; + fileSizeInByte: number | null; + focalLength: number | null; + fps: number | null; + iso: number | null; + latitude: number | null; + lensModel: string | null; + longitude: number | null; + make: string | null; + model: string | null; + modifyDate: string | null; + orientation: string | null; + profileDescription: string | null; + projectionType: string | null; + rating: number | null; + state: string | null; + timeZone: string | null; +}; +export type SyncAssetFaceDeleteV1 = { + assetFaceId: string; +}; +export type SyncAssetFaceV1 = { + assetId: string; + boundingBoxX1: number; + boundingBoxX2: number; + boundingBoxY1: number; + boundingBoxY2: number; + id: string; + imageHeight: number; + imageWidth: number; + personId: string | null; + sourceType: string; +}; +export type SyncAssetMetadataDeleteV1 = { + assetId: string; + key: string; +}; +export type SyncAssetMetadataV1 = { + assetId: string; + key: string; + value: object; +}; +export type SyncAssetV1 = { + checksum: string; + deletedAt: string | null; + duration: string | null; + fileCreatedAt: string | null; + fileModifiedAt: string | null; + height: number | null; + id: string; + isEdited: boolean; + isFavorite: boolean; + libraryId: string | null; + livePhotoVideoId: string | null; + localDateTime: string | null; + originalFileName: string; + ownerId: string; + stackId: string | null; + thumbhash: string | null; + "type": AssetTypeEnum; + visibility: AssetVisibility; + width: number | null; +}; +export type SyncAuthUserV1 = { + avatarColor: (UserAvatarColor) | null; + deletedAt: string | null; + email: string; + hasProfileImage: boolean; + id: string; + isAdmin: boolean; + name: string; + oauthId: string; + pinCode: string | null; + profileChangedAt: string; + quotaSizeInBytes: number | null; + quotaUsageInBytes: number; + storageLabel: string | null; +}; +export type SyncCompleteV1 = {}; +export type SyncMemoryAssetDeleteV1 = { + assetId: string; + memoryId: string; +}; +export type SyncMemoryAssetV1 = { + assetId: string; + memoryId: string; +}; +export type SyncMemoryDeleteV1 = { + memoryId: string; +}; +export type SyncMemoryV1 = { + createdAt: string; + data: object; + deletedAt: string | null; + hideAt: string | null; + id: string; + isSaved: boolean; + memoryAt: string; + ownerId: string; + seenAt: string | null; + showAt: string | null; + "type": MemoryType; + updatedAt: string; +}; +export type SyncPartnerDeleteV1 = { + sharedById: string; + sharedWithId: string; +}; +export type SyncPartnerV1 = { + inTimeline: boolean; + sharedById: string; + sharedWithId: string; +}; +export type SyncPersonDeleteV1 = { + personId: string; +}; +export type SyncPersonV1 = { + birthDate: string | null; + color: string | null; + createdAt: string; + faceAssetId: string | null; + id: string; + isFavorite: boolean; + isHidden: boolean; + name: string; + ownerId: string; + updatedAt: string; +}; +export type SyncResetV1 = {}; +export type SyncStackDeleteV1 = { + stackId: string; +}; +export type SyncStackV1 = { + createdAt: string; + id: string; + ownerId: string; + primaryAssetId: string; + updatedAt: string; +}; +export type SyncUserDeleteV1 = { + userId: string; +}; +export type SyncUserMetadataDeleteV1 = { + key: UserMetadataKey; + userId: string; +}; +export type SyncUserMetadataV1 = { + key: UserMetadataKey; + userId: string; + value: object; +}; +export type SyncUserV1 = { + avatarColor: (UserAvatarColor) | null; + deletedAt: string | null; + email: string; + hasProfileImage: boolean; + id: string; + name: string; + profileChangedAt: string; +}; /** * List all activities */ @@ -5939,3 +6143,8 @@ export enum OAuthTokenEndpointAuthMethod { ClientSecretPost = "client_secret_post", ClientSecretBasic = "client_secret_basic" } +export enum UserMetadataKey { + Preferences = "preferences", + License = "license", + Onboarding = "onboarding" +} diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 5e197fbb3f..335ec188ea 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -12,6 +12,7 @@ import { type MaintenanceStatusResponseDto, type NotificationDto, type ServerVersionResponseDto, + type SyncAssetV1, } from '@immich/sdk'; import { io, type Socket } from 'socket.io-client'; import { get, writable } from 'svelte/store'; @@ -40,7 +41,7 @@ export interface Events { AppRestartV1: (event: AppRestartEvent) => void; MaintenanceStatusV1: (event: MaintenanceStatusResponseDto) => void; - AssetEditReadyV1: (data: { asset: { id: string } }) => void; + AssetEditReadyV1: (data: { asset: SyncAssetV1 }) => void; } const websocket: Socket = io({ From 41c5a0ca2fb9dc6b5652a920afa0a47ecb5f9032 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:42:21 +0100 Subject: [PATCH 010/156] fix(docs): document that fullsize thumbnail might redirect to original (#25416) --- mobile/openapi/lib/api/assets_api.dart | 4 ++-- open-api/immich-openapi-specs.json | 2 +- server/src/controllers/asset-media.controller.ts | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index 03d91c9dae..e737397fc8 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -1691,7 +1691,7 @@ class AssetsApi { /// View asset thumbnail /// - /// Retrieve the thumbnail image for the specified asset. + /// Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission. /// /// Note: This method returns the HTTP [Response]. /// @@ -1747,7 +1747,7 @@ class AssetsApi { /// View asset thumbnail /// - /// Retrieve the thumbnail image for the specified asset. + /// Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission. /// /// Parameters: /// diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 28b61c421e..fb329d2653 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4279,7 +4279,7 @@ }, "/assets/{id}/thumbnail": { "get": { - "description": "Retrieve the thumbnail image for the specified asset.", + "description": "Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.", "operationId": "viewAsset", "parameters": [ { diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index 3ef63ff7f9..ec6083cfa8 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -147,7 +147,8 @@ export class AssetMediaController { @Authenticated({ permission: Permission.AssetView, sharedLink: true }) @Endpoint({ summary: 'View asset thumbnail', - description: 'Retrieve the thumbnail image for the specified asset.', + description: + 'Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) async viewAsset( From 3cb284c15aa0944390312a1c4ced6f6eaed19b9a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:18:19 +0100 Subject: [PATCH 011/156] chore(deps): update dependency lodash to v4.17.23 [security] (#25441) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1776c56d7c..624fbc1469 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -489,7 +489,7 @@ importers: version: 3.0.0(kysely@0.28.2)(postgres@3.4.8) lodash: specifier: ^4.17.21 - version: 4.17.21 + version: 4.17.23 luxon: specifier: ^3.4.2 version: 3.7.2 @@ -8964,6 +8964,9 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} @@ -14542,7 +14545,7 @@ snapshots: html-tags: 3.3.1 html-webpack-plugin: 5.6.5(webpack@5.104.1) leven: 3.1.0 - lodash: 4.17.21 + lodash: 4.17.23 open: 8.4.2 p-map: 4.0.0 prompts: 2.4.2 @@ -14659,7 +14662,7 @@ snapshots: cheerio: 1.0.0-rc.12 feed: 4.2.2 fs-extra: 11.3.2 - lodash: 4.17.21 + lodash: 4.17.23 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) schema-dts: 1.1.5 @@ -14701,7 +14704,7 @@ snapshots: combine-promises: 1.2.0 fs-extra: 11.3.2 js-yaml: 4.1.1 - lodash: 4.17.21 + lodash: 4.17.23 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) schema-dts: 1.1.5 @@ -15014,7 +15017,7 @@ snapshots: '@mdx-js/react': 3.1.1(@types/react@19.2.8)(react@18.3.1) clsx: 2.1.1 infima: 0.2.0-alpha.45 - lodash: 4.17.21 + lodash: 4.17.23 nprogress: 0.2.0 postcss: 8.5.6 prism-react-renderer: 2.4.1(react@18.3.1) @@ -15112,7 +15115,7 @@ snapshots: clsx: 2.1.1 eta: 2.2.0 fs-extra: 11.3.2 - lodash: 4.17.21 + lodash: 4.17.23 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) tslib: 2.8.1 @@ -15187,7 +15190,7 @@ snapshots: fs-extra: 11.3.2 joi: 17.13.3 js-yaml: 4.1.1 - lodash: 4.17.21 + lodash: 4.17.23 tslib: 2.8.1 transitivePeerDependencies: - '@swc/core' @@ -15212,7 +15215,7 @@ snapshots: gray-matter: 4.0.3 jiti: 1.21.7 js-yaml: 4.1.1 - lodash: 4.17.21 + lodash: 4.17.23 micromatch: 4.0.8 p-queue: 6.6.2 prompts: 2.4.2 @@ -15597,7 +15600,7 @@ snapshots: dependencies: '@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2) - lodash: 4.17.21 + lodash: 4.17.23 '@grpc/grpc-js@1.14.3': dependencies: @@ -18842,7 +18845,7 @@ snapshots: graceful-fs: 4.2.11 is-stream: 2.0.1 lazystream: 1.0.1 - lodash: 4.17.21 + lodash: 4.17.23 normalize-path: 3.0.0 readable-stream: 4.7.0 @@ -21575,7 +21578,7 @@ snapshots: dependencies: '@types/html-minifier-terser': 6.1.0 html-minifier-terser: 6.1.0 - lodash: 4.17.21 + lodash: 4.17.23 pretty-error: 4.0.0 tapable: 2.3.0 optionalDependencies: @@ -21769,7 +21772,7 @@ snapshots: cli-cursor: 3.1.0 cli-width: 3.0.0 figures: 3.2.0 - lodash: 4.17.21 + lodash: 4.17.23 mute-stream: 0.0.8 ora: 5.4.1 run-async: 2.4.1 @@ -22408,6 +22411,8 @@ snapshots: lodash@4.17.21: {} + lodash@4.17.23: {} + log-symbols@4.1.0: dependencies: chalk: 4.1.2 @@ -23383,7 +23388,7 @@ snapshots: node-emoji@1.11.0: dependencies: - lodash: 4.17.21 + lodash: 4.17.23 node-emoji@2.2.0: dependencies: @@ -24384,7 +24389,7 @@ snapshots: pretty-error@4.0.0: dependencies: - lodash: 4.17.21 + lodash: 4.17.23 renderkid: 3.0.0 pretty-format@27.5.1: @@ -24857,7 +24862,7 @@ snapshots: css-select: 4.3.0 dom-converter: 0.2.0 htmlparser2: 6.1.0 - lodash: 4.17.21 + lodash: 4.17.23 strip-ansi: 6.0.1 repeat-string@1.6.1: {} From 574d9c34ffe8a6eac87d02b3ceb129ce7eaab6cf Mon Sep 17 00:00:00 2001 From: Yaros Date: Fri, 23 Jan 2026 16:47:46 +0100 Subject: [PATCH 012/156] feat(mobile): star rating (#24457) * feat(mobile): star rating * refactor: use custom rating bar & provider * refactor: remove user prop from provider * feat: clear, padding, star size, impl suggestions * chore: switch to rounded star icons * fix: alignment & gesturedetector * feat: rating search filter --- i18n/en.json | 1 + mobile/lib/domain/models/exif.model.dart | 7 + .../infrastructure/entities/exif.entity.dart | 1 + .../repositories/remote_asset.repository.dart | 6 + .../repositories/search_api.repository.dart | 2 + .../models/search/search_filter.model.dart | 44 +++++- .../places/places_collection.page.dart | 1 + mobile/lib/pages/search/search.page.dart | 1 + .../pages/search/drift_search.page.dart | 45 +++++++ .../similar_photos_action_button.widget.dart | 1 + .../asset_viewer/bottom_sheet.widget.dart | 37 ++++++ .../asset_viewer/rating_bar.widget.dart | 125 ++++++++++++++++++ .../infrastructure/action.provider.dart | 16 +++ .../user_metadata.provider.dart | 15 +++ .../repositories/asset_api.repository.dart | 4 + mobile/lib/services/action.service.dart | 8 ++ mobile/lib/widgets/search/explore_grid.dart | 1 + .../search_filter/star_rating_picker.dart | 35 +++++ 18 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart create mode 100644 mobile/lib/widgets/search/search_filter/star_rating_picker.dart diff --git a/i18n/en.json b/i18n/en.json index a2da7b783b..c1c06f47fc 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1911,6 +1911,7 @@ "search_filter_media_type_title": "Select media type", "search_filter_ocr": "Search by OCR", "search_filter_people_title": "Select people", + "search_filter_star_rating": "Star Rating", "search_for": "Search for", "search_for_existing_person": "Search for existing person", "search_no_more_result": "No more results", diff --git a/mobile/lib/domain/models/exif.model.dart b/mobile/lib/domain/models/exif.model.dart index 46e2352ac8..d0f78b59de 100644 --- a/mobile/lib/domain/models/exif.model.dart +++ b/mobile/lib/domain/models/exif.model.dart @@ -6,6 +6,7 @@ class ExifInfo { final String? orientation; final String? timeZone; final DateTime? dateTimeOriginal; + final int? rating; // GPS final double? latitude; @@ -46,6 +47,7 @@ class ExifInfo { this.orientation, this.timeZone, this.dateTimeOriginal, + this.rating, this.isFlipped = false, this.latitude, this.longitude, @@ -71,6 +73,7 @@ class ExifInfo { other.orientation == orientation && other.timeZone == timeZone && other.dateTimeOriginal == dateTimeOriginal && + other.rating == rating && other.latitude == latitude && other.longitude == longitude && other.city == city && @@ -94,6 +97,7 @@ class ExifInfo { isFlipped.hashCode ^ timeZone.hashCode ^ dateTimeOriginal.hashCode ^ + rating.hashCode ^ latitude.hashCode ^ longitude.hashCode ^ city.hashCode ^ @@ -118,6 +122,7 @@ orientation: ${orientation ?? 'NA'}, isFlipped: $isFlipped, timeZone: ${timeZone ?? 'NA'}, dateTimeOriginal: ${dateTimeOriginal ?? 'NA'}, +rating: ${rating ?? 'NA'}, latitude: ${latitude ?? 'NA'}, longitude: ${longitude ?? 'NA'}, city: ${city ?? 'NA'}, @@ -140,6 +145,7 @@ exposureSeconds: ${exposureSeconds ?? 'NA'}, String? orientation, String? timeZone, DateTime? dateTimeOriginal, + int? rating, double? latitude, double? longitude, String? city, @@ -161,6 +167,7 @@ exposureSeconds: ${exposureSeconds ?? 'NA'}, orientation: orientation ?? this.orientation, timeZone: timeZone ?? this.timeZone, dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal, + rating: rating ?? this.rating, isFlipped: isFlipped ?? this.isFlipped, latitude: latitude ?? this.latitude, longitude: longitude ?? this.longitude, diff --git a/mobile/lib/infrastructure/entities/exif.entity.dart b/mobile/lib/infrastructure/entities/exif.entity.dart index 2dbe05b9d7..77cae5dbbe 100644 --- a/mobile/lib/infrastructure/entities/exif.entity.dart +++ b/mobile/lib/infrastructure/entities/exif.entity.dart @@ -151,6 +151,7 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData { domain.ExifInfo toDto() => domain.ExifInfo( fileSize: fileSize, dateTimeOriginal: dateTimeOriginal, + rating: rating, timeZone: timeZone, make: make, model: model, diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 96c204ea0e..df4172df99 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -255,6 +255,12 @@ class RemoteAssetRepository extends DriftDatabaseRepository { ); } + Future updateRating(String assetId, int rating) async { + await (_db.remoteExifEntity.update()..where((row) => row.assetId.equals(assetId))).write( + RemoteExifEntityCompanion(rating: Value(rating)), + ); + } + Future getCount() { return _db.managers.remoteAssetEntity.count(); } diff --git a/mobile/lib/infrastructure/repositories/search_api.repository.dart b/mobile/lib/infrastructure/repositories/search_api.repository.dart index 34870dc1b3..043a42b1a4 100644 --- a/mobile/lib/infrastructure/repositories/search_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/search_api.repository.dart @@ -31,6 +31,7 @@ class SearchApiRepository extends ApiRepository { takenAfter: filter.date.takenAfter, takenBefore: filter.date.takenBefore, visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline, + rating: filter.rating.rating, isFavorite: filter.display.isFavorite ? true : null, isNotInAlbum: filter.display.isNotInAlbum ? true : null, personIds: filter.people.map((e) => e.id).toList(), @@ -54,6 +55,7 @@ class SearchApiRepository extends ApiRepository { takenAfter: filter.date.takenAfter, takenBefore: filter.date.takenBefore, visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline, + rating: filter.rating.rating, isFavorite: filter.display.isFavorite ? true : null, isNotInAlbum: filter.display.isNotInAlbum ? true : null, personIds: filter.people.map((e) => e.id).toList(), diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 93322f5031..2d45913fcb 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -126,6 +126,41 @@ class SearchDateFilter { int get hashCode => takenBefore.hashCode ^ takenAfter.hashCode; } +class SearchRatingFilter { + int? rating; + SearchRatingFilter({this.rating}); + + SearchRatingFilter copyWith({int? rating}) { + return SearchRatingFilter(rating: rating ?? this.rating); + } + + Map toMap() { + return {'rating': rating}; + } + + factory SearchRatingFilter.fromMap(Map map) { + return SearchRatingFilter(rating: map['rating'] != null ? map['rating'] as int : null); + } + + String toJson() => json.encode(toMap()); + + factory SearchRatingFilter.fromJson(String source) => + SearchRatingFilter.fromMap(json.decode(source) as Map); + + @override + String toString() => 'SearchRatingFilter(rating: $rating)'; + + @override + bool operator ==(covariant SearchRatingFilter other) { + if (identical(this, other)) return true; + + return other.rating == rating; + } + + @override + int get hashCode => rating.hashCode; +} + class SearchDisplayFilters { bool isNotInAlbum = false; bool isArchive = false; @@ -183,6 +218,7 @@ class SearchFilter { SearchLocationFilter location; SearchCameraFilter camera; SearchDateFilter date; + SearchRatingFilter rating; SearchDisplayFilters display; // Enum @@ -200,6 +236,7 @@ class SearchFilter { required this.camera, required this.date, required this.display, + required this.rating, required this.mediaType, }); @@ -220,6 +257,7 @@ class SearchFilter { display.isNotInAlbum == false && display.isArchive == false && display.isFavorite == false && + rating.rating == null && mediaType == AssetType.other; } @@ -235,6 +273,7 @@ class SearchFilter { SearchCameraFilter? camera, SearchDateFilter? date, SearchDisplayFilters? display, + SearchRatingFilter? rating, AssetType? mediaType, }) { return SearchFilter( @@ -249,13 +288,14 @@ class SearchFilter { camera: camera ?? this.camera, date: date ?? this.date, display: display ?? this.display, + rating: rating ?? this.rating, mediaType: mediaType ?? this.mediaType, ); } @override String toString() { - return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType, assetId: $assetId)'; + return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)'; } @override @@ -273,6 +313,7 @@ class SearchFilter { other.camera == camera && other.date == date && other.display == display && + other.rating == rating && other.mediaType == mediaType; } @@ -289,6 +330,7 @@ class SearchFilter { camera.hashCode ^ date.hashCode ^ display.hashCode ^ + rating.hashCode ^ mediaType.hashCode; } } diff --git a/mobile/lib/pages/library/places/places_collection.page.dart b/mobile/lib/pages/library/places/places_collection.page.dart index f376709316..d6511cb25b 100644 --- a/mobile/lib/pages/library/places/places_collection.page.dart +++ b/mobile/lib/pages/library/places/places_collection.page.dart @@ -113,6 +113,7 @@ class PlaceTile extends StatelessWidget { camera: SearchCameraFilter(), date: SearchDateFilter(), display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), + rating: SearchRatingFilter(), mediaType: AssetType.other, ), ), diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 902110f6a8..dbd32ac94b 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -43,6 +43,7 @@ class SearchPage extends HookConsumerWidget { date: prefilter?.date ?? SearchDateFilter(), display: prefilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), mediaType: prefilter?.mediaType ?? AssetType.other, + rating: prefilter?.rating ?? SearchRatingFilter(), language: "${context.locale.languageCode}-${context.locale.countryCode}", ), ); diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 58ca892f5f..16655e98f6 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_s import 'package:immich_mobile/presentation/widgets/search/quick_date_picker.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/common/feature_check.dart'; @@ -30,6 +31,7 @@ import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dar import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart'; import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart'; import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart'; +import 'package:immich_mobile/widgets/search/search_filter/star_rating_picker.dart'; @RoutePage() class DriftSearchPage extends HookConsumerWidget { @@ -48,6 +50,7 @@ class DriftSearchPage extends HookConsumerWidget { camera: preFilter?.camera ?? SearchCameraFilter(), date: preFilter?.date ?? SearchDateFilter(), display: preFilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), + rating: preFilter?.rating ?? SearchRatingFilter(), mediaType: preFilter?.mediaType ?? AssetType.other, language: "${context.locale.languageCode}-${context.locale.countryCode}", assetId: preFilter?.assetId, @@ -62,10 +65,15 @@ class DriftSearchPage extends HookConsumerWidget { final cameraCurrentFilterWidget = useState(null); final locationCurrentFilterWidget = useState(null); final mediaTypeCurrentFilterWidget = useState(null); + final ratingCurrentFilterWidget = useState(null); final displayOptionCurrentFilterWidget = useState(null); final isSearching = useState(false); + final isRatingEnabled = ref + .watch(userMetadataPreferencesProvider) + .maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false); + SnackBar searchInfoSnackBar(String message) { return SnackBar( content: Text(message, style: context.textTheme.labelLarge), @@ -369,6 +377,35 @@ class DriftSearchPage extends HookConsumerWidget { ); } + // STAR RATING PICKER + showStarRatingPicker() { + handleOnSelected(SearchRatingFilter rating) { + filter.value = filter.value.copyWith(rating: rating); + + ratingCurrentFilterWidget.value = Text( + 'rating_count'.t(args: {'count': rating.rating!}), + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith(rating: SearchRatingFilter(rating: null)); + ratingCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + child: FilterBottomSheetScaffold( + title: 'rating'.t(context: context), + onSearch: search, + onClear: handleClear, + child: StarRatingPicker(onSelect: handleOnSelected, filter: filter.value.rating), + ), + ); + } + // DISPLAY OPTION showDisplayOptionPicker() { handleOnSelect(Map value) { @@ -629,6 +666,14 @@ class DriftSearchPage extends HookConsumerWidget { label: 'search_filter_media_type'.t(context: context), currentFilter: mediaTypeCurrentFilterWidget.value, ), + if (isRatingEnabled) ...[ + SearchFilterChip( + icon: Icons.star_outline_rounded, + onTap: showStarRatingPicker, + label: 'search_filter_star_rating'.t(context: context), + currentFilter: ratingCurrentFilterWidget.value, + ), + ], SearchFilterChip( icon: Icons.display_settings_outlined, onTap: showDisplayOptionPicker, 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 65ba744ec3..294ddfd1f5 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 @@ -34,6 +34,7 @@ class SimilarPhotosActionButton extends ConsumerWidget { camera: SearchCameraFilter(), date: SearchDateFilter(), display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), + rating: SearchRatingFilter(), mediaType: AssetType.image, ), ); diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 771d518bba..2b9196389e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -16,11 +16,13 @@ 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/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -204,6 +206,9 @@ class _AssetDetailBottomSheet extends ConsumerWidget { final cameraTitle = _getCameraInfoTitle(exifInfo); final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null; final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null); + final isRatingEnabled = ref + .watch(userMetadataPreferencesProvider) + .maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false); // Build file info tile based on asset type Widget buildFileInfoTile() { @@ -283,6 +288,38 @@ class _AssetDetailBottomSheet extends ConsumerWidget { subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), ), ], + // Rating bar + if (isRatingEnabled) ...[ + Padding( + padding: const EdgeInsets.only(left: 16.0, top: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + 'rating'.t(context: context).toUpperCase(), + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(200), + fontWeight: FontWeight.w600, + ), + ), + RatingBar( + initialRating: exifInfo?.rating?.toDouble() ?? 0, + filledColor: context.themeData.colorScheme.primary, + unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100), + itemSize: 40, + onRatingUpdate: (rating) async { + await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round()); + }, + onClearRating: () async { + await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0); + }, + ), + ], + ), + ), + ], // Appears in (Albums) Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)), // padding at the bottom to avoid cut-off diff --git a/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart new file mode 100644 index 0000000000..64090dc5c2 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/rating_bar.widget.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; + +class RatingBar extends StatefulWidget { + final double initialRating; + final int itemCount; + final double itemSize; + final Color filledColor; + final Color unfilledColor; + final ValueChanged? onRatingUpdate; + final VoidCallback? onClearRating; + final Widget? itemBuilder; + final double starPadding; + + const RatingBar({ + super.key, + this.initialRating = 0.0, + this.itemCount = 5, + this.itemSize = 40.0, + this.filledColor = Colors.amber, + this.unfilledColor = Colors.grey, + this.onRatingUpdate, + this.onClearRating, + this.itemBuilder, + this.starPadding = 4.0, + }); + + @override + State createState() => _RatingBarState(); +} + +class _RatingBarState extends State { + late double _currentRating; + + @override + void initState() { + super.initState(); + _currentRating = widget.initialRating; + } + + void _updateRating(Offset localPosition, bool isRTL, {bool isTap = false}) { + final totalWidth = widget.itemCount * widget.itemSize + (widget.itemCount - 1) * widget.starPadding; + double dx = localPosition.dx; + + if (isRTL) dx = totalWidth - dx; + + double newRating; + + if (dx <= 0) { + newRating = 0; + } else if (dx >= totalWidth) { + newRating = widget.itemCount.toDouble(); + } else { + double starWithPadding = widget.itemSize + widget.starPadding; + int tappedIndex = (dx / starWithPadding).floor().clamp(0, widget.itemCount - 1); + newRating = tappedIndex + 1.0; + + if (isTap && newRating == _currentRating && _currentRating != 0) { + newRating = 0; + } + } + + if (_currentRating != newRating) { + setState(() { + _currentRating = newRating; + }); + widget.onRatingUpdate?.call(newRating.round()); + } + } + + @override + Widget build(BuildContext context) { + final isRTL = Directionality.of(context) == TextDirection.rtl; + final double visualAlignmentOffset = 5.0; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Transform.translate( + offset: Offset(isRTL ? visualAlignmentOffset : -visualAlignmentOffset, 0), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (details) => _updateRating(details.localPosition, isRTL, isTap: true), + onPanUpdate: (details) => _updateRating(details.localPosition, isRTL, isTap: false), + child: Row( + mainAxisSize: MainAxisSize.min, + textDirection: isRTL ? TextDirection.rtl : TextDirection.ltr, + children: List.generate(widget.itemCount * 2 - 1, (i) { + if (i.isOdd) { + return SizedBox(width: widget.starPadding); + } + int index = i ~/ 2; + bool filled = _currentRating > index; + return widget.itemBuilder ?? + Icon( + Icons.star_rounded, + size: widget.itemSize, + color: filled ? widget.filledColor : widget.unfilledColor, + ); + }), + ), + ), + ), + if (_currentRating > 0) + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: GestureDetector( + onTap: () { + setState(() { + _currentRating = 0; + }); + widget.onClearRating?.call(); + }, + child: Text( + 'rating_clear'.t(context: context), + style: TextStyle(color: context.themeData.colorScheme.primary), + ), + ), + ), + ], + ); + } +} diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 48ce88799a..924e9c558a 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -359,6 +359,22 @@ class ActionNotifier extends Notifier { } } + Future updateRating(ActionSource source, int rating) async { + final ids = _getRemoteIdsForSource(source); + if (ids.length != 1) { + _logger.warning('updateRating called with multiple assets, expected single asset'); + return ActionResult(count: ids.length, success: false, error: 'Expected single asset for rating update'); + } + + try { + final isUpdated = await _service.updateRating(ids.first, rating); + return ActionResult(count: 1, success: isUpdated); + } catch (error, stack) { + _logger.severe('Failed to update rating for asset', error, stack); + return ActionResult(count: 1, success: false, error: error.toString()); + } + } + Future stack(String userId, ActionSource source) async { final ids = _getOwnedRemoteIdsForSource(source); try { diff --git a/mobile/lib/providers/infrastructure/user_metadata.provider.dart b/mobile/lib/providers/infrastructure/user_metadata.provider.dart index 2e2ae7555b..9a463463f5 100644 --- a/mobile/lib/providers/infrastructure/user_metadata.provider.dart +++ b/mobile/lib/providers/infrastructure/user_metadata.provider.dart @@ -1,7 +1,22 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/user_metadata.model.dart'; import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; final userMetadataRepository = Provider( (ref) => DriftUserMetadataRepository(ref.watch(driftProvider)), ); + +final userMetadataProvider = FutureProvider>((ref) async { + final repository = ref.watch(userMetadataRepository); + final user = ref.watch(currentUserProvider); + if (user == null) return []; + return repository.getUserMetadata(user.id); +}); + +final userMetadataPreferencesProvider = FutureProvider((ref) async { + final metadataList = await ref.watch(userMetadataProvider.future); + final metadataWithPrefs = metadataList.firstWhere((meta) => meta.preferences != null); + return metadataWithPrefs.preferences; +}); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 07639fbb3a..4d2473e64e 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -101,6 +101,10 @@ class AssetApiRepository extends ApiRepository { Future updateDescription(String assetId, String description) { return _api.updateAsset(assetId, UpdateAssetDto(description: description)); } + + Future updateRating(String assetId, int rating) { + return _api.updateAsset(assetId, UpdateAssetDto(rating: rating)); + } } extension on StackResponseDto { diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 4d6e9611d6..13e491f321 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -214,6 +214,14 @@ class ActionService { return true; } + Future updateRating(String assetId, int rating) async { + // update remote first, then local to ensure consistency + await _assetApiRepository.updateRating(assetId, rating); + await _remoteAssetRepository.updateRating(assetId, rating); + + return true; + } + Future stack(String userId, List remoteIds) async { final stack = await _assetApiRepository.stack(remoteIds); await _remoteAssetRepository.stack(userId, stack); diff --git a/mobile/lib/widgets/search/explore_grid.dart b/mobile/lib/widgets/search/explore_grid.dart index a6e1cf5aac..6af20df029 100644 --- a/mobile/lib/widgets/search/explore_grid.dart +++ b/mobile/lib/widgets/search/explore_grid.dart @@ -55,6 +55,7 @@ class ExploreGrid extends StatelessWidget { camera: SearchCameraFilter(), date: SearchDateFilter(), display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false), + rating: SearchRatingFilter(), mediaType: AssetType.other, ), ), diff --git a/mobile/lib/widgets/search/search_filter/star_rating_picker.dart b/mobile/lib/widgets/search/search_filter/star_rating_picker.dart new file mode 100644 index 0000000000..5591b0e264 --- /dev/null +++ b/mobile/lib/widgets/search/search_filter/star_rating_picker.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/models/search/search_filter.model.dart'; + +class StarRatingPicker extends HookWidget { + const StarRatingPicker({super.key, required this.onSelect, this.filter}); + final Function(SearchRatingFilter) onSelect; + final SearchRatingFilter? filter; + + @override + Widget build(BuildContext context) { + final selectedRating = useState(filter); + + return RadioGroup( + groupValue: selectedRating.value?.rating, + onChanged: (int? newValue) { + if (newValue == null) return; + final newFilter = SearchRatingFilter(rating: newValue); + selectedRating.value = newFilter; + onSelect(newFilter); + }, + child: Column( + children: List.generate( + 6, + (index) => RadioListTile( + key: Key("star_$index"), + title: Text('rating_count'.t(args: {'count': (index)})), + value: index, + ), + ), + ), + ); + } +} From 2792d97027451c9b02933911760a7cdc4de444fa Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 23 Jan 2026 09:54:04 -0600 Subject: [PATCH 013/156] feat: more factories for small tests (#25396) --- server/src/config.ts | 4 +- server/src/services/metadata.service.spec.ts | 37 ++-- server/src/services/tag.service.spec.ts | 11 +- server/src/types.ts | 27 +-- server/test/small.factory.ts | 196 +++++++++++++++---- 5 files changed, 210 insertions(+), 65 deletions(-) diff --git a/server/src/config.ts b/server/src/config.ts index 9b5fafd605..62f7841b4a 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -15,7 +15,7 @@ import { } from 'src/enum'; import { ConcurrentQueueName, FullsizeImageOptions, ImageOptions } from 'src/types'; -export interface SystemConfig { +export type SystemConfig = { backup: { database: { enabled: boolean; @@ -187,7 +187,7 @@ export interface SystemConfig { user: { deleteDelay: number; }; -} +}; export type MachineLearningConfig = SystemConfig['machineLearning']; diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 6395e66e31..942817a213 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -387,7 +387,7 @@ describe(MetadataService.name, () => { it('should extract tags from TagsList', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any); + mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) }); mockReadTags({ TagsList: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -398,7 +398,7 @@ describe(MetadataService.name, () => { it('should extract hierarchy from TagsList', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child'] } } as any); + mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) }); mockReadTags({ TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); @@ -419,7 +419,7 @@ describe(MetadataService.name, () => { it('should extract tags from Keywords as a string', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any); + mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) }); mockReadTags({ Keywords: 'Parent' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -430,7 +430,7 @@ describe(MetadataService.name, () => { it('should extract tags from Keywords as a list', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any); + mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) }); mockReadTags({ Keywords: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -441,7 +441,10 @@ describe(MetadataService.name, () => { it('should extract tags from Keywords as a list with a number', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent', '2024'] } } as any); + mocks.asset.getById.mockResolvedValue({ + ...factory.asset(), + exifInfo: factory.exif({ tags: ['Parent', '2024'] }), + }); mockReadTags({ Keywords: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -453,7 +456,7 @@ describe(MetadataService.name, () => { it('should extract hierarchal tags from Keywords', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child'] } } as any); + mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) }); mockReadTags({ Keywords: 'Parent/Child' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -473,7 +476,10 @@ describe(MetadataService.name, () => { it('should ignore Keywords when TagsList is present', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'Child'] } } as any); + mocks.asset.getById.mockResolvedValue({ + ...factory.asset(), + exifInfo: factory.exif({ tags: ['Parent/Child', 'Child'] }), + }); mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -493,7 +499,10 @@ describe(MetadataService.name, () => { it('should extract hierarchy from HierarchicalSubject', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'TagA'] } } as any); + mocks.asset.getById.mockResolvedValue({ + ...factory.asset(), + exifInfo: factory.exif({ tags: ['Parent/Child', 'TagA'] }), + }); mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); @@ -515,7 +524,10 @@ describe(MetadataService.name, () => { it('should extract tags from HierarchicalSubject as a list with a number', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent', '2024'] } } as any); + mocks.asset.getById.mockResolvedValue({ + ...factory.asset(), + exifInfo: factory.exif({ tags: ['Parent', '2024'] }), + }); mockReadTags({ HierarchicalSubject: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -527,7 +539,7 @@ describe(MetadataService.name, () => { it('should extract ignore / characters in a HierarchicalSubject tag', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Mom|Dad'] } } as any); + mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Mom|Dad'] }) }); mockReadTags({ HierarchicalSubject: ['Mom/Dad'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); @@ -542,7 +554,10 @@ describe(MetadataService.name, () => { it('should ignore HierarchicalSubject when TagsList is present', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'Parent2/Child2'] } } as any); + mocks.asset.getById.mockResolvedValue({ + ...factory.asset(), + exifInfo: factory.exif({ tags: ['Parent/Child', 'Parent2/Child2'] }), + }); mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index a80e6d508b..f42f40940d 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -4,6 +4,7 @@ import { JobStatus } from 'src/enum'; import { TagService } from 'src/services/tag.service'; import { authStub } from 'test/fixtures/auth.stub'; import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub'; +import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(TagService.name, () => { @@ -191,7 +192,10 @@ describe(TagService.name, () => { it('should upsert records', async () => { mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - mocks.asset.getById.mockResolvedValue({ tags: [{ value: 'tag-1' }, { value: 'tag-2' }] } as any); + mocks.asset.getById.mockResolvedValue({ + ...factory.asset(), + tags: [factory.tag({ value: 'tag-1' }), factory.tag({ value: 'tag-2' })], + }); mocks.tag.upsertAssetIds.mockResolvedValue([ { tagId: 'tag-1', assetId: 'asset-1' }, { tagId: 'tag-1', assetId: 'asset-2' }, @@ -242,7 +246,10 @@ describe(TagService.name, () => { mocks.tag.get.mockResolvedValue(tagStub.tag); mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1'])); mocks.tag.addAssetIds.mockResolvedValue(); - mocks.asset.getById.mockResolvedValue({ tags: [{ value: 'tag-1' }] } as any); + mocks.asset.getById.mockResolvedValue({ + ...factory.asset(), + tags: [factory.tag({ value: 'tag-1' })], + }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2'])); await expect( diff --git a/server/src/types.ts b/server/src/types.ts index f3b647f08f..afcaa6509b 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -23,34 +23,39 @@ import { VideoCodec, } from 'src/enum'; -export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial } : T; +export type DeepPartial = + T extends Record + ? { [K in keyof T]?: DeepPartial } + : T extends Array + ? DeepPartial[] + : T; export type RepositoryInterface = Pick; -export interface FullsizeImageOptions { +export type FullsizeImageOptions = { format: ImageFormat; quality: number; enabled: boolean; -} +}; -export interface ImageOptions { +export type ImageOptions = { format: ImageFormat; quality: number; size: number; -} +}; -export interface RawImageInfo { +export type RawImageInfo = { width: number; height: number; channels: 1 | 2 | 3 | 4; -} +}; -interface DecodeImageOptions { +type DecodeImageOptions = { colorspace: string; processInvalidImages: boolean; raw?: RawImageInfo; edits?: AssetEditActionItem[]; -} +}; export interface DecodeToBufferOptions extends DecodeImageOptions { size?: number; @@ -504,7 +509,7 @@ export interface SystemMetadata extends Record = { key: T; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 900296d040..37c5400e58 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -1,6 +1,7 @@ import { Activity, ApiKey, + AssetFace, AssetFile, AuthApiKey, AuthSharedLink, @@ -9,12 +10,16 @@ import { Library, Memory, Partner, + Person, Session, + Stack, + Tag, User, UserAdmin, } from 'src/database'; import { MapAsset } from 'src/dtos/asset-response.dto'; 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, @@ -23,10 +28,11 @@ import { AssetVisibility, MemoryType, Permission, + SourceType, UserMetadataKey, UserStatus, } from 'src/enum'; -import { OnThisDayData, UserMetadataItem } from 'src/types'; +import { DeepPartial, OnThisDayData, UserMetadataItem } from 'src/types'; import { v4, v7 } from 'uuid'; export const newUuid = () => v4(); @@ -160,11 +166,18 @@ const queueStatisticsFactory = (dto?: Partial) => ({ ...dto, }); -const stackFactory = () => ({ - id: newUuid(), - ownerId: newUuid(), - primaryAssetId: newUuid(), -}); +const stackFactory = ({ owner, assets, ...stack }: DeepPartial = {}): Stack => { + const ownerId = newUuid(); + + return { + id: newUuid(), + primaryAssetId: assets?.[0].id ?? newUuid(), + ownerId, + owner: userFactory(owner ?? { id: ownerId }), + assets: assets?.map((asset) => assetFactory(asset)) ?? [], + ...stack, + }; +}; const userFactory = (user: Partial = {}) => ({ id: newUuid(), @@ -223,39 +236,43 @@ const userAdminFactory = (user: Partial = {}) => { }; }; -const assetFactory = (asset: Partial = {}) => ({ - id: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - deletedAt: null, - updateId: newUuidV7(), - status: AssetStatus.Active, - checksum: newSha1(), - deviceAssetId: '', - deviceId: '', - duplicateId: null, - duration: null, - encodedVideoPath: null, - fileCreatedAt: newDate(), - fileModifiedAt: newDate(), - isExternal: false, - isFavorite: false, - isOffline: false, - libraryId: null, - livePhotoVideoId: null, - localDateTime: newDate(), - originalFileName: 'IMG_123.jpg', - originalPath: `/data/12/34/IMG_123.jpg`, - ownerId: newUuid(), - stackId: null, - thumbhash: null, - type: AssetType.Image, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - isEdited: false, - ...asset, -}); +const assetFactory = ( + asset: Omit, 'exifInfo' | 'owner' | 'stack' | 'tags' | 'faces' | 'files' | 'edits'> = {}, +) => { + return { + id: newUuid(), + createdAt: newDate(), + updatedAt: newDate(), + deletedAt: null, + updateId: newUuidV7(), + status: AssetStatus.Active, + checksum: newSha1(), + deviceAssetId: '', + deviceId: '', + duplicateId: null, + duration: null, + encodedVideoPath: null, + fileCreatedAt: newDate(), + fileModifiedAt: newDate(), + isExternal: false, + isFavorite: false, + isOffline: false, + libraryId: null, + livePhotoVideoId: null, + localDateTime: newDate(), + originalFileName: 'IMG_123.jpg', + originalPath: `/data/12/34/IMG_123.jpg`, + ownerId: newUuid(), + stackId: null, + thumbhash: null, + type: AssetType.Image, + visibility: AssetVisibility.Timeline, + width: null, + height: null, + isEdited: false, + ...asset, + }; +}; const activityFactory = (activity: Partial = {}) => { const userId = activity.userId || newUuid(); @@ -391,6 +408,102 @@ const assetFileFactory = (file: Partial = {}): AssetFile => ({ ...file, }); +const exifFactory = (exif: Partial = {}) => ({ + assetId: newUuid(), + autoStackId: null, + bitsPerSample: null, + city: 'Austin', + colorspace: null, + country: 'United States of America', + dateTimeOriginal: newDate(), + description: '', + exifImageHeight: 420, + exifImageWidth: 42, + exposureTime: null, + fileSizeInByte: 69, + fNumber: 1.7, + focalLength: 4.38, + fps: null, + iso: 947, + latitude: 30.267_334_570_570_195, + longitude: -97.789_833_534_282_07, + lensModel: null, + livePhotoCID: null, + make: 'Google', + model: 'Pixel 7', + modifyDate: newDate(), + orientation: '1', + profileDescription: null, + projectionType: null, + rating: 4, + state: 'Texas', + tags: ['parent/child'], + timeZone: 'UTC-6', + ...exif, +}); + +const tagFactory = (tag: Partial): Tag => ({ + id: newUuid(), + color: null, + createdAt: newDate(), + parentId: null, + updatedAt: newDate(), + value: `tag-${newUuid()}`, + ...tag, +}); + +const faceFactory = ({ person, ...face }: DeepPartial = {}): AssetFace => ({ + assetId: newUuid(), + boundingBoxX1: 1, + boundingBoxX2: 2, + boundingBoxY1: 1, + boundingBoxY2: 2, + deletedAt: null, + id: newUuid(), + imageHeight: 420, + imageWidth: 42, + isVisible: true, + personId: null, + sourceType: SourceType.MachineLearning, + updatedAt: newDate(), + updateId: newUuidV7(), + person: person === null ? null : personFactory(person), + ...face, +}); + +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, +}); + export const factory = { activity: activityFactory, apiKey: apiKeyFactory, @@ -412,6 +525,11 @@ export const factory = { jobAssets: { sidecarWrite: assetSidecarWriteFactory, }, + exif: exifFactory, + face: faceFactory, + person: personFactory, + assetEdit: assetEditFactory, + tag: tagFactory, uuid: newUuid, date: newDate, responses: { From d942e7212aeb2b99770c110b46f1ed1f1a287e8f Mon Sep 17 00:00:00 2001 From: Anukul Date: Fri, 23 Jan 2026 21:54:19 +0545 Subject: [PATCH 014/156] fix(web): fix badge value in queues page (#25445) Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --- web/src/routes/admin/queues/[name]/+page.svelte | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/web/src/routes/admin/queues/[name]/+page.svelte b/web/src/routes/admin/queues/[name]/+page.svelte index 7ecc935250..482076d146 100644 --- a/web/src/routes/admin/queues/[name]/+page.svelte +++ b/web/src/routes/admin/queues/[name]/+page.svelte @@ -1,11 +1,9 @@ - - Date: Fri, 23 Jan 2026 14:06:19 -0500 Subject: [PATCH 015/156] refactor: asset navbar (#25476) --- .../asset-viewer/actions/edit-action.svelte | 20 ----- .../asset-viewer/asset-viewer-nav-bar.svelte | 74 +++++++++---------- .../asset-viewer/asset-viewer.svelte | 25 ++----- .../managers/asset-viewer-manager.svelte.ts | 10 +++ web/src/lib/services/asset.service.ts | 18 +++++ 5 files changed, 70 insertions(+), 77 deletions(-) delete mode 100644 web/src/lib/components/asset-viewer/actions/edit-action.svelte diff --git a/web/src/lib/components/asset-viewer/actions/edit-action.svelte b/web/src/lib/components/asset-viewer/actions/edit-action.svelte deleted file mode 100644 index 2a630f1697..0000000000 --- a/web/src/lib/components/asset-viewer/actions/edit-action.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - onAction()} -/> 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 93d1a4acd1..bd20d6efeb 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 @@ -7,7 +7,6 @@ import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte'; import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte'; import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte'; - import EditAction from '$lib/components/asset-viewer/actions/edit-action.svelte'; import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte'; import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte'; import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte'; @@ -20,7 +19,6 @@ import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; - import { ProjectionType } from '$lib/constants'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { Route } from '$lib/route'; import { getGlobalActions } from '$lib/services/app.service'; @@ -67,7 +65,6 @@ onAction: OnAction; onUndoDelete?: OnUndoDelete; onPlaySlideshow: () => void; - onEdit: () => void; onClose?: () => void; playOriginalVideo: boolean; setPlayOriginalVideo: (value: boolean) => void; @@ -86,26 +83,42 @@ onUndoDelete = undefined, onPlaySlideshow, onClose, - onEdit, playOriginalVideo = false, setPlayOriginalVideo, }: Props = $props(); - let isOwner = $derived($user && asset.ownerId === $user?.id); - let isLocked = $derived(asset.visibility === AssetVisibility.Locked); - let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch); - - const Close: ActionItem = { - title: $t('go_back'), - type: $t('assets'), - icon: mdiArrowLeft, - $if: () => !!onClose, - onAction: () => onClose?.(), - shortcuts: [{ key: 'Escape' }], - }; + const isOwner = $derived($user && asset.ownerId === $user?.id); + const isLocked = $derived(asset.visibility === AssetVisibility.Locked); + const isImage = $derived(asset.type === AssetTypeEnum.Image); + const smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch); const { Cast } = $derived(getGlobalActions($t)); + const { Close, ZoomIn, ZoomOut } = $derived({ + Close: { + title: $t('go_back'), + type: $t('assets'), + icon: mdiArrowLeft, + $if: () => !!onClose, + onAction: () => onClose?.(), + shortcuts: [{ key: 'Escape' }], + }, + + ZoomIn: { + title: $t('zoom_image'), + icon: mdiMagnifyPlusOutline, + $if: () => isImage && $photoZoomState && $photoZoomState.currentZoom <= 1, + onAction: () => onZoomImage(), + }, + + ZoomOut: { + title: $t('zoom_image'), + icon: mdiMagnifyMinusOutline, + $if: () => $photoZoomState && $photoZoomState.currentZoom > 1, + onAction: () => onZoomImage(), + }, + } satisfies Record); + const { Share, Download, @@ -117,22 +130,13 @@ PlayMotionPhoto, StopMotionPhoto, Info, + Edit, RefreshFacesJob, RefreshMetadataJob, RegenerateThumbnailJob, TranscodeVideoJob, } = $derived(getAssetActions($t, asset)); const sharedLink = getSharedLink(); - - const editorDisabled = $derived( - !isOwner || - asset.type !== AssetTypeEnum.Image || - asset.livePhotoVideoId || - (asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR && - asset.originalPath.toLowerCase().endsWith('.insp')) || - asset.originalPath.toLowerCase().endsWith('.gif') || - asset.originalPath.toLowerCase().endsWith('.svg'), - ); + + - {#if asset.type === AssetTypeEnum.Image} -