diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 4e7dfb35fe..1da4463a12 100644
--- a/mobile/openapi/README.md
+++ b/mobile/openapi/README.md
@@ -243,6 +243,8 @@ Class | Method | HTTP request | Description
 *UsersAdminApi* | [**searchUsersAdmin**](doc//UsersAdminApi.md#searchusersadmin) | **GET** /admin/users | 
 *UsersAdminApi* | [**updateUserAdmin**](doc//UsersAdminApi.md#updateuseradmin) | **PUT** /admin/users/{id} | 
 *UsersAdminApi* | [**updateUserPreferencesAdmin**](doc//UsersAdminApi.md#updateuserpreferencesadmin) | **PUT** /admin/users/{id}/preferences | 
+*ViewApi* | [**getAssetsByOriginalPath**](doc//ViewApi.md#getassetsbyoriginalpath) | **GET** /view/folder | 
+*ViewApi* | [**getUniqueOriginalPaths**](doc//ViewApi.md#getuniqueoriginalpaths) | **GET** /view/folder/unique-paths | 
 
 
 ## Documentation For Models
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 1179bff56d..05a43c8af7 100644
--- a/mobile/openapi/lib/api.dart
+++ b/mobile/openapi/lib/api.dart
@@ -62,6 +62,7 @@ part 'api/timeline_api.dart';
 part 'api/trash_api.dart';
 part 'api/users_api.dart';
 part 'api/users_admin_api.dart';
+part 'api/view_api.dart';
 
 part 'model/api_key_create_dto.dart';
 part 'model/api_key_create_response_dto.dart';
diff --git a/mobile/openapi/lib/api/view_api.dart b/mobile/openapi/lib/api/view_api.dart
new file mode 100644
index 0000000000..f4489f2d1a
--- /dev/null
+++ b/mobile/openapi/lib/api/view_api.dart
@@ -0,0 +1,114 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+
+class ViewApi {
+  ViewApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
+
+  final ApiClient apiClient;
+
+  /// Performs an HTTP 'GET /view/folder' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] path (required):
+  Future<Response> getAssetsByOriginalPathWithHttpInfo(String path,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/view/folder';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+      queryParams.addAll(_queryParams('', 'path', path));
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] path (required):
+  Future<List<AssetResponseDto>?> getAssetsByOriginalPath(String path,) async {
+    final response = await getAssetsByOriginalPathWithHttpInfo(path,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      final responseBody = await _decodeBodyBytes(response);
+      return (await apiClient.deserializeAsync(responseBody, 'List<AssetResponseDto>') as List)
+        .cast<AssetResponseDto>()
+        .toList(growable: false);
+
+    }
+    return null;
+  }
+
+  /// Performs an HTTP 'GET /view/folder/unique-paths' operation and returns the [Response].
+  Future<Response> getUniqueOriginalPathsWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/view/folder/unique-paths';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<List<String>?> getUniqueOriginalPaths() async {
+    final response = await getUniqueOriginalPathsWithHttpInfo();
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      final responseBody = await _decodeBodyBytes(response);
+      return (await apiClient.deserializeAsync(responseBody, 'List<String>') as List)
+        .cast<String>()
+        .toList(growable: false);
+
+    }
+    return null;
+  }
+}
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index 35fecdb1ee..02a887370a 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -7250,6 +7250,85 @@
           "Users"
         ]
       }
+    },
+    "/view/folder": {
+      "get": {
+        "operationId": "getAssetsByOriginalPath",
+        "parameters": [
+          {
+            "name": "path",
+            "required": true,
+            "in": "query",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "items": {
+                    "$ref": "#/components/schemas/AssetResponseDto"
+                  },
+                  "type": "array"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "View"
+        ]
+      }
+    },
+    "/view/folder/unique-paths": {
+      "get": {
+        "operationId": "getUniqueOriginalPaths",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "items": {
+                    "type": "string"
+                  },
+                  "type": "array"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "View"
+        ]
+      }
     }
   },
   "info": {
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index 80394ad901..9642f4c817 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -3114,6 +3114,26 @@ export function getProfileImage({ id }: {
         ...opts
     }));
 }
+export function getAssetsByOriginalPath({ path }: {
+    path: string;
+}, opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchJson<{
+        status: 200;
+        data: AssetResponseDto[];
+    }>(`/view/folder${QS.query(QS.explode({
+        path
+    }))}`, {
+        ...opts
+    }));
+}
+export function getUniqueOriginalPaths(opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchJson<{
+        status: 200;
+        data: string[];
+    }>("/view/folder/unique-paths", {
+        ...opts
+    }));
+}
 export enum ReactionLevel {
     Album = "album",
     Asset = "asset"
diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts
index 3a832c1a1b..ab569d7434 100644
--- a/server/src/controllers/index.ts
+++ b/server/src/controllers/index.ts
@@ -32,6 +32,7 @@ import { TimelineController } from 'src/controllers/timeline.controller';
 import { TrashController } from 'src/controllers/trash.controller';
 import { UserAdminController } from 'src/controllers/user-admin.controller';
 import { UserController } from 'src/controllers/user.controller';
+import { ViewController } from 'src/controllers/view.controller';
 
 export const controllers = [
   APIKeyController,
@@ -68,4 +69,5 @@ export const controllers = [
   TrashController,
   UserAdminController,
   UserController,
+  ViewController,
 ];
diff --git a/server/src/controllers/view.controller.ts b/server/src/controllers/view.controller.ts
new file mode 100644
index 0000000000..b5e281e093
--- /dev/null
+++ b/server/src/controllers/view.controller.ts
@@ -0,0 +1,24 @@
+import { Controller, Get, Query } from '@nestjs/common';
+import { ApiTags } from '@nestjs/swagger';
+import { AssetResponseDto } from 'src/dtos/asset-response.dto';
+import { AuthDto } from 'src/dtos/auth.dto';
+import { Auth, Authenticated } from 'src/middleware/auth.guard';
+import { ViewService } from 'src/services/view.service';
+
+@ApiTags('View')
+@Controller('view')
+export class ViewController {
+  constructor(private service: ViewService) {}
+
+  @Get('folder/unique-paths')
+  @Authenticated()
+  getUniqueOriginalPaths(@Auth() auth: AuthDto): Promise<string[]> {
+    return this.service.getUniqueOriginalPaths(auth);
+  }
+
+  @Get('folder')
+  @Authenticated()
+  getAssetsByOriginalPath(@Auth() auth: AuthDto, @Query('path') path: string): Promise<AssetResponseDto[]> {
+    return this.service.getAssetsByOriginalPath(auth, path);
+  }
+}
diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts
index 05ea84a5ae..666c6d3f7e 100644
--- a/server/src/interfaces/asset.interface.ts
+++ b/server/src/interfaces/asset.interface.ts
@@ -145,6 +145,8 @@ export type AssetPathEntity = Pick<AssetEntity, 'id' | 'originalPath' | 'isOffli
 export const IAssetRepository = 'IAssetRepository';
 
 export interface IAssetRepository {
+  getAssetsByOriginalPath(userId: string, partialPath: string): Promise<AssetEntity[]>;
+  getUniqueOriginalPaths(userId: string): Promise<string[]>;
   create(asset: AssetCreate): Promise<AssetEntity>;
   getByIds(
     ids: string[],
diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql
index c9bd8083bb..fd5dc15c0a 100644
--- a/server/src/queries/asset.repository.sql
+++ b/server/src/queries/asset.repository.sql
@@ -1168,6 +1168,116 @@ WHERE
   AND "asset"."ownerId" IN ($1)
   AND "asset"."updatedAt" > $2
 
+-- AssetRepository.getAssetsByOriginalPath
+SELECT
+  "asset"."id" AS "asset_id",
+  "asset"."deviceAssetId" AS "asset_deviceAssetId",
+  "asset"."ownerId" AS "asset_ownerId",
+  "asset"."libraryId" AS "asset_libraryId",
+  "asset"."deviceId" AS "asset_deviceId",
+  "asset"."type" AS "asset_type",
+  "asset"."originalPath" AS "asset_originalPath",
+  "asset"."thumbhash" AS "asset_thumbhash",
+  "asset"."encodedVideoPath" AS "asset_encodedVideoPath",
+  "asset"."createdAt" AS "asset_createdAt",
+  "asset"."updatedAt" AS "asset_updatedAt",
+  "asset"."deletedAt" AS "asset_deletedAt",
+  "asset"."fileCreatedAt" AS "asset_fileCreatedAt",
+  "asset"."localDateTime" AS "asset_localDateTime",
+  "asset"."fileModifiedAt" AS "asset_fileModifiedAt",
+  "asset"."isFavorite" AS "asset_isFavorite",
+  "asset"."isArchived" AS "asset_isArchived",
+  "asset"."isExternal" AS "asset_isExternal",
+  "asset"."isOffline" AS "asset_isOffline",
+  "asset"."checksum" AS "asset_checksum",
+  "asset"."duration" AS "asset_duration",
+  "asset"."isVisible" AS "asset_isVisible",
+  "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId",
+  "asset"."originalFileName" AS "asset_originalFileName",
+  "asset"."sidecarPath" AS "asset_sidecarPath",
+  "asset"."stackId" AS "asset_stackId",
+  "asset"."duplicateId" AS "asset_duplicateId",
+  "files"."id" AS "files_id",
+  "files"."assetId" AS "files_assetId",
+  "files"."createdAt" AS "files_createdAt",
+  "files"."updatedAt" AS "files_updatedAt",
+  "files"."type" AS "files_type",
+  "files"."path" AS "files_path",
+  "exifInfo"."assetId" AS "exifInfo_assetId",
+  "exifInfo"."description" AS "exifInfo_description",
+  "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth",
+  "exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight",
+  "exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte",
+  "exifInfo"."orientation" AS "exifInfo_orientation",
+  "exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal",
+  "exifInfo"."modifyDate" AS "exifInfo_modifyDate",
+  "exifInfo"."timeZone" AS "exifInfo_timeZone",
+  "exifInfo"."latitude" AS "exifInfo_latitude",
+  "exifInfo"."longitude" AS "exifInfo_longitude",
+  "exifInfo"."projectionType" AS "exifInfo_projectionType",
+  "exifInfo"."city" AS "exifInfo_city",
+  "exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID",
+  "exifInfo"."autoStackId" AS "exifInfo_autoStackId",
+  "exifInfo"."state" AS "exifInfo_state",
+  "exifInfo"."country" AS "exifInfo_country",
+  "exifInfo"."make" AS "exifInfo_make",
+  "exifInfo"."model" AS "exifInfo_model",
+  "exifInfo"."lensModel" AS "exifInfo_lensModel",
+  "exifInfo"."fNumber" AS "exifInfo_fNumber",
+  "exifInfo"."focalLength" AS "exifInfo_focalLength",
+  "exifInfo"."iso" AS "exifInfo_iso",
+  "exifInfo"."exposureTime" AS "exifInfo_exposureTime",
+  "exifInfo"."profileDescription" AS "exifInfo_profileDescription",
+  "exifInfo"."colorspace" AS "exifInfo_colorspace",
+  "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
+  "exifInfo"."rating" AS "exifInfo_rating",
+  "exifInfo"."fps" AS "exifInfo_fps",
+  "stack"."id" AS "stack_id",
+  "stack"."ownerId" AS "stack_ownerId",
+  "stack"."primaryAssetId" AS "stack_primaryAssetId",
+  "stackedAssets"."id" AS "stackedAssets_id",
+  "stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId",
+  "stackedAssets"."ownerId" AS "stackedAssets_ownerId",
+  "stackedAssets"."libraryId" AS "stackedAssets_libraryId",
+  "stackedAssets"."deviceId" AS "stackedAssets_deviceId",
+  "stackedAssets"."type" AS "stackedAssets_type",
+  "stackedAssets"."originalPath" AS "stackedAssets_originalPath",
+  "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
+  "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
+  "stackedAssets"."createdAt" AS "stackedAssets_createdAt",
+  "stackedAssets"."updatedAt" AS "stackedAssets_updatedAt",
+  "stackedAssets"."deletedAt" AS "stackedAssets_deletedAt",
+  "stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt",
+  "stackedAssets"."localDateTime" AS "stackedAssets_localDateTime",
+  "stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt",
+  "stackedAssets"."isFavorite" AS "stackedAssets_isFavorite",
+  "stackedAssets"."isArchived" AS "stackedAssets_isArchived",
+  "stackedAssets"."isExternal" AS "stackedAssets_isExternal",
+  "stackedAssets"."isOffline" AS "stackedAssets_isOffline",
+  "stackedAssets"."checksum" AS "stackedAssets_checksum",
+  "stackedAssets"."duration" AS "stackedAssets_duration",
+  "stackedAssets"."isVisible" AS "stackedAssets_isVisible",
+  "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId",
+  "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName",
+  "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath",
+  "stackedAssets"."stackId" AS "stackedAssets_stackId",
+  "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId"
+FROM
+  "assets" "asset"
+  LEFT JOIN "asset_files" "files" ON "files"."assetId" = "asset"."id"
+  LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
+  LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId"
+  LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id"
+  AND ("stackedAssets"."deletedAt" IS NULL)
+WHERE
+  "asset"."ownerId" = $1
+  AND (
+    "asset"."originalPath" LIKE $2
+    AND "asset"."originalPath" NOT LIKE $3
+  )
+ORDER BY
+  regexp_replace("asset"."originalPath", '.*/(.+)', '\1') ASC
+
 -- AssetRepository.upsertFile
 INSERT INTO
   "asset_files" (
diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts
index 8e97d54817..50ed724f9f 100644
--- a/server/src/repositories/asset.repository.ts
+++ b/server/src/repositories/asset.repository.ts
@@ -821,6 +821,50 @@ export class AssetRepository implements IAssetRepository {
     return builder.getMany();
   }
 
+  async getUniqueOriginalPaths(userId: string): Promise<string[]> {
+    const builder = this.getBuilder({
+      userIds: [userId],
+      exifInfo: false,
+      withStacked: false,
+      isArchived: false,
+      isTrashed: false,
+    });
+
+    const results = await builder
+      .select("DISTINCT substring(asset.originalPath FROM '^(.*/)[^/]*$')", 'directoryPath')
+      .getRawMany();
+
+    return results.map((row: { directoryPath: string }) => row.directoryPath.replaceAll(/^\/|\/$/g, ''));
+  }
+
+  @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
+  async getAssetsByOriginalPath(userId: string, partialPath: string): Promise<AssetEntity[]> {
+    const normalizedPath = partialPath.replaceAll(/^\/|\/$/g, '');
+
+    const builder = this.getBuilder({
+      userIds: [userId],
+      exifInfo: true,
+      withStacked: false,
+      isArchived: false,
+      isTrashed: false,
+    });
+
+    const assets = await builder
+      .where('asset.ownerId = :userId', { userId })
+      .andWhere(
+        new Brackets((qb) => {
+          qb.where('asset.originalPath LIKE :likePath', { likePath: `%${normalizedPath}/%` }).andWhere(
+            'asset.originalPath NOT LIKE :notLikePath',
+            { notLikePath: `%${normalizedPath}/%/%` },
+          );
+        }),
+      )
+      .orderBy(String.raw`regexp_replace(asset.originalPath, '.*/(.+)', '\1')`, 'ASC')
+      .getMany();
+
+    return assets;
+  }
+
   @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] })
   async upsertFile({ assetId, type, path }: { assetId: string; type: AssetFileType; path: string }): Promise<void> {
     await this.fileRepository.upsert({ assetId, type, path }, { conflictPaths: ['assetId', 'type'] });
diff --git a/server/src/services/index.ts b/server/src/services/index.ts
index 5a2e53927a..2cfbdb40c2 100644
--- a/server/src/services/index.ts
+++ b/server/src/services/index.ts
@@ -37,6 +37,7 @@ import { TrashService } from 'src/services/trash.service';
 import { UserAdminService } from 'src/services/user-admin.service';
 import { UserService } from 'src/services/user.service';
 import { VersionService } from 'src/services/version.service';
+import { ViewService } from 'src/services/view.service';
 
 export const services = [
   APIKeyService,
@@ -78,4 +79,5 @@ export const services = [
   UserAdminService,
   UserService,
   VersionService,
+  ViewService,
 ];
diff --git a/server/src/services/view.service.spec.ts b/server/src/services/view.service.spec.ts
new file mode 100644
index 0000000000..3f9aa9f2f5
--- /dev/null
+++ b/server/src/services/view.service.spec.ts
@@ -0,0 +1,55 @@
+import { mapAsset } from 'src/dtos/asset-response.dto';
+import { IAssetRepository } from 'src/interfaces/asset.interface';
+
+import { ViewService } from 'src/services/view.service';
+import { assetStub } from 'test/fixtures/asset.stub';
+import { authStub } from 'test/fixtures/auth.stub';
+import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
+
+import { Mocked } from 'vitest';
+
+describe(ViewService.name, () => {
+  let sut: ViewService;
+  let assetMock: Mocked<IAssetRepository>;
+
+  beforeEach(() => {
+    assetMock = newAssetRepositoryMock();
+
+    sut = new ViewService(assetMock);
+  });
+
+  it('should work', () => {
+    expect(sut).toBeDefined();
+  });
+
+  describe('getUniqueOriginalPaths', () => {
+    it('should return unique original paths', async () => {
+      const mockPaths = ['path1', 'path2', 'path3'];
+      assetMock.getUniqueOriginalPaths.mockResolvedValue(mockPaths);
+
+      const result = await sut.getUniqueOriginalPaths(authStub.admin);
+
+      expect(result).toEqual(mockPaths);
+      expect(assetMock.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id);
+    });
+  });
+
+  describe('getAssetsByOriginalPath', () => {
+    it('should return assets by original path', async () => {
+      const path = '/asset';
+
+      const asset1 = { ...assetStub.image, originalPath: '/asset/path1' };
+      const asset2 = { ...assetStub.image, originalPath: '/asset/path2' };
+
+      const mockAssets = [asset1, asset2];
+
+      const mockAssetReponseDto = mockAssets.map((a) => mapAsset(a, { auth: authStub.admin }));
+
+      assetMock.getAssetsByOriginalPath.mockResolvedValue(mockAssets);
+
+      const result = await sut.getAssetsByOriginalPath(authStub.admin, path);
+      expect(result).toEqual(mockAssetReponseDto);
+      await expect(assetMock.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets);
+    });
+  });
+});
diff --git a/server/src/services/view.service.ts b/server/src/services/view.service.ts
new file mode 100644
index 0000000000..1bf9a3408c
--- /dev/null
+++ b/server/src/services/view.service.ts
@@ -0,0 +1,18 @@
+import { Inject } from '@nestjs/common';
+import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
+import { AuthDto } from 'src/dtos/auth.dto';
+import { IAssetRepository } from 'src/interfaces/asset.interface';
+
+export class ViewService {
+  constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {}
+
+  getUniqueOriginalPaths(auth: AuthDto): Promise<string[]> {
+    return this.assetRepository.getUniqueOriginalPaths(auth.user.id);
+  }
+
+  async getAssetsByOriginalPath(auth: AuthDto, path: string): Promise<AssetResponseDto[]> {
+    const assets = await this.assetRepository.getAssetsByOriginalPath(auth.user.id, path);
+
+    return assets.map((asset) => mapAsset(asset, { auth }));
+  }
+}
diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts
index 9320639b93..69f07bf105 100644
--- a/server/test/repositories/asset.repository.mock.ts
+++ b/server/test/repositories/asset.repository.mock.ts
@@ -43,5 +43,7 @@ export const newAssetRepositoryMock = (): Mocked<IAssetRepository> => {
     getChangedDeltaSync: vitest.fn(),
     getDuplicates: vitest.fn(),
     upsertFile: vitest.fn(),
+    getAssetsByOriginalPath: vitest.fn(),
+    getUniqueOriginalPaths: vitest.fn(),
   };
 };
diff --git a/web/package-lock.json b/web/package-lock.json
index fee3148631..73682c06cb 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -79,7 +79,7 @@
         "@oazapfts/runtime": "^1.0.2"
       },
       "devDependencies": {
-        "@types/node": "^20.14.14",
+        "@types/node": "^20.14.15",
         "typescript": "^5.3.3"
       }
     },
diff --git a/web/src/lib/components/folder-tree/folder-tree.svelte b/web/src/lib/components/folder-tree/folder-tree.svelte
new file mode 100644
index 0000000000..7f8289ce74
--- /dev/null
+++ b/web/src/lib/components/folder-tree/folder-tree.svelte
@@ -0,0 +1,65 @@
+<script lang="ts">
+  import { goto } from '$app/navigation';
+  import Icon from '$lib/components/elements/icon.svelte';
+  import { mdiChevronDown, mdiChevronRight, mdiFolder, mdiFolderEye, mdiFolderOutline } from '@mdi/js';
+  import FolderBrowser from './folder-tree.svelte';
+  import { AppRoute } from '$lib/constants';
+  import type { RecursiveObject } from '$lib/utils/folder-utils';
+
+  // Exported props
+  export let folderName: string;
+  export let content: RecursiveObject;
+  export let basePath: string;
+  export let currentPath: string = '';
+
+  let isExpanded = false;
+  let currentFolderPath = `${basePath}/${folderName}`.replace(/^\//, '').replace(/\/$/, '');
+
+  $: isExpanded = currentPath.startsWith(currentFolderPath);
+  $: isOpened = currentPath === currentFolderPath;
+
+  function toggleExpand(event: MouseEvent) {
+    event.stopPropagation();
+    isExpanded = !isExpanded;
+  }
+
+  const handleNavigation = async () => {
+    const url = new URL(AppRoute.FOLDERS, window.location.href);
+    url.searchParams.set('folder', currentFolderPath);
+    await goto(url);
+  };
+</script>
+
+<button
+  class={`flex place-items-center gap-1 mt-1 px-3 py-1 hover:bg-slate-200 dark:hover:bg-slate-600 rounded-lg font-mono text-sm hover:font-semibold ${isOpened ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-immich-primary dark:text-immich-dark-primary' : 'dark:text-gray-200'}`}
+  on:click={toggleExpand}
+  on:dblclick|stopPropagation|preventDefault={handleNavigation}
+  title={folderName}
+  type="button"
+>
+  <a href={`${AppRoute.FOLDERS}?folder=${currentFolderPath}`} on:click|preventDefault={handleNavigation} class="flex">
+    <Icon
+      path={isExpanded ? mdiChevronDown : mdiChevronRight}
+      class={isExpanded ? 'text-immich-primary dark:text-immich-dark-primary' : 'text-gray-400 dark:text-gray-700'}
+      size={20}
+    />
+    <Icon
+      path={isOpened ? mdiFolderEye : isExpanded ? mdiFolder : mdiFolderOutline}
+      class={isExpanded ? 'text-immich-primary dark:text-immich-dark-primary' : 'text-gray-400 dark:text-gray-700'}
+      size={20}
+    />
+  </a>
+  <button on:click={toggleExpand} type="button">
+    <p class="text-nowrap overflow-clip">{folderName}</p>
+  </button>
+</button>
+
+{#if isExpanded}
+  <ul class="list-none ml-2">
+    {#each Object.entries(content) as [subFolderName, subContent], index (index)}
+      <li class="my-1">
+        <FolderBrowser folderName={subFolderName} content={subContent} basePath={currentFolderPath} {currentPath} />
+      </li>
+    {/each}
+  </ul>
+{/if}
diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte
index 8222007d57..495c1aae30 100644
--- a/web/src/lib/components/layouts/user-page-layout.svelte
+++ b/web/src/lib/components/layouts/user-page-layout.svelte
@@ -3,6 +3,7 @@
   import NavigationBar from '../shared-components/navigation-bar/navigation-bar.svelte';
   import SideBar from '../shared-components/side-bar/side-bar.svelte';
   import AdminSideBar from '../shared-components/side-bar/admin-side-bar.svelte';
+  import FolderSideBar from '$lib/components/shared-components/side-bar/folder-side-bar.svelte';
 
   export let hideNavbar = false;
   export let showUploadButton = false;
@@ -10,6 +11,7 @@
   export let description: string | undefined = undefined;
   export let scrollbar = true;
   export let admin = false;
+  export let isFolderView = false;
 
   $: scrollbarClass = scrollbar ? 'immich-scrollbar p-2 pb-8' : 'scrollbar-hidden';
   $: hasTitleClass = title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full';
@@ -29,6 +31,8 @@
   <slot name="sidebar">
     {#if admin}
       <AdminSideBar />
+    {:else if isFolderView}
+      <FolderSideBar />
     {:else}
       <SideBar />
     {/if}
diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte
index 337b681a22..f977d91a99 100644
--- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte
+++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte
@@ -23,6 +23,7 @@
   export let disableAssetSelect = false;
   export let showArchiveIcon = false;
   export let viewport: Viewport;
+  export let showAssetName = false;
 
   let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore;
 
@@ -121,6 +122,7 @@
         class="absolute"
         style="width: {geometry.boxes[i].width}px; height: {geometry.boxes[i].height}px; top: {geometry.boxes[i]
           .top}px; left: {geometry.boxes[i].left}px"
+        title={showAssetName ? asset.originalFileName : ''}
       >
         <Thumbnail
           {asset}
@@ -142,6 +144,13 @@
           thumbnailWidth={geometry.boxes[i].width}
           thumbnailHeight={geometry.boxes[i].height}
         />
+        {#if showAssetName}
+          <div
+            class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-gradient-to-t bg-slate-50/75 overflow-clip text-ellipsis"
+          >
+            {asset.originalFileName}
+          </div>
+        {/if}
       </div>
     {/each}
   </div>
diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
index 52825850f3..b8df9cbbbe 100644
--- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
+++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
@@ -19,6 +19,7 @@
   import AccountInfoPanel from './account-info-panel.svelte';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import { t } from 'svelte-i18n';
+  import { foldersStore } from '$lib/stores/folders.store';
 
   export let showUploadButton = true;
 
@@ -38,6 +39,7 @@
       window.location.href = redirectUri;
     }
     resetSavedUser();
+    foldersStore.clearCache();
   };
 </script>
 
diff --git a/web/src/lib/components/shared-components/side-bar/folder-browser-sidebar.svelte b/web/src/lib/components/shared-components/side-bar/folder-browser-sidebar.svelte
new file mode 100644
index 0000000000..8e744c23aa
--- /dev/null
+++ b/web/src/lib/components/shared-components/side-bar/folder-browser-sidebar.svelte
@@ -0,0 +1,32 @@
+<script lang="ts">
+  import { onMount } from 'svelte';
+  import { page } from '$app/stores';
+  import FolderTree from '$lib/components/folder-tree/folder-tree.svelte';
+  import { buildFolderTree, type RecursiveObject } from '$lib/utils/folder-utils';
+  import { foldersStore } from '$lib/stores/folders.store';
+  import { get } from 'svelte/store';
+  import { t } from 'svelte-i18n';
+
+  let folderTree: RecursiveObject = {};
+  $: currentPath = $page.url.searchParams.get('folder') || '';
+
+  onMount(async () => {
+    await foldersStore.fetchUniquePaths();
+  });
+
+  $: {
+    const { uniquePaths } = get(foldersStore);
+    if (uniquePaths && uniquePaths.length > 0) {
+      folderTree = buildFolderTree(uniquePaths);
+    }
+  }
+</script>
+
+<section id="folder-browser-sidebar">
+  <div class="text-xs pl-4 mb-4 dark:text-white">{$t('explorer').toUpperCase()}</div>
+  <div class="overflow-auto pb-10 immich-scrollbar">
+    {#each Object.entries(folderTree) as [folderName, content]}
+      <FolderTree {folderName} {content} {currentPath} basePath="" />
+    {/each}
+  </div>
+</section>
diff --git a/web/src/lib/components/shared-components/side-bar/folder-side-bar.svelte b/web/src/lib/components/shared-components/side-bar/folder-side-bar.svelte
new file mode 100644
index 0000000000..ff1cd514e6
--- /dev/null
+++ b/web/src/lib/components/shared-components/side-bar/folder-side-bar.svelte
@@ -0,0 +1,8 @@
+<script lang="ts">
+  import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte';
+  import FolderBrowserSidebar from '$lib/components/shared-components/side-bar/folder-browser-sidebar.svelte';
+</script>
+
+<SideBarSection>
+  <FolderBrowserSidebar />
+</SideBarSection>
diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte
index 05ae856919..1985160b27 100644
--- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte
+++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte
@@ -20,6 +20,7 @@
     mdiTrashCanOutline,
     mdiToolbox,
     mdiToolboxOutline,
+    mdiFolderOutline,
   } from '@mdi/js';
   import SideBarSection from './side-bar-section.svelte';
   import SideBarLink from './side-bar-link.svelte';
@@ -104,6 +105,8 @@
       </svelte:fragment>
     </SideBarLink>
 
+    <SideBarLink title={$t('folders')} routeId="/(user)/folders" icon={mdiFolderOutline} flippedLogo />
+
     <SideBarLink
       title={$t('utilities')}
       routeId="/(user)/utilities"
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts
index 57242ae66e..184e913d9e 100644
--- a/web/src/lib/constants.ts
+++ b/web/src/lib/constants.ts
@@ -45,6 +45,8 @@ export enum AppRoute {
 
   UTILITIES = '/utilities',
   DUPLICATES = '/utilities/duplicates',
+
+  FOLDERS = '/folders',
 }
 
 export enum ProjectionType {
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json
index 2b97cb6e24..91fb1aba43 100644
--- a/web/src/lib/i18n/en.json
+++ b/web/src/lib/i18n/en.json
@@ -683,6 +683,7 @@
   "expired": "Expired",
   "expires_date": "Expires {date}",
   "explore": "Explore",
+  "explorer": "Explorer",
   "export": "Export",
   "export_as_json": "Export as JSON",
   "extension": "Extension",
@@ -700,6 +701,7 @@
   "filter_people": "Filter people",
   "find_them_fast": "Find them fast by name with search",
   "fix_incorrect_match": "Fix incorrect match",
+  "folders": "Folders",
   "force_re-scan_library_files": "Force Re-scan All Library Files",
   "forward": "Forward",
   "general": "General",
diff --git a/web/src/lib/stores/folders.store.ts b/web/src/lib/stores/folders.store.ts
new file mode 100644
index 0000000000..2e491374e2
--- /dev/null
+++ b/web/src/lib/stores/folders.store.ts
@@ -0,0 +1,69 @@
+import {
+  getAssetsByOriginalPath,
+  getUniqueOriginalPaths,
+  /**
+   * TODO: Incorrect type
+   */
+  type AssetResponseDto,
+} from '@immich/sdk';
+import { get, writable } from 'svelte/store';
+
+type AssetCache = {
+  [path: string]: AssetResponseDto[];
+};
+
+type FoldersStore = {
+  uniquePaths: string[] | null;
+  assets: AssetCache;
+};
+
+function createFoldersStore() {
+  const initialState: FoldersStore = {
+    uniquePaths: null,
+    assets: {},
+  };
+
+  const { subscribe, set, update } = writable(initialState);
+
+  async function fetchUniquePaths() {
+    const state = get(foldersStore);
+
+    if (state.uniquePaths !== null) {
+      return;
+    }
+
+    const uniquePaths = await getUniqueOriginalPaths();
+    if (uniquePaths) {
+      update((state) => ({ ...state, uniquePaths }));
+    }
+  }
+
+  async function fetchAssetsByPath(path: string) {
+    const state = get(foldersStore);
+
+    if (state.assets[path]) {
+      return;
+    }
+
+    const assets = await getAssetsByOriginalPath({ path });
+    if (assets) {
+      update((state) => ({
+        ...state,
+        assets: { ...state.assets, [path]: assets },
+      }));
+    }
+  }
+
+  function clearCache() {
+    set(initialState);
+  }
+
+  return {
+    subscribe,
+    fetchUniquePaths,
+    fetchAssetsByPath,
+    clearCache,
+  };
+}
+
+export const foldersStore = createFoldersStore();
diff --git a/web/src/lib/utils/folder-utils.ts b/web/src/lib/utils/folder-utils.ts
new file mode 100644
index 0000000000..0305f89672
--- /dev/null
+++ b/web/src/lib/utils/folder-utils.ts
@@ -0,0 +1,18 @@
+export interface RecursiveObject {
+  [key: string]: RecursiveObject;
+}
+
+export function buildFolderTree(paths: string[]) {
+  const root: RecursiveObject = {};
+  for (const path of paths) {
+    const parts = path.split('/');
+    let current = root;
+    for (const part of parts) {
+      if (!current[part]) {
+        current[part] = {};
+      }
+      current = current[part];
+    }
+  }
+  return root;
+}
diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte
new file mode 100644
index 0000000000..bf914ff8f9
--- /dev/null
+++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -0,0 +1,112 @@
+<script lang="ts">
+  import { goto } from '$app/navigation';
+  import type { PageData } from './$types';
+  import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
+  import { type AssetResponseDto } from '@immich/sdk';
+  import Icon from '$lib/components/elements/icon.svelte';
+  import { mdiArrowUpLeft, mdiChevronRight, mdiFolder, mdiFolderHome } from '@mdi/js';
+  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+  import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
+  import type { Viewport } from '$lib/stores/assets.store';
+  import { AppRoute } from '$lib/constants';
+
+  export let data: PageData;
+
+  let selectedAssets: Set<AssetResponseDto> = new Set();
+  const viewport: Viewport = { width: 0, height: 0 };
+
+  $: pathSegments = data.path ? data.path.split('/') : [];
+
+  const handleNavigation = async (folderName: string) => {
+    const folderFullPath = `${data.path ? data.path + '/' : ''}${folderName}`.replaceAll(/^\/|\/$/g, '');
+    await navigateToView(folderFullPath);
+  };
+
+  const handleBackNavigation = async () => {
+    if (data.path) {
+      const parentPath = data.path.split('/').slice(0, -1).join('/');
+      await navigateToView(parentPath);
+    }
+  };
+
+  const handleBreadcrumbNavigation = async (targetPath: string) => {
+    await navigateToView(targetPath);
+  };
+
+  const navigateToView = async (folderPath: string) => {
+    const url = new URL(AppRoute.FOLDERS, window.location.href);
+    url.searchParams.set('folder', folderPath);
+    await goto(url);
+  };
+
+  const loadNextPage = () => {};
+</script>
+
+<UserPageLayout title={data.meta.title} isFolderView>
+  <section id="path-summary" class="text-immich-primary dark:text-immich-dark-primary rounded-xl flex">
+    {#if data.path}
+      <CircleIconButton icon={mdiArrowUpLeft} title="Back" on:click={handleBackNavigation} class="mr-2" padding="2" />
+    {/if}
+
+    <div
+      class="flex place-items-center gap-2 bg-gray-50 dark:bg-immich-dark-gray/50 w-full py-2 px-4 rounded-2xl border border-gray-100 dark:border-gray-900"
+    >
+      <a href={`${AppRoute.FOLDERS}`} title="To root">
+        <Icon path={mdiFolderHome} class="text-immich-primary dark:text-immich-dark-primary mr-2" size={28} />
+      </a>
+      {#each pathSegments as segment, index}
+        <button
+          class="text-sm font-mono underline hover:font-semibold"
+          on:click={() => handleBreadcrumbNavigation(pathSegments.slice(0, index + 1).join('/'))}
+          type="button"
+        >
+          {segment}
+        </button>
+        <p class="text-gray-500">
+          {#if index < pathSegments.length - 1}
+            <Icon path={mdiChevronRight} class="text-gray-500 dark:text-gray-300" size={16} />
+          {/if}
+        </p>
+      {/each}
+    </div>
+  </section>
+
+  <section id="folder-detail-view" class="mt-2">
+    <!-- Sub Folders -->
+    {#if data.currentFolders.length > 0}
+      <div
+        class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 2xl:grid-cols-8 gap-2 bg-gray-50 dark:bg-immich-dark-gray/50 rounded-2xl border border-gray-100 dark:border-gray-900 max-h-[500px] overflow-auto immich-scrollbar"
+      >
+        {#each data.currentFolders as folder}
+          <button
+            class="flex flex-col place-items-center gap-2 py-2 px-4 hover:bg-immich-primary/10 dark:hover:bg-immich-primary/40 rounded-xl"
+            on:click={() => handleNavigation(folder)}
+            title={folder}
+            type="button"
+          >
+            <Icon path={mdiFolder} class="text-immich-primary dark:text-immich-dark-primary" size={64} />
+            <p class="text-sm dark:text-gray-200 text-nowrap text-ellipsis overflow-clip w-full">{folder}</p>
+          </button>
+        {/each}
+      </div>
+    {/if}
+
+    <!-- Assets -->
+    <div
+      bind:clientHeight={viewport.height}
+      bind:clientWidth={viewport.width}
+      class:mt-4={data.currentFolders.length > 0}
+    >
+      {#if data.pathAssets && data.pathAssets.length > 0}
+        <GalleryViewer
+          assets={data.pathAssets}
+          bind:selectedAssets
+          on:intersected={loadNextPage}
+          {viewport}
+          disableAssetSelect={true}
+          showAssetName={true}
+        />
+      {/if}
+    </div>
+  </section>
+</UserPageLayout>
diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts
new file mode 100644
index 0000000000..f04d7840e5
--- /dev/null
+++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts
@@ -0,0 +1,41 @@
+import { foldersStore } from '$lib/stores/folders.store';
+import { authenticate } from '$lib/utils/auth';
+import { getFormatter } from '$lib/utils/i18n';
+import { getAssetInfoFromParam } from '$lib/utils/navigation';
+import { get } from 'svelte/store';
+import type { PageLoad } from './$types';
+
+export const load = (async ({ params, url }) => {
+  await authenticate();
+  const asset = await getAssetInfoFromParam(params);
+  const $t = await getFormatter();
+
+  await foldersStore.fetchUniquePaths();
+  const { uniquePaths } = get(foldersStore);
+
+  let pathAssets = null;
+  const path = url.searchParams.get('folder');
+
+  if (path) {
+    await foldersStore.fetchAssetsByPath(path);
+    const { assets } = get(foldersStore);
+    pathAssets = assets[path] || null;
+  }
+
+  const currentPath = path ? `${path}/`.replaceAll('//', '/') : '';
+
+  const currentFolders = (uniquePaths || [])
+    .filter((path) => path.startsWith(currentPath) && path !== currentPath)
+    .map((path) => path.replaceAll(currentPath, '').split('/')[0])
+    .filter((value, index, self) => self.indexOf(value) === index);
+
+  return {
+    asset,
+    path,
+    currentFolders,
+    pathAssets,
+    meta: {
+      title: $t('folders'),
+    },
+  };
+}) satisfies PageLoad;