Compare commits

..

5 Commits

Author SHA1 Message Date
Alex Tran ad996d1b88 wip: fav button 2026-04-26 22:05:56 -05:00
Alex Tran 08a73c0978 fix: tests 2026-04-26 13:06:17 -05:00
Alex 976ce39fe2 Merge branch 'main' into feat/favorite-albums 2026-04-26 09:18:12 -05:00
Alex a162230a75 chore: regenerate mobile openapi sdk 2026-04-23 04:15:14 +00:00
Alex 166f36e5bf feat(server,web): favorite albums per user 2026-04-23 03:33:32 +00:00
142 changed files with 1987 additions and 2053 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[tools] [tools]
terragrunt = "1.0.2" terragrunt = "1.0.1"
opentofu = "1.11.6" opentofu = "1.11.6"
[tasks."tg:fmt"] [tasks."tg:fmt"]
+1 -1
View File
@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus container_name: immich_prometheus
ports: ports:
- 9090:9090 - 9090:9090
image: prom/prometheus@sha256:e4254400b85610324913f0dc4acf92603d9984e7519414c5a12811aa6146acc3 image: prom/prometheus@sha256:5550dc63da361dc30f6fe02ac0e4dfc736ededfef3c8d12a634db04a67824d78
volumes: volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml - ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus - prometheus-data:/prometheus
+1 -1
View File
@@ -39,7 +39,7 @@ You can learn how to set up Tailscale together with Immich with the [tutorial vi
### Cons ### Cons
- The Tailscale client usually needs to run as root on your devices and it increases the attack surface slightly compared to a minimal Wireguard server. e.g., an [RCE vulnerability](https://github.com/tailscale/tailscale/security/advisories/GHSA-vqp6-rc3h-83cp) was discovered in the Windows Tailscale client in November 2022. - The Tailscale client usually needs to run as root on your devices and it increases the attack surface slightly compared to a minimal Wireguard server. e.g., an [RCE vulnerability](https://github.com/tailscale/tailscale/security/advisories/GHSA-vqp6-rc3h-83cp) was discovered in the Windows Tailscale client in November 2022.
- Tailscale is a paid service. However, there is a generous [free tier](https://tailscale.com/pricing/) suitable for personal use. - Tailscale is a paid service. However, there is a generous [free tier](https://tailscale.com/pricing/) that permits up to 3 users and up to 100 devices.
- Tailscale needs to be installed and running on both server-side and client-side. - Tailscale needs to be installed and running on both server-side and client-side.
## Option 3: Reverse Proxy ## Option 3: Reverse Proxy
+39
View File
@@ -2,43 +2,82 @@ import { expect } from 'vitest';
export const errorDto = { export const errorDto = {
unauthorized: { unauthorized: {
error: 'Unauthorized',
statusCode: 401,
message: 'Authentication required', message: 'Authentication required',
correlationId: expect.any(String),
}, },
unauthorizedWithMessage: (message: string) => ({ unauthorizedWithMessage: (message: string) => ({
error: 'Unauthorized',
statusCode: 401,
message, message,
correlationId: expect.any(String),
}), }),
forbidden: { forbidden: {
error: 'Forbidden',
statusCode: 403,
message: expect.any(String), message: expect.any(String),
correlationId: expect.any(String),
}, },
missingPermission: (permission: string) => ({ missingPermission: (permission: string) => ({
error: 'Forbidden',
statusCode: 403,
message: `Missing required permission: ${permission}`, message: `Missing required permission: ${permission}`,
correlationId: expect.any(String),
}), }),
wrongPassword: { wrongPassword: {
error: 'Bad Request',
statusCode: 400,
message: 'Wrong password', message: 'Wrong password',
correlationId: expect.any(String),
}, },
invalidToken: { invalidToken: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid user token', message: 'Invalid user token',
correlationId: expect.any(String),
}, },
invalidShareKey: { invalidShareKey: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid share key', message: 'Invalid share key',
correlationId: expect.any(String),
}, },
passwordRequired: { passwordRequired: {
error: 'Unauthorized',
statusCode: 401,
message: 'Password required', message: 'Password required',
correlationId: expect.any(String),
}, },
badRequest: (message: any = null) => ({ badRequest: (message: any = null) => ({
error: 'Bad Request',
statusCode: 400,
message: message ?? expect.anything(), message: message ?? expect.anything(),
correlationId: expect.any(String),
}), }),
noPermission: { noPermission: {
error: 'Bad Request',
statusCode: 400,
message: expect.stringContaining('Not found or no'), message: expect.stringContaining('Not found or no'),
correlationId: expect.any(String),
}, },
incorrectLogin: { incorrectLogin: {
error: 'Unauthorized',
statusCode: 401,
message: 'Incorrect email or password', message: 'Incorrect email or password',
correlationId: expect.any(String),
}, },
alreadyHasAdmin: { alreadyHasAdmin: {
error: 'Bad Request',
statusCode: 400,
message: 'The server already has an admin', message: 'The server already has an admin',
correlationId: expect.any(String),
}, },
invalidEmail: { invalidEmail: {
error: 'Bad Request',
statusCode: 400,
message: ['email must be an email'], message: ['email must be an email'],
correlationId: expect.any(String),
}, },
}; };
+4 -1
View File
@@ -332,7 +332,9 @@ describe(`/oauth`, () => {
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams); const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(500); expect(status).toBe(500);
expect(body).toMatchObject({ expect(body).toMatchObject({
error: 'Internal Server Error',
message: 'Failed to finish oauth', message: 'Failed to finish oauth',
statusCode: 500,
}); });
}); });
@@ -493,10 +495,11 @@ describe(`/oauth`, () => {
}); });
it('should reject OAuth discovery over HTTP', async () => { it('should reject OAuth discovery over HTTP', async () => {
const { status } = await request(app) const { status, body } = await request(app)
.post('/oauth/authorize') .post('/oauth/authorize')
.send({ redirectUri: 'http://127.0.0.1:2285/auth/login' }); .send({ redirectUri: 'http://127.0.0.1:2285/auth/login' });
expect(status).toBe(500); expect(status).toBe(500);
expect(body).toMatchObject({ statusCode: 500 });
}); });
}); });
}); });
+1 -4
View File
@@ -183,10 +183,7 @@ async def predict(
text: str | None = Form(default=None), text: str | None = Form(default=None),
) -> Any: ) -> Any:
if image is not None: if image is not None:
decoded = await run(lambda: decode_pil(image)) inputs: Image | str = await run(lambda: decode_pil(image))
if decoded.width == 0 or decoded.height == 0:
raise HTTPException(400, "Image has zero width or height")
inputs: Image | str = decoded
elif text is not None: elif text is not None:
inputs = text inputs = text
else: else:
-13
View File
@@ -1198,19 +1198,6 @@ class TestLoad:
mock_model.model_format = ModelFormat.ONNX mock_model.model_format = ModelFormat.ONNX
@pytest.mark.parametrize("size", [(0, 100), (100, 0), (0, 0)])
def test_predict_rejects_empty_image(size: tuple[int, int], deployed_app: TestClient) -> None:
with mock.patch("immich_ml.main.decode_pil", return_value=Image.new("RGB", size)):
response = deployed_app.post(
"http://localhost:3003/predict",
data={"entries": json.dumps({"clip": {"visual": {"modelName": "ViT-B-32__openai"}}})},
files={"image": b"fake image bytes"},
)
assert response.status_code == 400
assert "zero" in response.json()["detail"].lower()
def test_root_endpoint(deployed_app: TestClient) -> None: def test_root_endpoint(deployed_app: TestClient) -> None:
response = deployed_app.get("http://localhost:3003") response = deployed_app.get("http://localhost:3003")
+3 -3
View File
@@ -15,9 +15,9 @@ config_roots = [
[tools] [tools]
node = "24.15.0" node = "24.15.0"
flutter = "3.41.7" flutter = "3.41.6"
pnpm = "10.33.1" pnpm = "10.33.0"
terragrunt = "1.0.2" terragrunt = "1.0.1"
opentofu = "1.11.6" opentofu = "1.11.6"
java = "21.0.2" java = "21.0.2"
+1 -1
View File
@@ -1,5 +1,5 @@
app_identifier "app.alextran.immich" # The bundle identifier of your app app_identifier "app.alextran.immich" # The bundle identifier of your app
apple_id "altran@futo.org" # Your Apple email address apple_id "alex.tran1502@gmail.com" # Your Apple email address
# For more information about the Appfile, see: # For more information about the Appfile, see:
+41 -41
View File
@@ -17,11 +17,10 @@ default_platform(:ios)
platform :ios do platform :ios do
# Constants # Constants
TEAM_ID = "2W7AC6T8T5" TEAM_ID = "2F67MQ8R79"
CODE_SIGN_IDENTITY = "Apple Distribution: FUTO Holdings, Inc. (#{TEAM_ID})" CODE_SIGN_IDENTITY = "Apple Distribution: Hau Tran (#{TEAM_ID})"
BASE_BUNDLE_ID = "app.alextran.immich" BASE_BUNDLE_ID = "app.alextran.immich"
DEV_BUNDLE_ID = "tech.futo.immich.testflight"
# Helper method to get App Store Connect API key # Helper method to get App Store Connect API key
def get_api_key def get_api_key
app_store_connect_api_key( app_store_connect_api_key(
@@ -45,45 +44,47 @@ def get_version_from_pubspec
end end
# Helper method to configure code signing for all targets # Helper method to configure code signing for all targets
def configure_code_signing(base_bundle_id:, profile_name_main:, profile_name_share:, profile_name_widget:) def configure_code_signing(bundle_id_suffix: "", profile_name_main:, profile_name_share:, profile_name_widget:)
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
# Runner (main app) # Runner (main app)
update_code_signing_settings( update_code_signing_settings(
use_automatic_signing: false, use_automatic_signing: false,
path: "./Runner.xcodeproj", path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID, team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY, code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: base_bundle_id, bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}",
profile_name: profile_name_main, profile_name: profile_name_main,
targets: ["Runner"] targets: ["Runner"]
) )
# ShareExtension # ShareExtension
update_code_signing_settings( update_code_signing_settings(
use_automatic_signing: false, use_automatic_signing: false,
path: "./Runner.xcodeproj", path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID, team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY, code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{base_bundle_id}.ShareExtension", bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
profile_name: profile_name_share, profile_name: profile_name_share,
targets: ["ShareExtension"] targets: ["ShareExtension"]
) )
# WidgetExtension # WidgetExtension
update_code_signing_settings( update_code_signing_settings(
use_automatic_signing: false, use_automatic_signing: false,
path: "./Runner.xcodeproj", path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID, team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY, code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{base_bundle_id}.Widget", bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
profile_name: profile_name_widget, profile_name: profile_name_widget,
targets: ["WidgetExtension"] targets: ["WidgetExtension"]
) )
end end
# Helper method to build and upload to TestFlight # Helper method to build and upload to TestFlight
def build_and_upload( def build_and_upload(
api_key:, api_key:,
base_bundle_id:, bundle_id_suffix: "",
configuration: "Release", configuration: "Release",
distribute_external: true, distribute_external: true,
version_number: nil, version_number: nil,
@@ -91,8 +92,9 @@ end
profile_name_share:, profile_name_share:,
profile_name_widget: profile_name_widget:
) )
app_identifier = base_bundle_id bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}"
# Set version number if provided # Set version number if provided
if version_number if version_number
increment_version_number(version_number: version_number) increment_version_number(version_number: version_number)
@@ -136,31 +138,31 @@ end
desc "iOS Development Build to TestFlight (requires separate bundle ID)" desc "iOS Development Build to TestFlight (requires separate bundle ID)"
lane :gha_testflight_dev do lane :gha_testflight_dev do
api_key = get_api_key api_key = get_api_key
# Download and install provisioning profiles from App Store Connect # Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain # Certificate is imported by GHA workflow into build.keychain
# Capture profile names after each sigh call # Capture profile names after each sigh call
sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true) sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME] main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.ShareExtension", force: true) sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME] share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.Widget", force: true) sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME] widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs using the downloaded profile names # Configure code signing for dev bundle IDs using the downloaded profile names
configure_code_signing( configure_code_signing(
base_bundle_id: DEV_BUNDLE_ID, bundle_id_suffix: "development",
profile_name_main: main_profile_name, profile_name_main: main_profile_name,
profile_name_share: share_profile_name, profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name profile_name_widget: widget_profile_name
) )
# Build and upload # Build and upload
build_and_upload( build_and_upload(
api_key: api_key, api_key: api_key,
base_bundle_id: DEV_BUNDLE_ID, bundle_id_suffix: "development",
configuration: "Profile", configuration: "Profile",
distribute_external: false, distribute_external: false,
profile_name_main: main_profile_name, profile_name_main: main_profile_name,
@@ -187,7 +189,6 @@ end
# Configure code signing for production bundle IDs # Configure code signing for production bundle IDs
configure_code_signing( configure_code_signing(
base_bundle_id: BASE_BUNDLE_ID,
profile_name_main: main_profile_name, profile_name_main: main_profile_name,
profile_name_share: share_profile_name, profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name profile_name_widget: widget_profile_name
@@ -196,7 +197,6 @@ end
# Build and upload with version number # Build and upload with version number
build_and_upload( build_and_upload(
api_key: api_key, api_key: api_key,
base_bundle_id: BASE_BUNDLE_ID,
version_number: get_version_from_pubspec, version_number: get_version_from_pubspec,
distribute_external: false, distribute_external: false,
profile_name_main: main_profile_name, profile_name_main: main_profile_name,
@@ -243,30 +243,30 @@ end
desc "iOS Build Only (no TestFlight upload)" desc "iOS Build Only (no TestFlight upload)"
lane :gha_build_only do lane :gha_build_only do
# Use the same build process as the dev TestFlight lane, just skip the upload # Use the same build process as production, just skip the upload
# This ensures PR builds validate the same way as dev TestFlight builds # This ensures PR builds validate the same way as production builds
api_key = get_api_key api_key = get_api_key
# Download and install provisioning profiles from App Store Connect # Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain # Certificate is imported by GHA workflow into build.keychain
sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true) sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME] main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.ShareExtension", force: true) sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME] share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.Widget", force: true) sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME] widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs # Configure code signing for dev bundle IDs
configure_code_signing( configure_code_signing(
base_bundle_id: DEV_BUNDLE_ID, bundle_id_suffix: "development",
profile_name_main: main_profile_name, profile_name_main: main_profile_name,
profile_name_share: share_profile_name, profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name profile_name_widget: widget_profile_name
) )
# Build the app (same as gha_testflight_dev but without upload) # Build the app (same as gha_testflight_dev but without upload)
build_app( build_app(
scheme: "Runner", scheme: "Runner",
@@ -277,9 +277,9 @@ end
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual", xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: { export_options: {
provisioningProfiles: { provisioningProfiles: {
DEV_BUNDLE_ID => main_profile_name, "#{BASE_BUNDLE_ID}.development" => main_profile_name,
"#{DEV_BUNDLE_ID}.ShareExtension" => share_profile_name, "#{BASE_BUNDLE_ID}.development.ShareExtension" => share_profile_name,
"#{DEV_BUNDLE_ID}.Widget" => widget_profile_name "#{BASE_BUNDLE_ID}.development.Widget" => widget_profile_name
}, },
signingStyle: "manual", signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY signingCertificate: CODE_SIGN_IDENTITY
@@ -3,7 +3,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
@@ -192,22 +191,17 @@ class SyncStreamService {
case SyncEntityType.assetV1: case SyncEntityType.assetV1:
final remoteSyncAssets = data.cast<SyncAssetV1>(); final remoteSyncAssets = data.cast<SyncAssetV1>();
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets); await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
await _runWithManageMediaPermission( if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
logContext: "Trashed Assets", final hasPermission = await _localFilesManager.hasManageMediaPermission();
action: () async { if (hasPermission) {
await _handleRemoteDeleted(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id)); await _handleRemoteTrashed(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum));
await _applyRemoteRestoreToLocal(); await _applyRemoteRestoreToLocal();
}, } else {
); _logger.warning("sync Trashed Assets cannot proceed because MANAGE_MEDIA permission is missing");
}
}
return; return;
case SyncEntityType.assetDeleteV1: case SyncEntityType.assetDeleteV1:
await _runWithManageMediaPermission(
logContext: "Deleted Assets",
action: () async {
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
await _handleRemoteDeleted(remoteSyncAssets.map((e) => e.assetId));
},
);
return _syncStreamRepository.deleteAssetsV1(data.cast()); return _syncStreamRepository.deleteAssetsV1(data.cast());
case SyncEntityType.assetExifV1: case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast()); return _syncStreamRepository.updateAssetsExifV1(data.cast());
@@ -388,32 +382,28 @@ class SyncStreamService {
} }
} }
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async { Future<void> _handleRemoteTrashed(Iterable<String> checksums) async {
if (remoteIds.isEmpty) { if (checksums.isEmpty) {
return Future.value(); return Future.value();
} else { } else {
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(remoteIds); final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums);
if (localAssetsToTrash.isNotEmpty) { if (localAssetsToTrash.isNotEmpty) {
await _trashLocalAssets(localAssetsToTrash); final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
}
} else { } else {
_logger.info("No assets found in backup-enabled albums for remote assets: $remoteIds"); _logger.info("No assets found in backup-enabled albums for assets: $checksums");
} }
} }
} }
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
}
}
Future<void> _applyRemoteRestoreToLocal() async { Future<void> _applyRemoteRestoreToLocal() async {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore(); final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) { if (assetsToRestore.isNotEmpty) {
@@ -423,21 +413,4 @@ class SyncStreamService {
_logger.info("No remote assets found for restoration"); _logger.info("No remote assets found for restoration");
} }
} }
Future<void> _runWithManageMediaPermission({
required String logContext,
required Future<void> Function() action,
}) async {
if (!CurrentPlatform.isAndroid || !Store.get(StoreKey.manageLocalMediaAndroid, false)) {
return;
}
final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (!hasPermission) {
_logger.warning("sync $logContext cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
await action();
}
} }
@@ -109,40 +109,31 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((localAlbum) => localAlbum.toDto()).get(); return query.map((localAlbum) => localAlbum.toDto()).get();
} }
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> remoteIds) async { Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> checksums) async {
if (remoteIds.isEmpty) { if (checksums.isEmpty) {
return {}; return {};
} }
final result = <String, List<LocalAsset>>{}; final result = <String, List<LocalAsset>>{};
for (final slice in remoteIds.toSet().slices(kDriftMaxChunk)) { for (final slice in checksums.toSet().slices(kDriftMaxChunk)) {
final rows = final rows =
await (_db.select(_db.localAlbumAssetEntity).join([ await (_db.select(_db.localAlbumAssetEntity).join([
innerJoin( innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)), innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
innerJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
])..where( ])..where(
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) & _db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.id.isIn(slice), _db.localAssetEntity.checksum.isIn(slice),
)) ))
.get(); .get();
for (final row in rows) { for (final row in rows) {
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId; final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
final asset = row.readTable(_db.localAssetEntity).toDto(); final assetData = row.readTable(_db.localAssetEntity);
final asset = assetData.toDto();
(result[albumId] ??= <LocalAsset>[]).add(asset); (result[albumId] ??= <LocalAsset>[]).add(asset);
} }
} }
return result; return result;
} }
@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart';
@@ -364,8 +363,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
} }
BaseAsset displayAsset = asset; BaseAsset displayAsset = asset;
final showAssetStack = ref.watch(timelineServiceProvider.select((s) => s.origin != TimelineOrigin.trash)); final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull;
final stackChildren = showAssetStack ? ref.watch(stackChildrenNotifier(asset)).valueOrNull : null;
if (stackChildren != null && stackChildren.isNotEmpty) { if (stackChildren != null && stackChildren.isNotEmpty) {
displayAsset = stackChildren.elementAt(stackIndex); displayAsset = stackChildren.elementAt(stackIndex);
} }
@@ -1,10 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
class AssetStackRow extends ConsumerWidget { class AssetStackRow extends ConsumerWidget {
final List<RemoteAsset> stack; final List<RemoteAsset> stack;
@@ -17,11 +15,6 @@ class AssetStackRow extends ConsumerWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final hideAssetStack = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
if (hideAssetStack) {
return const SizedBox.shrink();
}
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0); double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
@@ -21,7 +21,6 @@ class ThumbnailTile extends ConsumerStatefulWidget {
this.showStorageIndicator = false, this.showStorageIndicator = false,
this.lockSelection = false, this.lockSelection = false,
this.heroOffset, this.heroOffset,
this.showStackIndicator = false,
super.key, super.key,
}); });
@@ -31,7 +30,6 @@ class ThumbnailTile extends ConsumerStatefulWidget {
final bool showStorageIndicator; final bool showStorageIndicator;
final bool lockSelection; final bool lockSelection;
final int? heroOffset; final int? heroOffset;
final bool showStackIndicator;
@override @override
ConsumerState<ThumbnailTile> createState() => _ThumbnailTileState(); ConsumerState<ThumbnailTile> createState() => _ThumbnailTileState();
@@ -141,14 +139,7 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
duration: Durations.short4, duration: Durations.short4,
child: Align( child: Align(
alignment: Alignment.topRight, alignment: Alignment.topRight,
child: Column( child: _AssetTypeIcons(asset: asset),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_AssetTypeIcons(asset: asset),
if (widget.showStackIndicator) _StackIndicator(asset: asset),
],
),
), ),
), ),
if (storageIndicator && asset != null) if (storageIndicator && asset != null)
@@ -295,8 +286,8 @@ class _AssetTypeIcons extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final remoteAsset = asset is RemoteAsset ? asset as RemoteAsset : null; final hasStack = asset is RemoteAsset && (asset as RemoteAsset).stackId != null;
final isLivePhoto = remoteAsset?.livePhotoVideoId != null; final isLivePhoto = asset is RemoteAsset && asset.livePhotoVideoId != null;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -304,6 +295,11 @@ class _AssetTypeIcons extends StatelessWidget {
children: [ children: [
if (asset.isVideo) if (asset.isVideo)
Padding(padding: const EdgeInsets.only(right: 10.0, top: 6.0), child: _VideoIndicator(asset.duration)), Padding(padding: const EdgeInsets.only(right: 10.0, top: 6.0), child: _VideoIndicator(asset.duration)),
if (hasStack)
const Padding(
padding: EdgeInsets.only(right: 10.0, top: 6.0),
child: _TileOverlayIcon(Icons.burst_mode_rounded),
),
if (isLivePhoto) if (isLivePhoto)
const Padding( const Padding(
padding: EdgeInsets.only(right: 10.0, top: 6.0), padding: EdgeInsets.only(right: 10.0, top: 6.0),
@@ -316,24 +312,6 @@ class _AssetTypeIcons extends StatelessWidget {
} }
} }
class _StackIndicator extends StatelessWidget {
final BaseAsset asset;
const _StackIndicator({required this.asset});
@override
Widget build(BuildContext context) {
if (asset is! RemoteAsset || (asset as RemoteAsset).stackId == null) {
return const SizedBox.shrink();
}
return const Padding(
padding: EdgeInsets.only(right: 10.0, top: 6.0),
child: _TileOverlayIcon(Icons.burst_mode_rounded),
);
}
}
class _UploadProgressOverlay extends StatelessWidget { class _UploadProgressOverlay extends StatelessWidget {
final double progress; final double progress;
@@ -244,7 +244,6 @@ class _AssetTileWidget extends ConsumerWidget {
final lockSelection = _getLockSelectionStatus(ref); final lockSelection = _getLockSelectionStatus(ref);
final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator)); final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator));
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final showStackIndicator = ref.read(timelineServiceProvider).origin != TimelineOrigin.trash;
return RepaintBoundary( return RepaintBoundary(
child: GestureDetector( child: GestureDetector(
@@ -254,7 +253,6 @@ class _AssetTileWidget extends ConsumerWidget {
asset, asset,
lockSelection: lockSelection, lockSelection: lockSelection,
showStorageIndicator: showStorageIndicator, showStorageIndicator: showStorageIndicator,
showStackIndicator: showStackIndicator,
heroOffset: heroOffset, heroOffset: heroOffset,
), ),
), ),
@@ -148,7 +148,6 @@ enum ActionButtonType {
context.selectedCount == 1, context.selectedCount == 1,
ActionButtonType.unstack => ActionButtonType.unstack =>
context.isOwner && // context.isOwner && //
context.timelineOrigin != TimelineOrigin.trash &&
!context.isInLockedView && // !context.isInLockedView && //
context.isStacked, context.isStacked,
ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView, ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView,
+12 -3
View File
@@ -508,9 +508,12 @@ class AlbumsApi {
/// * [String] assetId: /// * [String] assetId:
/// Filter albums containing this asset ID (ignores shared parameter) /// Filter albums containing this asset ID (ignores shared parameter)
/// ///
/// * [bool] favorite:
/// Filter to only albums favorited by the authenticated user
///
/// * [bool] shared: /// * [bool] shared:
/// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums /// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? shared, }) async { Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? favorite, bool? shared, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/albums'; final apiPath = r'/albums';
@@ -524,6 +527,9 @@ class AlbumsApi {
if (assetId != null) { if (assetId != null) {
queryParams.addAll(_queryParams('', 'assetId', assetId)); queryParams.addAll(_queryParams('', 'assetId', assetId));
} }
if (favorite != null) {
queryParams.addAll(_queryParams('', 'favorite', favorite));
}
if (shared != null) { if (shared != null) {
queryParams.addAll(_queryParams('', 'shared', shared)); queryParams.addAll(_queryParams('', 'shared', shared));
} }
@@ -551,10 +557,13 @@ class AlbumsApi {
/// * [String] assetId: /// * [String] assetId:
/// Filter albums containing this asset ID (ignores shared parameter) /// Filter albums containing this asset ID (ignores shared parameter)
/// ///
/// * [bool] favorite:
/// Filter to only albums favorited by the authenticated user
///
/// * [bool] shared: /// * [bool] shared:
/// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums /// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? shared, }) async { Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? favorite, bool? shared, }) async {
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, shared: shared, ); final response = await getAllAlbumsWithHttpInfo( assetId: assetId, favorite: favorite, shared: shared, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
+6 -6
View File
@@ -183,15 +183,15 @@ class PeopleApi {
/// * [String] closestPersonId: /// * [String] closestPersonId:
/// Closest person ID for similarity search /// Closest person ID for similarity search
/// ///
/// * [int] page: /// * [num] page:
/// Page number for pagination /// Page number for pagination
/// ///
/// * [int] size: /// * [num] size:
/// Number of items per page /// Number of items per page
/// ///
/// * [bool] withHidden: /// * [bool] withHidden:
/// Include hidden people /// Include hidden people
Future<Response> getAllPeopleWithHttpInfo({ String? closestAssetId, String? closestPersonId, int? page, int? size, bool? withHidden, }) async { Future<Response> getAllPeopleWithHttpInfo({ String? closestAssetId, String? closestPersonId, num? page, num? size, bool? withHidden, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/people'; final apiPath = r'/people';
@@ -244,15 +244,15 @@ class PeopleApi {
/// * [String] closestPersonId: /// * [String] closestPersonId:
/// Closest person ID for similarity search /// Closest person ID for similarity search
/// ///
/// * [int] page: /// * [num] page:
/// Page number for pagination /// Page number for pagination
/// ///
/// * [int] size: /// * [num] size:
/// Number of items per page /// Number of items per page
/// ///
/// * [bool] withHidden: /// * [bool] withHidden:
/// Include hidden people /// Include hidden people
Future<PeopleResponseDto?> getAllPeople({ String? closestAssetId, String? closestPersonId, int? page, int? size, bool? withHidden, }) async { Future<PeopleResponseDto?> getAllPeople({ String? closestAssetId, String? closestPersonId, num? page, num? size, bool? withHidden, }) async {
final response = await getAllPeopleWithHttpInfo( closestAssetId: closestAssetId, closestPersonId: closestPersonId, page: page, size: size, withHidden: withHidden, ); final response = await getAllPeopleWithHttpInfo( closestAssetId: closestAssetId, closestPersonId: closestPersonId, page: page, size: size, withHidden: withHidden, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+6 -6
View File
@@ -404,10 +404,10 @@ class SearchApi {
/// * [List<String>] personIds: /// * [List<String>] personIds:
/// Filter by person IDs /// Filter by person IDs
/// ///
/// * [int] rating: /// * [num] rating:
/// Filter by rating [1-5], or null for unrated /// Filter by rating [1-5], or null for unrated
/// ///
/// * [int] size: /// * [num] size:
/// Number of results to return /// Number of results to return
/// ///
/// * [String] state: /// * [String] state:
@@ -443,7 +443,7 @@ class SearchApi {
/// ///
/// * [bool] withExif: /// * [bool] withExif:
/// Include EXIF data in response /// Include EXIF data in response
Future<Response> searchLargeAssetsWithHttpInfo({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, int? rating, int? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async { Future<Response> searchLargeAssetsWithHttpInfo({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, num? rating, num? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/search/large-assets'; final apiPath = r'/search/large-assets';
@@ -619,10 +619,10 @@ class SearchApi {
/// * [List<String>] personIds: /// * [List<String>] personIds:
/// Filter by person IDs /// Filter by person IDs
/// ///
/// * [int] rating: /// * [num] rating:
/// Filter by rating [1-5], or null for unrated /// Filter by rating [1-5], or null for unrated
/// ///
/// * [int] size: /// * [num] size:
/// Number of results to return /// Number of results to return
/// ///
/// * [String] state: /// * [String] state:
@@ -658,7 +658,7 @@ class SearchApi {
/// ///
/// * [bool] withExif: /// * [bool] withExif:
/// Include EXIF data in response /// Include EXIF data in response
Future<List<AssetResponseDto>?> searchLargeAssets({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, int? rating, int? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async { Future<List<AssetResponseDto>?> searchLargeAssets({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, num? rating, num? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
final response = await searchLargeAssetsWithHttpInfo( albumIds: albumIds, city: city, country: country, createdAfter: createdAfter, createdBefore: createdBefore, isEncoded: isEncoded, isFavorite: isFavorite, isMotion: isMotion, isNotInAlbum: isNotInAlbum, isOffline: isOffline, lensModel: lensModel, libraryId: libraryId, make: make, minFileSize: minFileSize, model: model, ocr: ocr, personIds: personIds, rating: rating, size: size, state: state, tagIds: tagIds, takenAfter: takenAfter, takenBefore: takenBefore, trashedAfter: trashedAfter, trashedBefore: trashedBefore, type: type, updatedAfter: updatedAfter, updatedBefore: updatedBefore, visibility: visibility, withDeleted: withDeleted, withExif: withExif, ); final response = await searchLargeAssetsWithHttpInfo( albumIds: albumIds, city: city, country: country, createdAfter: createdAfter, createdBefore: createdBefore, isEncoded: isEncoded, isFavorite: isFavorite, isMotion: isMotion, isNotInAlbum: isNotInAlbum, isOffline: isOffline, lensModel: lensModel, libraryId: libraryId, make: make, minFileSize: minFileSize, model: model, ocr: ocr, personIds: personIds, rating: rating, size: size, state: state, tagIds: tagIds, takenAfter: takenAfter, takenBefore: takenBefore, trashedAfter: trashedAfter, trashedBefore: trashedBefore, type: type, updatedAfter: updatedAfter, updatedBefore: updatedBefore, visibility: visibility, withDeleted: withDeleted, withExif: withExif, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+10 -1
View File
@@ -24,6 +24,7 @@ class AlbumResponseDto {
required this.hasSharedLink, required this.hasSharedLink,
required this.id, required this.id,
required this.isActivityEnabled, required this.isActivityEnabled,
required this.isFavorite,
this.lastModifiedAssetTimestamp, this.lastModifiedAssetTimestamp,
this.order, this.order,
required this.shared, required this.shared,
@@ -72,6 +73,9 @@ class AlbumResponseDto {
/// Activity feed enabled /// Activity feed enabled
bool isActivityEnabled; bool isActivityEnabled;
/// Whether the authenticated user has favorited this album
bool isFavorite;
/// Last modified asset timestamp /// Last modified asset timestamp
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// Please note: This property should have been non-nullable! Since the specification file
@@ -117,6 +121,7 @@ class AlbumResponseDto {
other.hasSharedLink == hasSharedLink && other.hasSharedLink == hasSharedLink &&
other.id == id && other.id == id &&
other.isActivityEnabled == isActivityEnabled && other.isActivityEnabled == isActivityEnabled &&
other.isFavorite == isFavorite &&
other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp && other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp &&
other.order == order && other.order == order &&
other.shared == shared && other.shared == shared &&
@@ -137,6 +142,7 @@ class AlbumResponseDto {
(hasSharedLink.hashCode) + (hasSharedLink.hashCode) +
(id.hashCode) + (id.hashCode) +
(isActivityEnabled.hashCode) + (isActivityEnabled.hashCode) +
(isFavorite.hashCode) +
(lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) + (lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) +
(order == null ? 0 : order!.hashCode) + (order == null ? 0 : order!.hashCode) +
(shared.hashCode) + (shared.hashCode) +
@@ -144,7 +150,7 @@ class AlbumResponseDto {
(updatedAt.hashCode); (updatedAt.hashCode);
@override @override
String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, albumUsers=$albumUsers, assetCount=$assetCount, contributorCounts=$contributorCounts, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, shared=$shared, startDate=$startDate, updatedAt=$updatedAt]'; String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, albumUsers=$albumUsers, assetCount=$assetCount, contributorCounts=$contributorCounts, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, isFavorite=$isFavorite, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, shared=$shared, startDate=$startDate, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -167,6 +173,7 @@ class AlbumResponseDto {
json[r'hasSharedLink'] = this.hasSharedLink; json[r'hasSharedLink'] = this.hasSharedLink;
json[r'id'] = this.id; json[r'id'] = this.id;
json[r'isActivityEnabled'] = this.isActivityEnabled; json[r'isActivityEnabled'] = this.isActivityEnabled;
json[r'isFavorite'] = this.isFavorite;
if (this.lastModifiedAssetTimestamp != null) { if (this.lastModifiedAssetTimestamp != null) {
json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String(); json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String();
} else { } else {
@@ -207,6 +214,7 @@ class AlbumResponseDto {
hasSharedLink: mapValueOfType<bool>(json, r'hasSharedLink')!, hasSharedLink: mapValueOfType<bool>(json, r'hasSharedLink')!,
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled')!, isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''), lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''),
order: AssetOrder.fromJson(json[r'order']), order: AssetOrder.fromJson(json[r'order']),
shared: mapValueOfType<bool>(json, r'shared')!, shared: mapValueOfType<bool>(json, r'shared')!,
@@ -268,6 +276,7 @@ class AlbumResponseDto {
'hasSharedLink', 'hasSharedLink',
'id', 'id',
'isActivityEnabled', 'isActivityEnabled',
'isFavorite',
'shared', 'shared',
'updatedAt', 'updatedAt',
}; };
+2 -5
View File
@@ -37,15 +37,12 @@ class AssetBulkUpdateDto {
/// Relative time offset in seconds /// Relative time offset in seconds
/// ///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
///
/// Please note: This property should have been non-nullable! Since the specification file /// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated /// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
int? dateTimeRelative; num? dateTimeRelative;
/// Asset description /// Asset description
/// ///
@@ -216,7 +213,7 @@ class AssetBulkUpdateDto {
return AssetBulkUpdateDto( return AssetBulkUpdateDto(
dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'), dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
dateTimeRelative: mapValueOfType<int>(json, r'dateTimeRelative'), dateTimeRelative: num.parse('${json[r'dateTimeRelative']}'),
description: mapValueOfType<String>(json, r'description'), description: mapValueOfType<String>(json, r'description'),
duplicateId: mapValueOfType<String>(json, r'duplicateId'), duplicateId: mapValueOfType<String>(json, r'duplicateId'),
ids: json[r'ids'] is Iterable ids: json[r'ids'] is Iterable
@@ -24,26 +24,22 @@ class AssetEditActionItemDtoParameters {
/// Height of the crop /// Height of the crop
/// ///
/// Minimum value: 1 /// Minimum value: 1
/// Maximum value: 9007199254740991 num height;
int height;
/// Width of the crop /// Width of the crop
/// ///
/// Minimum value: 1 /// Minimum value: 1
/// Maximum value: 9007199254740991 num width;
int width;
/// Top-Left X coordinate of crop /// Top-Left X coordinate of crop
/// ///
/// Minimum value: 0 /// Minimum value: 0
/// Maximum value: 9007199254740991 num x;
int x;
/// Top-Left Y coordinate of crop /// Top-Left Y coordinate of crop
/// ///
/// Minimum value: 0 /// Minimum value: 0
/// Maximum value: 9007199254740991 num y;
int y;
/// Rotation angle in degrees /// Rotation angle in degrees
num angle; num angle;
@@ -92,10 +88,10 @@ class AssetEditActionItemDtoParameters {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return AssetEditActionItemDtoParameters( return AssetEditActionItemDtoParameters(
height: mapValueOfType<int>(json, r'height')!, height: num.parse('${json[r'height']}'),
width: mapValueOfType<int>(json, r'width')!, width: num.parse('${json[r'width']}'),
x: mapValueOfType<int>(json, r'x')!, x: num.parse('${json[r'x']}'),
y: mapValueOfType<int>(json, r'y')!, y: num.parse('${json[r'y']}'),
angle: num.parse('${json[r'angle']}'), angle: num.parse('${json[r'angle']}'),
axis: MirrorAxis.fromJson(json[r'axis'])!, axis: MirrorAxis.fromJson(json[r'axis'])!,
); );
+8 -6
View File
@@ -80,8 +80,7 @@ class AssetResponseDto {
/// Asset height /// Asset height
/// ///
/// Minimum value: 0 /// Minimum value: 0
/// Maximum value: 9007199254740991 num? height;
int? height;
/// Asset ID /// Asset ID
String id; String id;
@@ -166,8 +165,7 @@ class AssetResponseDto {
/// Asset width /// Asset width
/// ///
/// Minimum value: 0 /// Minimum value: 0
/// Maximum value: 9007199254740991 num? width;
int? width;
@override @override
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto && bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
@@ -348,7 +346,9 @@ class AssetResponseDto {
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!, fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!,
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!, fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!,
hasMetadata: mapValueOfType<bool>(json, r'hasMetadata')!, hasMetadata: mapValueOfType<bool>(json, r'hasMetadata')!,
height: mapValueOfType<int>(json, r'height'), height: json[r'height'] == null
? null
: num.parse('${json[r'height']}'),
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
isArchived: mapValueOfType<bool>(json, r'isArchived')!, isArchived: mapValueOfType<bool>(json, r'isArchived')!,
isEdited: mapValueOfType<bool>(json, r'isEdited')!, isEdited: mapValueOfType<bool>(json, r'isEdited')!,
@@ -372,7 +372,9 @@ class AssetResponseDto {
unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']), unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
updatedAt: mapDateTime(json, r'updatedAt', r'')!, updatedAt: mapDateTime(json, r'updatedAt', r'')!,
visibility: AssetVisibility.fromJson(json[r'visibility'])!, visibility: AssetVisibility.fromJson(json[r'visibility'])!,
width: mapValueOfType<int>(json, r'width'), width: json[r'width'] == null
? null
: num.parse('${json[r'width']}'),
); );
} }
return null; return null;
+8 -12
View File
@@ -22,26 +22,22 @@ class CropParameters {
/// Height of the crop /// Height of the crop
/// ///
/// Minimum value: 1 /// Minimum value: 1
/// Maximum value: 9007199254740991 num height;
int height;
/// Width of the crop /// Width of the crop
/// ///
/// Minimum value: 1 /// Minimum value: 1
/// Maximum value: 9007199254740991 num width;
int width;
/// Top-Left X coordinate of crop /// Top-Left X coordinate of crop
/// ///
/// Minimum value: 0 /// Minimum value: 0
/// Maximum value: 9007199254740991 num x;
int x;
/// Top-Left Y coordinate of crop /// Top-Left Y coordinate of crop
/// ///
/// Minimum value: 0 /// Minimum value: 0
/// Maximum value: 9007199254740991 num y;
int y;
@override @override
bool operator ==(Object other) => identical(this, other) || other is CropParameters && bool operator ==(Object other) => identical(this, other) || other is CropParameters &&
@@ -79,10 +75,10 @@ class CropParameters {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return CropParameters( return CropParameters(
height: mapValueOfType<int>(json, r'height')!, height: num.parse('${json[r'height']}'),
width: mapValueOfType<int>(json, r'width')!, width: num.parse('${json[r'width']}'),
x: mapValueOfType<int>(json, r'x')!, x: num.parse('${json[r'x']}'),
y: mapValueOfType<int>(json, r'y')!, y: num.parse('${json[r'y']}'),
); );
} }
return null; return null;
+2 -3
View File
@@ -27,8 +27,7 @@ class DatabaseBackupConfig {
/// Keep last amount /// Keep last amount
/// ///
/// Minimum value: 1 /// Minimum value: 1
/// Maximum value: 9007199254740991 num keepLastAmount;
int keepLastAmount;
@override @override
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupConfig && bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupConfig &&
@@ -65,7 +64,7 @@ class DatabaseBackupConfig {
return DatabaseBackupConfig( return DatabaseBackupConfig(
cronExpression: mapValueOfType<String>(json, r'cronExpression')!, cronExpression: mapValueOfType<String>(json, r'cronExpression')!,
enabled: mapValueOfType<bool>(json, r'enabled')!, enabled: mapValueOfType<bool>(json, r'enabled')!,
keepLastAmount: mapValueOfType<int>(json, r'keepLastAmount')!, keepLastAmount: num.parse('${json[r'keepLastAmount']}'),
); );
} }
return null; return null;
+2 -5
View File
@@ -22,10 +22,7 @@ class DatabaseBackupDto {
String filename; String filename;
/// Backup file size /// Backup file size
/// num filesize;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int filesize;
/// Backup timezone /// Backup timezone
String timezone; String timezone;
@@ -64,7 +61,7 @@ class DatabaseBackupDto {
return DatabaseBackupDto( return DatabaseBackupDto(
filename: mapValueOfType<String>(json, r'filename')!, filename: mapValueOfType<String>(json, r'filename')!,
filesize: mapValueOfType<int>(json, r'filesize')!, filesize: num.parse('${json[r'filesize']}'),
timezone: mapValueOfType<String>(json, r'timezone')!, timezone: mapValueOfType<String>(json, r'timezone')!,
); );
} }
+16 -16
View File
@@ -52,14 +52,12 @@ class ExifResponseDto {
/// Image height in pixels /// Image height in pixels
/// ///
/// Minimum value: 0 /// Minimum value: 0
/// Maximum value: 9007199254740991 num? exifImageHeight;
int? exifImageHeight;
/// Image width in pixels /// Image width in pixels
/// ///
/// Minimum value: 0 /// Minimum value: 0
/// Maximum value: 9007199254740991 num? exifImageWidth;
int? exifImageWidth;
/// Exposure time /// Exposure time
String? exposureTime; String? exposureTime;
@@ -77,10 +75,7 @@ class ExifResponseDto {
num? focalLength; num? focalLength;
/// ISO sensitivity /// ISO sensitivity
/// num? iso;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int? iso;
/// GPS latitude /// GPS latitude
num? latitude; num? latitude;
@@ -107,10 +102,7 @@ class ExifResponseDto {
String? projectionType; String? projectionType;
/// Rating /// Rating
/// num? rating;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int? rating;
/// State/province name /// State/province name
String? state; String? state;
@@ -300,8 +292,12 @@ class ExifResponseDto {
country: mapValueOfType<String>(json, r'country'), country: mapValueOfType<String>(json, r'country'),
dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''), dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''),
description: mapValueOfType<String>(json, r'description'), description: mapValueOfType<String>(json, r'description'),
exifImageHeight: mapValueOfType<int>(json, r'exifImageHeight'), exifImageHeight: json[r'exifImageHeight'] == null
exifImageWidth: mapValueOfType<int>(json, r'exifImageWidth'), ? null
: num.parse('${json[r'exifImageHeight']}'),
exifImageWidth: json[r'exifImageWidth'] == null
? null
: num.parse('${json[r'exifImageWidth']}'),
exposureTime: mapValueOfType<String>(json, r'exposureTime'), exposureTime: mapValueOfType<String>(json, r'exposureTime'),
fNumber: json[r'fNumber'] == null fNumber: json[r'fNumber'] == null
? null ? null
@@ -310,7 +306,9 @@ class ExifResponseDto {
focalLength: json[r'focalLength'] == null focalLength: json[r'focalLength'] == null
? null ? null
: num.parse('${json[r'focalLength']}'), : num.parse('${json[r'focalLength']}'),
iso: mapValueOfType<int>(json, r'iso'), iso: json[r'iso'] == null
? null
: num.parse('${json[r'iso']}'),
latitude: json[r'latitude'] == null latitude: json[r'latitude'] == null
? null ? null
: num.parse('${json[r'latitude']}'), : num.parse('${json[r'latitude']}'),
@@ -323,7 +321,9 @@ class ExifResponseDto {
modifyDate: mapDateTime(json, r'modifyDate', r''), modifyDate: mapDateTime(json, r'modifyDate', r''),
orientation: mapValueOfType<String>(json, r'orientation'), orientation: mapValueOfType<String>(json, r'orientation'),
projectionType: mapValueOfType<String>(json, r'projectionType'), projectionType: mapValueOfType<String>(json, r'projectionType'),
rating: mapValueOfType<int>(json, r'rating'), rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
timeZone: mapValueOfType<String>(json, r'timeZone'), timeZone: mapValueOfType<String>(json, r'timeZone'),
); );
@@ -21,13 +21,9 @@ class MachineLearningAvailabilityChecksDto {
/// Enabled /// Enabled
bool enabled; bool enabled;
/// Minimum value: -9007199254740991 num interval;
/// Maximum value: 9007199254740991
int interval;
/// Minimum value: -9007199254740991 num timeout;
/// Maximum value: 9007199254740991
int timeout;
@override @override
bool operator ==(Object other) => identical(this, other) || other is MachineLearningAvailabilityChecksDto && bool operator ==(Object other) => identical(this, other) || other is MachineLearningAvailabilityChecksDto &&
@@ -63,8 +59,8 @@ class MachineLearningAvailabilityChecksDto {
return MachineLearningAvailabilityChecksDto( return MachineLearningAvailabilityChecksDto(
enabled: mapValueOfType<bool>(json, r'enabled')!, enabled: mapValueOfType<bool>(json, r'enabled')!,
interval: mapValueOfType<int>(json, r'interval')!, interval: num.parse('${json[r'interval']}'),
timeout: mapValueOfType<int>(json, r'timeout')!, timeout: num.parse('${json[r'timeout']}'),
); );
} }
return null; return null;
@@ -20,10 +20,7 @@ class MaintenanceDetectInstallStorageFolderDto {
}); });
/// Number of files in the folder /// Number of files in the folder
/// num files;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int files;
StorageFolder folder; StorageFolder folder;
@@ -69,7 +66,7 @@ class MaintenanceDetectInstallStorageFolderDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return MaintenanceDetectInstallStorageFolderDto( return MaintenanceDetectInstallStorageFolderDto(
files: mapValueOfType<int>(json, r'files')!, files: num.parse('${json[r'files']}'),
folder: StorageFolder.fromJson(json[r'folder'])!, folder: StorageFolder.fromJson(json[r'folder'])!,
readable: mapValueOfType<bool>(json, r'readable')!, readable: mapValueOfType<bool>(json, r'readable')!,
writable: mapValueOfType<bool>(json, r'writable')!, writable: mapValueOfType<bool>(json, r'writable')!,
@@ -32,15 +32,13 @@ class MaintenanceStatusResponseDto {
/// ///
String? error; String? error;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated /// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
int? progress; num? progress;
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// Please note: This property should have been non-nullable! Since the specification file
@@ -104,7 +102,7 @@ class MaintenanceStatusResponseDto {
action: MaintenanceAction.fromJson(json[r'action'])!, action: MaintenanceAction.fromJson(json[r'action'])!,
active: mapValueOfType<bool>(json, r'active')!, active: mapValueOfType<bool>(json, r'active')!,
error: mapValueOfType<String>(json, r'error'), error: mapValueOfType<String>(json, r'error'),
progress: mapValueOfType<int>(json, r'progress'), progress: num.parse('${json[r'progress']}'),
task: mapValueOfType<String>(json, r'task'), task: mapValueOfType<String>(json, r'task'),
); );
} }
+8 -7
View File
@@ -215,14 +215,13 @@ class MetadataSearchDto {
/// Page number /// Page number
/// ///
/// Minimum value: 1 /// Minimum value: 1
/// Maximum value: 9007199254740991
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated /// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
int? page; num? page;
/// Filter by person IDs /// Filter by person IDs
List<String> personIds; List<String> personIds;
@@ -240,7 +239,7 @@ class MetadataSearchDto {
/// ///
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
int? rating; num? rating;
/// Number of results to return /// Number of results to return
/// ///
@@ -252,7 +251,7 @@ class MetadataSearchDto {
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
int? size; num? size;
/// Filter by state/province name /// Filter by state/province name
String? state; String? state;
@@ -725,13 +724,15 @@ class MetadataSearchDto {
order: AssetOrder.fromJson(json[r'order']), order: AssetOrder.fromJson(json[r'order']),
originalFileName: mapValueOfType<String>(json, r'originalFileName'), originalFileName: mapValueOfType<String>(json, r'originalFileName'),
originalPath: mapValueOfType<String>(json, r'originalPath'), originalPath: mapValueOfType<String>(json, r'originalPath'),
page: mapValueOfType<int>(json, r'page'), page: num.parse('${json[r'page']}'),
personIds: json[r'personIds'] is Iterable personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
previewPath: mapValueOfType<String>(json, r'previewPath'), previewPath: mapValueOfType<String>(json, r'previewPath'),
rating: mapValueOfType<int>(json, r'rating'), rating: json[r'rating'] == null
size: mapValueOfType<int>(json, r'size'), ? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
+6 -4
View File
@@ -147,7 +147,7 @@ class RandomSearchDto {
/// ///
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
int? rating; num? rating;
/// Number of results to return /// Number of results to return
/// ///
@@ -159,7 +159,7 @@ class RandomSearchDto {
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
int? size; num? size;
/// Filter by state/province name /// Filter by state/province name
String? state; String? state;
@@ -549,8 +549,10 @@ class RandomSearchDto {
personIds: json[r'personIds'] is Iterable personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
rating: mapValueOfType<int>(json, r'rating'), rating: json[r'rating'] == null
size: mapValueOfType<int>(json, r'size'), ? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
+2 -3
View File
@@ -39,14 +39,13 @@ class SessionCreateDto {
/// Session duration in seconds /// Session duration in seconds
/// ///
/// Minimum value: 1 /// Minimum value: 1
/// Maximum value: 9007199254740991
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated /// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
int? duration; num? duration;
@override @override
bool operator ==(Object other) => identical(this, other) || other is SessionCreateDto && bool operator ==(Object other) => identical(this, other) || other is SessionCreateDto &&
@@ -95,7 +94,7 @@ class SessionCreateDto {
return SessionCreateDto( return SessionCreateDto(
deviceOS: mapValueOfType<String>(json, r'deviceOS'), deviceOS: mapValueOfType<String>(json, r'deviceOS'),
deviceType: mapValueOfType<String>(json, r'deviceType'), deviceType: mapValueOfType<String>(json, r'deviceType'),
duration: mapValueOfType<int>(json, r'duration'), duration: num.parse('${json[r'duration']}'),
); );
} }
return null; return null;
+8 -7
View File
@@ -154,14 +154,13 @@ class SmartSearchDto {
/// Page number /// Page number
/// ///
/// Minimum value: 1 /// Minimum value: 1
/// Maximum value: 9007199254740991
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated /// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
int? page; num? page;
/// Filter by person IDs /// Filter by person IDs
List<String> personIds; List<String> personIds;
@@ -188,7 +187,7 @@ class SmartSearchDto {
/// ///
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
int? rating; num? rating;
/// Number of results to return /// Number of results to return
/// ///
@@ -200,7 +199,7 @@ class SmartSearchDto {
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
int? size; num? size;
/// Filter by state/province name /// Filter by state/province name
String? state; String? state;
@@ -584,14 +583,16 @@ class SmartSearchDto {
make: mapValueOfType<String>(json, r'make'), make: mapValueOfType<String>(json, r'make'),
model: mapValueOfType<String>(json, r'model'), model: mapValueOfType<String>(json, r'model'),
ocr: mapValueOfType<String>(json, r'ocr'), ocr: mapValueOfType<String>(json, r'ocr'),
page: mapValueOfType<int>(json, r'page'), page: num.parse('${json[r'page']}'),
personIds: json[r'personIds'] is Iterable personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
query: mapValueOfType<String>(json, r'query'), query: mapValueOfType<String>(json, r'query'),
queryAssetId: mapValueOfType<String>(json, r'queryAssetId'), queryAssetId: mapValueOfType<String>(json, r'queryAssetId'),
rating: mapValueOfType<int>(json, r'rating'), rating: json[r'rating'] == null
size: mapValueOfType<int>(json, r'size'), ? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
+4 -2
View File
@@ -152,7 +152,7 @@ class StatisticsSearchDto {
/// ///
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
int? rating; num? rating;
/// Filter by state/province name /// Filter by state/province name
String? state; String? state;
@@ -479,7 +479,9 @@ class StatisticsSearchDto {
personIds: json[r'personIds'] is Iterable personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
rating: mapValueOfType<int>(json, r'rating'), rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
+10 -1
View File
@@ -14,6 +14,7 @@ class SyncAlbumUserV1 {
/// Returns a new [SyncAlbumUserV1] instance. /// Returns a new [SyncAlbumUserV1] instance.
SyncAlbumUserV1({ SyncAlbumUserV1({
required this.albumId, required this.albumId,
required this.isFavorite,
required this.role, required this.role,
required this.userId, required this.userId,
}); });
@@ -21,6 +22,9 @@ class SyncAlbumUserV1 {
/// Album ID /// Album ID
String albumId; String albumId;
/// Favorite flag
bool isFavorite;
AlbumUserRole role; AlbumUserRole role;
/// User ID /// User ID
@@ -29,6 +33,7 @@ class SyncAlbumUserV1 {
@override @override
bool operator ==(Object other) => identical(this, other) || other is SyncAlbumUserV1 && bool operator ==(Object other) => identical(this, other) || other is SyncAlbumUserV1 &&
other.albumId == albumId && other.albumId == albumId &&
other.isFavorite == isFavorite &&
other.role == role && other.role == role &&
other.userId == userId; other.userId == userId;
@@ -36,15 +41,17 @@ class SyncAlbumUserV1 {
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(albumId.hashCode) + (albumId.hashCode) +
(isFavorite.hashCode) +
(role.hashCode) + (role.hashCode) +
(userId.hashCode); (userId.hashCode);
@override @override
String toString() => 'SyncAlbumUserV1[albumId=$albumId, role=$role, userId=$userId]'; String toString() => 'SyncAlbumUserV1[albumId=$albumId, isFavorite=$isFavorite, role=$role, userId=$userId]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'albumId'] = this.albumId; json[r'albumId'] = this.albumId;
json[r'isFavorite'] = this.isFavorite;
json[r'role'] = this.role; json[r'role'] = this.role;
json[r'userId'] = this.userId; json[r'userId'] = this.userId;
return json; return json;
@@ -60,6 +67,7 @@ class SyncAlbumUserV1 {
return SyncAlbumUserV1( return SyncAlbumUserV1(
albumId: mapValueOfType<String>(json, r'albumId')!, albumId: mapValueOfType<String>(json, r'albumId')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
role: AlbumUserRole.fromJson(json[r'role'])!, role: AlbumUserRole.fromJson(json[r'role'])!,
userId: mapValueOfType<String>(json, r'userId')!, userId: mapValueOfType<String>(json, r'userId')!,
); );
@@ -110,6 +118,7 @@ class SyncAlbumUserV1 {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'albumId', 'albumId',
'isFavorite',
'role', 'role',
'userId', 'userId',
}; };
+4 -3
View File
@@ -57,8 +57,7 @@ class SystemConfigOAuthDto {
/// Default storage quota /// Default storage quota
/// ///
/// Minimum value: 0 /// Minimum value: 0
/// Maximum value: 9007199254740991 num? defaultStorageQuota;
int? defaultStorageQuota;
/// Enabled /// Enabled
bool enabled; bool enabled;
@@ -201,7 +200,9 @@ class SystemConfigOAuthDto {
buttonText: mapValueOfType<String>(json, r'buttonText')!, buttonText: mapValueOfType<String>(json, r'buttonText')!,
clientId: mapValueOfType<String>(json, r'clientId')!, clientId: mapValueOfType<String>(json, r'clientId')!,
clientSecret: mapValueOfType<String>(json, r'clientSecret')!, clientSecret: mapValueOfType<String>(json, r'clientSecret')!,
defaultStorageQuota: mapValueOfType<int>(json, r'defaultStorageQuota'), defaultStorageQuota: json[r'defaultStorageQuota'] == null
? null
: num.parse('${json[r'defaultStorageQuota']}'),
enabled: mapValueOfType<bool>(json, r'enabled')!, enabled: mapValueOfType<bool>(json, r'enabled')!,
endSessionEndpoint: mapValueOfType<String>(json, r'endSessionEndpoint')!, endSessionEndpoint: mapValueOfType<String>(json, r'endSessionEndpoint')!,
issuerUrl: mapValueOfType<String>(json, r'issuerUrl')!, issuerUrl: mapValueOfType<String>(json, r'issuerUrl')!,
@@ -34,7 +34,7 @@ class SystemConfigSmtpTransportDto {
/// ///
/// Minimum value: 0 /// Minimum value: 0
/// Maximum value: 65535 /// Maximum value: 65535
int port; num port;
/// Whether to use secure connection (TLS/SSL) /// Whether to use secure connection (TLS/SSL)
bool secure; bool secure;
@@ -87,7 +87,7 @@ class SystemConfigSmtpTransportDto {
host: mapValueOfType<String>(json, r'host')!, host: mapValueOfType<String>(json, r'host')!,
ignoreCert: mapValueOfType<bool>(json, r'ignoreCert')!, ignoreCert: mapValueOfType<bool>(json, r'ignoreCert')!,
password: mapValueOfType<String>(json, r'password')!, password: mapValueOfType<String>(json, r'password')!,
port: mapValueOfType<int>(json, r'port')!, port: num.parse('${json[r'port']}'),
secure: mapValueOfType<bool>(json, r'secure')!, secure: mapValueOfType<bool>(json, r'secure')!,
username: mapValueOfType<String>(json, r'username')!, username: mapValueOfType<String>(json, r'username')!,
); );
+33 -6
View File
@@ -13,26 +13,53 @@ part of openapi.api;
class UpdateAlbumUserDto { class UpdateAlbumUserDto {
/// Returns a new [UpdateAlbumUserDto] instance. /// Returns a new [UpdateAlbumUserDto] instance.
UpdateAlbumUserDto({ UpdateAlbumUserDto({
required this.role, this.isFavorite,
this.role,
}); });
AlbumUserRole role; /// Mark album as favorite for the user (only the user themselves can update)
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isFavorite;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
AlbumUserRole? role;
@override @override
bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumUserDto && bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumUserDto &&
other.isFavorite == isFavorite &&
other.role == role; other.role == role;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(role.hashCode); (isFavorite == null ? 0 : isFavorite!.hashCode) +
(role == null ? 0 : role!.hashCode);
@override @override
String toString() => 'UpdateAlbumUserDto[role=$role]'; String toString() => 'UpdateAlbumUserDto[isFavorite=$isFavorite, role=$role]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
if (this.isFavorite != null) {
json[r'isFavorite'] = this.isFavorite;
} else {
// json[r'isFavorite'] = null;
}
if (this.role != null) {
json[r'role'] = this.role; json[r'role'] = this.role;
} else {
// json[r'role'] = null;
}
return json; return json;
} }
@@ -45,7 +72,8 @@ class UpdateAlbumUserDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return UpdateAlbumUserDto( return UpdateAlbumUserDto(
role: AlbumUserRole.fromJson(json[r'role'])!, isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
role: AlbumUserRole.fromJson(json[r'role']),
); );
} }
return null; return null;
@@ -93,7 +121,6 @@ class UpdateAlbumUserDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'role',
}; };
} }
+2 -5
View File
@@ -26,10 +26,7 @@ class WorkflowActionResponseDto {
String id; String id;
/// Action order /// Action order
/// num order;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int order;
/// Plugin action ID /// Plugin action ID
String pluginActionId; String pluginActionId;
@@ -82,7 +79,7 @@ class WorkflowActionResponseDto {
return WorkflowActionResponseDto( return WorkflowActionResponseDto(
actionConfig: mapCastOfType<String, Object>(json, r'actionConfig'), actionConfig: mapCastOfType<String, Object>(json, r'actionConfig'),
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
order: mapValueOfType<int>(json, r'order')!, order: num.parse('${json[r'order']}'),
pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!, pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!, workflowId: mapValueOfType<String>(json, r'workflowId')!,
); );
+2 -5
View File
@@ -26,10 +26,7 @@ class WorkflowFilterResponseDto {
String id; String id;
/// Filter order /// Filter order
/// num order;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int order;
/// Plugin filter ID /// Plugin filter ID
String pluginFilterId; String pluginFilterId;
@@ -82,7 +79,7 @@ class WorkflowFilterResponseDto {
return WorkflowFilterResponseDto( return WorkflowFilterResponseDto(
filterConfig: mapCastOfType<String, Object>(json, r'filterConfig'), filterConfig: mapCastOfType<String, Object>(json, r'filterConfig'),
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
order: mapValueOfType<int>(json, r'order')!, order: num.parse('${json[r'order']}'),
pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!, pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!, workflowId: mapValueOfType<String>(json, r'workflowId')!,
); );
@@ -419,8 +419,8 @@ void main() {
'album-b': [mergedAsset], 'album-b': [mergedAsset],
}; };
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async { when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>; final Iterable<String> requestedChecksums = invocation.positionalArguments.first as Iterable<String>;
expect(requestedRemoteIds.toSet(), equals({'remote-1', 'remote-2', 'remote-3'})); expect(requestedChecksums.toSet(), equals({'checksum-local', 'checksum-merged', 'checksum-remote-only'}));
return assetsByAlbum; return assetsByAlbum;
}); });
@@ -482,18 +482,12 @@ void main() {
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any())); verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
}); });
test("requests local deletions lookup by remote ids for permanent remote delete events", () async { test("does not request local deletions for permanent remote delete events", () async {
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
expect(requestedRemoteIds.toSet(), equals({'remote-asset'}));
return {};
});
final events = [SyncStreamStub.assetDeleteV1]; final events = [SyncStreamStub.assetDeleteV1];
await simulateEvents(events); await simulateEvents(events);
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1); verifyNever(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any()));
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any())); verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1); verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
}); });
+58 -72
View File
@@ -1648,6 +1648,15 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "favorite",
"required": false,
"in": "query",
"description": "Filter to only albums favorited by the authenticated user",
"schema": {
"type": "boolean"
}
},
{ {
"name": "shared", "name": "shared",
"required": false, "required": false,
@@ -7964,9 +7973,8 @@
"description": "Page number for pagination", "description": "Page number for pagination",
"schema": { "schema": {
"minimum": 1, "minimum": 1,
"maximum": 9007199254740991,
"default": 1, "default": 1,
"type": "integer" "type": "number"
} }
}, },
{ {
@@ -7978,7 +7986,7 @@
"minimum": 1, "minimum": 1,
"maximum": 1000, "maximum": 1000,
"default": 500, "default": 500,
"type": "integer" "type": "number"
} }
}, },
{ {
@@ -9373,7 +9381,7 @@
], ],
"x-immich-state": "Stable", "x-immich-state": "Stable",
"schema": { "schema": {
"type": "integer", "type": "number",
"minimum": -1, "minimum": -1,
"maximum": 5, "maximum": 5,
"nullable": true "nullable": true
@@ -9387,7 +9395,7 @@
"schema": { "schema": {
"minimum": 1, "minimum": 1,
"maximum": 1000, "maximum": 1000,
"type": "integer" "type": "number"
} }
}, },
{ {
@@ -15325,6 +15333,10 @@
"description": "Activity feed enabled", "description": "Activity feed enabled",
"type": "boolean" "type": "boolean"
}, },
"isFavorite": {
"description": "Whether the authenticated user has favorited this album",
"type": "boolean"
},
"lastModifiedAssetTimestamp": { "lastModifiedAssetTimestamp": {
"description": "Last modified asset timestamp", "description": "Last modified asset timestamp",
"format": "date-time", "format": "date-time",
@@ -15358,6 +15370,7 @@
"hasSharedLink", "hasSharedLink",
"id", "id",
"isActivityEnabled", "isActivityEnabled",
"isFavorite",
"shared", "shared",
"updatedAt" "updatedAt"
], ],
@@ -15637,9 +15650,7 @@
}, },
"dateTimeRelative": { "dateTimeRelative": {
"description": "Relative time offset in seconds", "description": "Relative time offset in seconds",
"maximum": 9007199254740991, "type": "number"
"minimum": -9007199254740991,
"type": "integer"
}, },
"description": { "description": {
"description": "Asset description", "description": "Asset description",
@@ -16653,10 +16664,9 @@
}, },
"height": { "height": {
"description": "Asset height", "description": "Asset height",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"nullable": true, "nullable": true,
"type": "integer" "type": "number"
}, },
"id": { "id": {
"description": "Asset ID", "description": "Asset ID",
@@ -16799,10 +16809,9 @@
}, },
"width": { "width": {
"description": "Asset width", "description": "Asset width",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"nullable": true, "nullable": true,
"type": "integer" "type": "number"
} }
}, },
"required": [ "required": [
@@ -17219,27 +17228,23 @@
"properties": { "properties": {
"height": { "height": {
"description": "Height of the crop", "description": "Height of the crop",
"maximum": 9007199254740991,
"minimum": 1, "minimum": 1,
"type": "integer" "type": "number"
}, },
"width": { "width": {
"description": "Width of the crop", "description": "Width of the crop",
"maximum": 9007199254740991,
"minimum": 1, "minimum": 1,
"type": "integer" "type": "number"
}, },
"x": { "x": {
"description": "Top-Left X coordinate of crop", "description": "Top-Left X coordinate of crop",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"type": "integer" "type": "number"
}, },
"y": { "y": {
"description": "Top-Left Y coordinate of crop", "description": "Top-Left Y coordinate of crop",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"type": "integer" "type": "number"
} }
}, },
"required": [ "required": [
@@ -17263,9 +17268,8 @@
}, },
"keepLastAmount": { "keepLastAmount": {
"description": "Keep last amount", "description": "Keep last amount",
"maximum": 9007199254740991,
"minimum": 1, "minimum": 1,
"type": "integer" "type": "number"
} }
}, },
"required": [ "required": [
@@ -17298,9 +17302,7 @@
}, },
"filesize": { "filesize": {
"description": "Backup file size", "description": "Backup file size",
"maximum": 9007199254740991, "type": "number"
"minimum": -9007199254740991,
"type": "integer"
}, },
"timezone": { "timezone": {
"description": "Backup timezone", "description": "Backup timezone",
@@ -17639,18 +17641,16 @@
"exifImageHeight": { "exifImageHeight": {
"default": null, "default": null,
"description": "Image height in pixels", "description": "Image height in pixels",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"nullable": true, "nullable": true,
"type": "integer" "type": "number"
}, },
"exifImageWidth": { "exifImageWidth": {
"default": null, "default": null,
"description": "Image width in pixels", "description": "Image width in pixels",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"nullable": true, "nullable": true,
"type": "integer" "type": "number"
}, },
"exposureTime": { "exposureTime": {
"default": null, "default": null,
@@ -17681,10 +17681,8 @@
"iso": { "iso": {
"default": null, "default": null,
"description": "ISO sensitivity", "description": "ISO sensitivity",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"nullable": true, "nullable": true,
"type": "integer" "type": "number"
}, },
"latitude": { "latitude": {
"default": null, "default": null,
@@ -17738,10 +17736,8 @@
"rating": { "rating": {
"default": null, "default": null,
"description": "Rating", "description": "Rating",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"nullable": true, "nullable": true,
"type": "integer" "type": "number"
}, },
"state": { "state": {
"default": null, "default": null,
@@ -18168,14 +18164,10 @@
"type": "boolean" "type": "boolean"
}, },
"interval": { "interval": {
"maximum": 9007199254740991, "type": "number"
"minimum": -9007199254740991,
"type": "integer"
}, },
"timeout": { "timeout": {
"maximum": 9007199254740991, "type": "number"
"minimum": -9007199254740991,
"type": "integer"
} }
}, },
"required": [ "required": [
@@ -18225,9 +18217,7 @@
"properties": { "properties": {
"files": { "files": {
"description": "Number of files in the folder", "description": "Number of files in the folder",
"maximum": 9007199254740991, "type": "number"
"minimum": -9007199254740991,
"type": "integer"
}, },
"folder": { "folder": {
"$ref": "#/components/schemas/StorageFolder" "$ref": "#/components/schemas/StorageFolder"
@@ -18270,9 +18260,7 @@
"type": "string" "type": "string"
}, },
"progress": { "progress": {
"maximum": 9007199254740991, "type": "number"
"minimum": -9007199254740991,
"type": "integer"
}, },
"task": { "task": {
"type": "string" "type": "string"
@@ -18749,9 +18737,8 @@
}, },
"page": { "page": {
"description": "Page number", "description": "Page number",
"maximum": 9007199254740991,
"minimum": 1, "minimum": 1,
"type": "integer" "type": "number"
}, },
"personIds": { "personIds": {
"description": "Filter by person IDs", "description": "Filter by person IDs",
@@ -18771,7 +18758,7 @@
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
"nullable": true, "nullable": true,
"type": "integer", "type": "number",
"x-immich-history": [ "x-immich-history": [
{ {
"version": "v1", "version": "v1",
@@ -18793,7 +18780,7 @@
"description": "Number of results to return", "description": "Number of results to return",
"maximum": 1000, "maximum": 1000,
"minimum": 1, "minimum": 1,
"type": "integer" "type": "number"
}, },
"state": { "state": {
"description": "Filter by state/province name", "description": "Filter by state/province name",
@@ -20624,7 +20611,7 @@
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
"nullable": true, "nullable": true,
"type": "integer", "type": "number",
"x-immich-history": [ "x-immich-history": [
{ {
"version": "v1", "version": "v1",
@@ -20646,7 +20633,7 @@
"description": "Number of results to return", "description": "Number of results to return",
"maximum": 1000, "maximum": 1000,
"minimum": 1, "minimum": 1,
"type": "integer" "type": "number"
}, },
"state": { "state": {
"description": "Filter by state/province name", "description": "Filter by state/province name",
@@ -21464,9 +21451,8 @@
}, },
"duration": { "duration": {
"description": "Session duration in seconds", "description": "Session duration in seconds",
"maximum": 9007199254740991,
"minimum": 1, "minimum": 1,
"type": "integer" "type": "number"
} }
}, },
"type": "object" "type": "object"
@@ -21980,9 +21966,8 @@
}, },
"page": { "page": {
"description": "Page number", "description": "Page number",
"maximum": 9007199254740991,
"minimum": 1, "minimum": 1,
"type": "integer" "type": "number"
}, },
"personIds": { "personIds": {
"description": "Filter by person IDs", "description": "Filter by person IDs",
@@ -22008,7 +21993,7 @@
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
"nullable": true, "nullable": true,
"type": "integer", "type": "number",
"x-immich-history": [ "x-immich-history": [
{ {
"version": "v1", "version": "v1",
@@ -22030,7 +22015,7 @@
"description": "Number of results to return", "description": "Number of results to return",
"maximum": 1000, "maximum": 1000,
"minimum": 1, "minimum": 1,
"type": "integer" "type": "number"
}, },
"state": { "state": {
"description": "Filter by state/province name", "description": "Filter by state/province name",
@@ -22268,7 +22253,7 @@
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
"nullable": true, "nullable": true,
"type": "integer", "type": "number",
"x-immich-history": [ "x-immich-history": [
{ {
"version": "v1", "version": "v1",
@@ -22481,6 +22466,10 @@
"description": "Album ID", "description": "Album ID",
"type": "string" "type": "string"
}, },
"isFavorite": {
"description": "Favorite flag",
"type": "boolean"
},
"role": { "role": {
"$ref": "#/components/schemas/AlbumUserRole" "$ref": "#/components/schemas/AlbumUserRole"
}, },
@@ -22491,6 +22480,7 @@
}, },
"required": [ "required": [
"albumId", "albumId",
"isFavorite",
"role", "role",
"userId" "userId"
], ],
@@ -24400,10 +24390,9 @@
}, },
"defaultStorageQuota": { "defaultStorageQuota": {
"description": "Default storage quota", "description": "Default storage quota",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"nullable": true, "nullable": true,
"type": "integer" "type": "number"
}, },
"enabled": { "enabled": {
"description": "Enabled", "description": "Enabled",
@@ -24578,7 +24567,7 @@
"description": "SMTP server port", "description": "SMTP server port",
"maximum": 65535, "maximum": 65535,
"minimum": 0, "minimum": 0,
"type": "integer" "type": "number"
}, },
"secure": { "secure": {
"description": "Whether to use secure connection (TLS/SSL)", "description": "Whether to use secure connection (TLS/SSL)",
@@ -25232,13 +25221,14 @@
}, },
"UpdateAlbumUserDto": { "UpdateAlbumUserDto": {
"properties": { "properties": {
"isFavorite": {
"description": "Mark album as favorite for the user (only the user themselves can update)",
"type": "boolean"
},
"role": { "role": {
"$ref": "#/components/schemas/AlbumUserRole" "$ref": "#/components/schemas/AlbumUserRole"
} }
}, },
"required": [
"role"
],
"type": "object" "type": "object"
}, },
"UpdateAssetDto": { "UpdateAssetDto": {
@@ -25996,9 +25986,7 @@
}, },
"order": { "order": {
"description": "Action order", "description": "Action order",
"maximum": 9007199254740991, "type": "number"
"minimum": -9007199254740991,
"type": "integer"
}, },
"pluginActionId": { "pluginActionId": {
"description": "Plugin action ID", "description": "Plugin action ID",
@@ -26097,9 +26085,7 @@
}, },
"order": { "order": {
"description": "Filter order", "description": "Filter order",
"maximum": 9007199254740991, "type": "number"
"minimum": -9007199254740991,
"type": "integer"
}, },
"pluginFilterId": { "pluginFilterId": {
"description": "Plugin filter ID", "description": "Plugin filter ID",
+10 -2
View File
@@ -474,6 +474,8 @@ export type AlbumResponseDto = {
id: string; id: string;
/** Activity feed enabled */ /** Activity feed enabled */
isActivityEnabled: boolean; isActivityEnabled: boolean;
/** Whether the authenticated user has favorited this album */
isFavorite: boolean;
/** Last modified asset timestamp */ /** Last modified asset timestamp */
lastModifiedAssetTimestamp?: string; lastModifiedAssetTimestamp?: string;
order?: AssetOrder; order?: AssetOrder;
@@ -556,7 +558,9 @@ export type MapMarkerResponseDto = {
state: string | null; state: string | null;
}; };
export type UpdateAlbumUserDto = { export type UpdateAlbumUserDto = {
role: AlbumUserRole; /** Mark album as favorite for the user (only the user themselves can update) */
isFavorite?: boolean;
role?: AlbumUserRole;
}; };
export type AlbumUserAddDto = { export type AlbumUserAddDto = {
/** Album user role */ /** Album user role */
@@ -2856,6 +2860,8 @@ export type SyncAlbumUserDeleteV1 = {
export type SyncAlbumUserV1 = { export type SyncAlbumUserV1 = {
/** Album ID */ /** Album ID */
albumId: string; albumId: string;
/** Favorite flag */
isFavorite: boolean;
role: AlbumUserRole; role: AlbumUserRole;
/** User ID */ /** User ID */
userId: string; userId: string;
@@ -3619,8 +3625,9 @@ export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility }
/** /**
* List all albums * List all albums
*/ */
export function getAllAlbums({ assetId, shared }: { export function getAllAlbums({ assetId, favorite, shared }: {
assetId?: string; assetId?: string;
favorite?: boolean;
shared?: boolean; shared?: boolean;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
@@ -3628,6 +3635,7 @@ export function getAllAlbums({ assetId, shared }: {
data: AlbumResponseDto[]; data: AlbumResponseDto[];
}>(`/albums${QS.query(QS.explode({ }>(`/albums${QS.query(QS.explode({
assetId, assetId,
favorite,
shared shared
}))}`, { }))}`, {
...opts ...opts
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "2.7.5", "version": "2.7.5",
"description": "Monorepo for Immich", "description": "Monorepo for Immich",
"private": true, "private": true,
"packageManager": "pnpm@10.33.1+sha512.05ba3c1d5d1c18f68df06470d74055e62d41fc110a0c660db1b2dfb2785327f04cf0f68345d4609bc52089e7fa0343c31593b2f9594e2c5d5da426230acc9820", "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"engines": { "engines": {
"pnpm": ">=10.0.0" "pnpm": ">=10.0.0"
} }
+998 -960
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -46,15 +46,15 @@
"@nestjs/platform-express": "^11.0.4", "@nestjs/platform-express": "^11.0.4",
"@nestjs/platform-socket.io": "^11.0.4", "@nestjs/platform-socket.io": "^11.0.4",
"@nestjs/schedule": "^6.0.0", "@nestjs/schedule": "^6.0.0",
"@nestjs/swagger": "^11.4.2", "@nestjs/swagger": "11.2.6",
"@nestjs/websockets": "^11.0.4", "@nestjs/websockets": "^11.0.4",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.215.0", "@opentelemetry/exporter-prometheus": "^0.215.0",
"@opentelemetry/instrumentation-http": "^0.215.0", "@opentelemetry/instrumentation-http": "^0.215.0",
"@opentelemetry/instrumentation-ioredis": "^0.63.0", "@opentelemetry/instrumentation-ioredis": "^0.62.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.61.0", "@opentelemetry/instrumentation-nestjs-core": "^0.60.0",
"@opentelemetry/instrumentation-pg": "^0.67.0", "@opentelemetry/instrumentation-pg": "^0.66.0",
"@opentelemetry/resources": "^2.0.1", "@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1", "@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.215.0", "@opentelemetry/sdk-node": "^0.215.0",
@@ -49,7 +49,7 @@ describe(SearchController.name, () => {
}); });
it('should reject an invalid size', async () => { it('should reject an invalid size', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1 }); const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1.5 });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[size] Too small: expected number to be >=1'])); expect(body).toEqual(errorDto.badRequest(['[size] Too small: expected number to be >=1']));
}); });
+7 -1
View File
@@ -35,6 +35,7 @@ export type AuthUser = {
export type AlbumUser = { export type AlbumUser = {
user: ShallowDehydrateObject<User>; user: ShallowDehydrateObject<User>;
role: AlbumUserRole; role: AlbumUserRole;
isFavorite: boolean;
}; };
export type AssetFile = { export type AssetFile = {
@@ -395,7 +396,12 @@ export const columns = {
'asset.height', 'asset.height',
'asset.isEdited', 'asset.isEdited',
], ],
syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'], syncAlbumUser: [
'album_user.albumId as albumId',
'album_user.userId as userId',
'album_user.role',
'album_user.isFavorite',
],
syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'], syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],
syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'], syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'],
stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'], stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'],
+13 -2
View File
@@ -69,6 +69,7 @@ const GetAlbumsSchema = z
.optional() .optional()
.describe('Filter by shared status: true = only shared, false = not shared, undefined = all owned albums'), .describe('Filter by shared status: true = only shared, false = not shared, undefined = all owned albums'),
assetId: z.uuidv4().optional().describe('Filter albums containing this asset ID (ignores shared parameter)'), assetId: z.uuidv4().optional().describe('Filter albums containing this asset ID (ignores shared parameter)'),
favorite: stringToBool.optional().describe('Filter to only albums favorited by the authenticated user'),
}) })
.meta({ id: 'GetAlbumsDto' }); .meta({ id: 'GetAlbumsDto' });
@@ -82,7 +83,11 @@ const AlbumStatisticsResponseSchema = z
const UpdateAlbumUserSchema = z const UpdateAlbumUserSchema = z
.object({ .object({
role: AlbumUserRoleSchema, role: AlbumUserRoleSchema.optional(),
isFavorite: z
.boolean()
.optional()
.describe('Mark album as favorite for the user (only the user themselves can update)'),
}) })
.meta({ id: 'UpdateAlbumUserDto' }); .meta({ id: 'UpdateAlbumUserDto' });
@@ -118,6 +123,7 @@ export const AlbumResponseSchema = z
'First entry is always the album owner. Second entry is the auth user, if it differs from the owner. The rest are ordered alphabetically.', 'First entry is always the album owner. Second entry is the auth user, if it differs from the owner. The rest are ordered alphabetically.',
), ),
hasSharedLink: z.boolean().describe('Has shared link'), hasSharedLink: z.boolean().describe('Has shared link'),
isFavorite: z.boolean().describe('Whether the authenticated user has favorited this album'),
assetCount: z.int().min(0).describe('Number of assets'), assetCount: z.int().min(0).describe('Number of assets'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
lastModifiedAssetTimestamp: z lastModifiedAssetTimestamp: z
@@ -161,8 +167,9 @@ export type MapAlbumDto = {
order: AssetOrder; order: AssetOrder;
}; };
export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto => { export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>, authUserId?: string): AlbumResponseDto => {
const albumUsers: AlbumUserResponseDto[] = []; const albumUsers: AlbumUserResponseDto[] = [];
let isFavorite = false;
if (entity.albumUsers) { if (entity.albumUsers) {
for (const albumUser of entity.albumUsers) { for (const albumUser of entity.albumUsers) {
@@ -171,6 +178,9 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
user, user,
role: albumUser.role, role: albumUser.role,
}); });
if (authUserId && user.id === authUserId) {
isFavorite = albumUser.isFavorite ?? false;
}
} }
} }
@@ -196,6 +206,7 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
albumUsers, albumUsers,
shared: hasSharedUser || hasSharedLink, shared: hasSharedUser || hasSharedLink,
hasSharedLink, hasSharedLink,
isFavorite,
startDate: asDateString(startDate), startDate: asDateString(startDate),
endDate: asDateString(endDate), endDate: asDateString(endDate),
assetCount: entity.assets?.length || 0, assetCount: entity.assets?.length || 0,
+2 -2
View File
@@ -50,8 +50,8 @@ const SanitizedAssetResponseSchema = z
duration: z.string().nullable().describe('Video/gif duration in hh:mm:ss.SSS format (null for static images)'), duration: z.string().nullable().describe('Video/gif duration in hh:mm:ss.SSS format (null for static images)'),
livePhotoVideoId: z.string().nullish().describe('Live photo video ID'), livePhotoVideoId: z.string().nullish().describe('Live photo video ID'),
hasMetadata: z.boolean().describe('Whether asset has metadata'), hasMetadata: z.boolean().describe('Whether asset has metadata'),
width: z.int().min(0).nullable().describe('Asset width'), width: z.number().min(0).nullable().describe('Asset width'),
height: z.int().min(0).nullable().describe('Asset height'), height: z.number().min(0).nullable().describe('Asset height'),
}) })
.meta({ id: 'SanitizedAssetResponseDto' }); .meta({ id: 'SanitizedAssetResponseDto' });
+1 -1
View File
@@ -40,7 +40,7 @@ const UpdateAssetBaseSchema = z
const AssetBulkUpdateBaseSchema = UpdateAssetBaseSchema.extend({ const AssetBulkUpdateBaseSchema = UpdateAssetBaseSchema.extend({
ids: z.array(z.uuidv4()).describe('Asset IDs to update'), ids: z.array(z.uuidv4()).describe('Asset IDs to update'),
duplicateId: z.string().nullish().describe('Duplicate ID'), duplicateId: z.string().nullish().describe('Duplicate ID'),
dateTimeRelative: z.int().optional().describe('Relative time offset in seconds'), dateTimeRelative: z.number().optional().describe('Relative time offset in seconds'),
timeZone: z.string().optional().describe('Time zone (IANA timezone)'), timeZone: z.string().optional().describe('Time zone (IANA timezone)'),
}); });
+1 -1
View File
@@ -4,7 +4,7 @@ import z from 'zod';
const DatabaseBackupSchema = z const DatabaseBackupSchema = z
.object({ .object({
filename: z.string().describe('Backup filename'), filename: z.string().describe('Backup filename'),
filesize: z.int().describe('Backup file size'), filesize: z.number().describe('Backup file size'),
timezone: z.string().describe('Backup timezone'), timezone: z.string().describe('Backup timezone'),
}) })
.meta({ id: 'DatabaseBackupDto' }); .meta({ id: 'DatabaseBackupDto' });
+4 -4
View File
@@ -21,10 +21,10 @@ const MirrorAxisSchema = z.enum(['horizontal', 'vertical']).describe('Axis to mi
const CropParametersSchema = z const CropParametersSchema = z
.object({ .object({
x: z.int().min(0).describe('Top-Left X coordinate of crop'), x: z.number().min(0).describe('Top-Left X coordinate of crop'),
y: z.int().min(0).describe('Top-Left Y coordinate of crop'), y: z.number().min(0).describe('Top-Left Y coordinate of crop'),
width: z.int().min(1).describe('Width of the crop'), width: z.number().min(1).describe('Width of the crop'),
height: z.int().min(1).describe('Height of the crop'), height: z.number().min(1).describe('Height of the crop'),
}) })
.meta({ id: 'CropParameters' }); .meta({ id: 'CropParameters' });
+4 -4
View File
@@ -8,8 +8,8 @@ export const ExifResponseSchema = z
.object({ .object({
make: z.string().nullish().default(null).describe('Camera make'), make: z.string().nullish().default(null).describe('Camera make'),
model: z.string().nullish().default(null).describe('Camera model'), model: z.string().nullish().default(null).describe('Camera model'),
exifImageWidth: z.int().min(0).nullish().default(null).describe('Image width in pixels'), exifImageWidth: z.number().min(0).nullish().default(null).describe('Image width in pixels'),
exifImageHeight: z.int().min(0).nullish().default(null).describe('Image height in pixels'), exifImageHeight: z.number().min(0).nullish().default(null).describe('Image height in pixels'),
fileSizeInByte: z.int().min(0).nullish().default(null).describe('File size in bytes'), fileSizeInByte: z.int().min(0).nullish().default(null).describe('File size in bytes'),
orientation: z.string().nullish().default(null).describe('Image orientation'), orientation: z.string().nullish().default(null).describe('Image orientation'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. // TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
@@ -20,7 +20,7 @@ export const ExifResponseSchema = z
lensModel: z.string().nullish().default(null).describe('Lens model'), lensModel: z.string().nullish().default(null).describe('Lens model'),
fNumber: z.number().nullish().default(null).describe('F-number (aperture)'), fNumber: z.number().nullish().default(null).describe('F-number (aperture)'),
focalLength: z.number().nullish().default(null).describe('Focal length in mm'), focalLength: z.number().nullish().default(null).describe('Focal length in mm'),
iso: z.int().nullish().default(null).describe('ISO sensitivity'), iso: z.number().nullish().default(null).describe('ISO sensitivity'),
exposureTime: z.string().nullish().default(null).describe('Exposure time'), exposureTime: z.string().nullish().default(null).describe('Exposure time'),
latitude: z.number().nullish().default(null).describe('GPS latitude'), latitude: z.number().nullish().default(null).describe('GPS latitude'),
longitude: z.number().nullish().default(null).describe('GPS longitude'), longitude: z.number().nullish().default(null).describe('GPS longitude'),
@@ -29,7 +29,7 @@ export const ExifResponseSchema = z
country: z.string().nullish().default(null).describe('Country name'), country: z.string().nullish().default(null).describe('Country name'),
description: z.string().nullish().default(null).describe('Image description'), description: z.string().nullish().default(null).describe('Image description'),
projectionType: z.string().nullish().default(null).describe('Projection type'), projectionType: z.string().nullish().default(null).describe('Projection type'),
rating: z.int().nullish().default(null).describe('Rating'), rating: z.number().nullish().default(null).describe('Rating'),
}) })
.describe('EXIF response') .describe('EXIF response')
.meta({ id: 'ExifResponseDto' }); .meta({ id: 'ExifResponseDto' });
+2 -2
View File
@@ -29,7 +29,7 @@ const MaintenanceStatusResponseSchema = z
.object({ .object({
active: z.boolean(), active: z.boolean(),
action: MaintenanceActionSchema, action: MaintenanceActionSchema,
progress: z.int().optional(), progress: z.number().optional(),
task: z.string().optional(), task: z.string().optional(),
error: z.string().optional(), error: z.string().optional(),
}) })
@@ -40,7 +40,7 @@ const MaintenanceDetectInstallStorageFolderSchema = z
folder: StorageFolderSchema, folder: StorageFolderSchema,
readable: z.boolean().describe('Whether the folder is readable'), readable: z.boolean().describe('Whether the folder is readable'),
writable: z.boolean().describe('Whether the folder is writable'), writable: z.boolean().describe('Whether the folder is writable'),
files: z.int().describe('Number of files in the folder'), files: z.number().describe('Number of files in the folder'),
}) })
.meta({ id: 'MaintenanceDetectInstallStorageFolderDto' }); .meta({ id: 'MaintenanceDetectInstallStorageFolderDto' });
+2 -2
View File
@@ -51,8 +51,8 @@ const PersonSearchSchema = z
withHidden: stringToBool.optional().describe('Include hidden people'), withHidden: stringToBool.optional().describe('Include hidden people'),
closestPersonId: z.uuidv4().optional().describe('Closest person ID for similarity search'), closestPersonId: z.uuidv4().optional().describe('Closest person ID for similarity search'),
closestAssetId: z.uuidv4().optional().describe('Closest asset ID for similarity search'), closestAssetId: z.uuidv4().optional().describe('Closest asset ID for similarity search'),
page: z.coerce.number().int().min(1).default(1).describe('Page number for pagination'), page: z.coerce.number().min(1).default(1).describe('Page number for pagination'),
size: z.coerce.number().int().min(1).max(1000).default(500).describe('Number of items per page'), size: z.coerce.number().min(1).max(1000).default(500).describe('Number of items per page'),
}) })
.meta({ id: 'PersonSearchDto' }); .meta({ id: 'PersonSearchDto' });
+5 -5
View File
@@ -34,7 +34,7 @@ const BaseSearchSchema = z.object({
tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'), tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'),
albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'), albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'),
rating: z rating: z
.int() .number()
.min(-1) .min(-1)
.max(5) .max(5)
.nullish() .nullish()
@@ -52,7 +52,7 @@ const BaseSearchSchema = z.object({
const BaseSearchWithResultsSchema = BaseSearchSchema.extend({ const BaseSearchWithResultsSchema = BaseSearchSchema.extend({
withDeleted: z.boolean().optional().describe('Include deleted assets'), withDeleted: z.boolean().optional().describe('Include deleted assets'),
withExif: z.boolean().optional().describe('Include EXIF data in response'), withExif: z.boolean().optional().describe('Include EXIF data in response'),
size: z.int().min(1).max(1000).optional().describe('Number of results to return'), size: z.number().min(1).max(1000).optional().describe('Number of results to return'),
}); });
const RandomSearchSchema = BaseSearchWithResultsSchema.extend({ const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
@@ -62,7 +62,7 @@ const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
const LargeAssetSearchSchema = BaseSearchWithResultsSchema.extend({ const LargeAssetSearchSchema = BaseSearchWithResultsSchema.extend({
minFileSize: z.coerce.number().int().min(0).optional().describe('Minimum file size in bytes'), minFileSize: z.coerce.number().int().min(0).optional().describe('Minimum file size in bytes'),
size: z.coerce.number().int().min(1).max(1000).optional().describe('Number of results to return'), size: z.coerce.number().min(1).max(1000).optional().describe('Number of results to return'),
}).meta({ id: 'LargeAssetSearchDto' }); }).meta({ id: 'LargeAssetSearchDto' });
const MetadataSearchSchema = RandomSearchSchema.extend({ const MetadataSearchSchema = RandomSearchSchema.extend({
@@ -75,7 +75,7 @@ const MetadataSearchSchema = RandomSearchSchema.extend({
thumbnailPath: z.string().optional().describe('Filter by thumbnail file path'), thumbnailPath: z.string().optional().describe('Filter by thumbnail file path'),
encodedVideoPath: z.string().optional().describe('Filter by encoded video file path'), encodedVideoPath: z.string().optional().describe('Filter by encoded video file path'),
order: AssetOrderSchema.default(AssetOrder.Desc).optional().describe('Sort order'), order: AssetOrderSchema.default(AssetOrder.Desc).optional().describe('Sort order'),
page: z.int().min(1).optional().describe('Page number'), page: z.number().min(1).optional().describe('Page number'),
}).meta({ id: 'MetadataSearchDto' }); }).meta({ id: 'MetadataSearchDto' });
const StatisticsSearchSchema = BaseSearchSchema.extend({ const StatisticsSearchSchema = BaseSearchSchema.extend({
@@ -86,7 +86,7 @@ const SmartSearchSchema = BaseSearchWithResultsSchema.extend({
query: z.string().trim().optional().describe('Natural language search query'), query: z.string().trim().optional().describe('Natural language search query'),
queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'), queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'),
language: z.string().optional().describe('Search language code'), language: z.string().optional().describe('Search language code'),
page: z.int().min(1).optional().describe('Page number'), page: z.number().min(1).optional().describe('Page number'),
}).meta({ id: 'SmartSearchDto' }); }).meta({ id: 'SmartSearchDto' });
const SearchPlacesSchema = z const SearchPlacesSchema = z
+1 -1
View File
@@ -4,7 +4,7 @@ import z from 'zod';
const SessionCreateSchema = z const SessionCreateSchema = z
.object({ .object({
duration: z.int().min(1).optional().describe('Session duration in seconds'), duration: z.number().min(1).optional().describe('Session duration in seconds'),
deviceType: z.string().optional().describe('Device type'), deviceType: z.string().optional().describe('Device type'),
deviceOS: z.string().optional().describe('Device OS'), deviceOS: z.string().optional().describe('Device OS'),
}) })
+1
View File
@@ -195,6 +195,7 @@ const SyncAlbumUserV1Schema = z
albumId: z.string().describe('Album ID'), albumId: z.string().describe('Album ID'),
userId: z.string().describe('User ID'), userId: z.string().describe('User ID'),
role: AlbumUserRoleSchema, role: AlbumUserRoleSchema,
isFavorite: z.boolean().describe('Favorite flag'),
}) })
.meta({ id: 'SyncAlbumUserV1' }); .meta({ id: 'SyncAlbumUserV1' });
+5 -5
View File
@@ -51,7 +51,7 @@ const DatabaseBackupSchema = z
.object({ .object({
enabled: configBool.describe('Enabled'), enabled: configBool.describe('Enabled'),
cronExpression: cronExpressionSchema, cronExpression: cronExpressionSchema,
keepLastAmount: z.int().min(1).describe('Keep last amount'), keepLastAmount: z.number().min(1).describe('Keep last amount'),
}) })
.meta({ id: 'DatabaseBackupConfig' }); .meta({ id: 'DatabaseBackupConfig' });
@@ -130,8 +130,8 @@ const SystemConfigLoggingSchema = z
const MachineLearningAvailabilityChecksSchema = z const MachineLearningAvailabilityChecksSchema = z
.object({ .object({
enabled: configBool.describe('Enabled'), enabled: configBool.describe('Enabled'),
timeout: z.int(), timeout: z.number(),
interval: z.int(), interval: z.number(),
}) })
.meta({ id: 'MachineLearningAvailabilityChecksDto' }); .meta({ id: 'MachineLearningAvailabilityChecksDto' });
@@ -180,7 +180,7 @@ const SystemConfigOAuthSchema = z
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethodSchema, tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethodSchema,
timeout: z.int().min(1).describe('Timeout'), timeout: z.int().min(1).describe('Timeout'),
allowInsecureRequests: configBool.describe('Allow insecure requests'), allowInsecureRequests: configBool.describe('Allow insecure requests'),
defaultStorageQuota: z.int().min(0).nullable().describe('Default storage quota'), defaultStorageQuota: z.number().min(0).nullable().describe('Default storage quota'),
enabled: configBool.describe('Enabled'), enabled: configBool.describe('Enabled'),
issuerUrl: z issuerUrl: z
.string() .string()
@@ -254,7 +254,7 @@ const SystemConfigSmtpTransportSchema = z
.object({ .object({
ignoreCert: configBool.describe('Whether to ignore SSL certificate errors'), ignoreCert: configBool.describe('Whether to ignore SSL certificate errors'),
host: z.string().describe('SMTP server hostname'), host: z.string().describe('SMTP server hostname'),
port: z.int().min(0).max(65_535).describe('SMTP server port'), port: z.number().min(0).max(65_535).describe('SMTP server port'),
secure: configBool.describe('Whether to use secure connection (TLS/SSL)'), secure: configBool.describe('Whether to use secure connection (TLS/SSL)'),
username: z.string().describe('SMTP username'), username: z.string().describe('SMTP username'),
password: z.string().describe('SMTP password'), password: z.string().describe('SMTP password'),
+2 -2
View File
@@ -46,7 +46,7 @@ const WorkflowFilterResponseSchema = z
workflowId: z.string().describe('Workflow ID'), workflowId: z.string().describe('Workflow ID'),
pluginFilterId: z.string().describe('Plugin filter ID'), pluginFilterId: z.string().describe('Plugin filter ID'),
filterConfig: FilterConfigSchema.nullable(), filterConfig: FilterConfigSchema.nullable(),
order: z.int().describe('Filter order'), order: z.number().describe('Filter order'),
}) })
.meta({ id: 'WorkflowFilterResponseDto' }); .meta({ id: 'WorkflowFilterResponseDto' });
@@ -56,7 +56,7 @@ const WorkflowActionResponseSchema = z
workflowId: z.string().describe('Workflow ID'), workflowId: z.string().describe('Workflow ID'),
pluginActionId: z.string().describe('Plugin action ID'), pluginActionId: z.string().describe('Plugin action ID'),
actionConfig: ActionConfigSchema.nullable(), actionConfig: ActionConfigSchema.nullable(),
order: z.int().describe('Action order'), order: z.number().describe('Action order'),
}) })
.meta({ id: 'WorkflowActionResponseDto' }); .meta({ id: 'WorkflowActionResponseDto' });
+1 -7
View File
@@ -22,7 +22,7 @@ export enum ImmichHeader {
SharedLinkKey = 'x-immich-share-key', SharedLinkKey = 'x-immich-share-key',
SharedLinkSlug = 'x-immich-share-slug', SharedLinkSlug = 'x-immich-share-slug',
Checksum = 'x-immich-checksum', Checksum = 'x-immich-checksum',
CorrelationId = 'X-Correlation-ID', Cid = 'x-immich-cid',
} }
export enum ImmichQuery { export enum ImmichQuery {
@@ -445,12 +445,6 @@ export enum VideoCodec {
export const VideoCodecSchema = z.enum(VideoCodec).describe('Target video codec').meta({ id: 'VideoCodec' }); export const VideoCodecSchema = z.enum(VideoCodec).describe('Target video codec').meta({ id: 'VideoCodec' });
export enum VideoSegmentCodec {
Av1 = 'av1',
Hevc = 'hevc',
H264 = 'h264',
}
export enum AudioCodec { export enum AudioCodec {
Mp3 = 'mp3', Mp3 = 'mp3',
Aac = 'aac', Aac = 'aac',
@@ -2,7 +2,6 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/co
import { Response } from 'express'; import { Response } from 'express';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import { ZodSerializationException, ZodValidationException } from 'nestjs-zod'; import { ZodSerializationException, ZodValidationException } from 'nestjs-zod';
import { ImmichHeader } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { logGlobalError } from 'src/utils/logger'; import { logGlobalError } from 'src/utils/logger';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
@@ -17,13 +16,18 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
} }
catch(error: Error, host: ArgumentsHost) { catch(error: Error, host: ArgumentsHost) {
this.handleError(host.switchToHttp().getResponse<Response>(), error); const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const { status, body } = this.fromError(error);
if (!response.headersSent) {
response.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
}
} }
handleError(res: Response, error: Error) { handleError(res: Response, error: Error) {
const { status, body } = this.fromError(error); const { status, body } = this.fromError(error);
if (!res.headersSent) { if (!res.headersSent) {
res.header(ImmichHeader.CorrelationId, this.cls.getId()).status(status).json(body); res.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
} }
} }
@@ -32,24 +36,26 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
if (error instanceof HttpException) { if (error instanceof HttpException) {
const status = error.getStatus(); const status = error.getStatus();
const response = error.getResponse(); let body = error.getResponse();
const body: Record<string, unknown> =
typeof response === 'string' ? { message: response } : { ...(response as object) }; // unclear what circumstances would return a string
if (typeof body === 'string') {
body = { message: body };
}
// handle both request and response validation errors // handle both request and response validation errors
if (error instanceof ZodValidationException || error instanceof ZodSerializationException) { if (error instanceof ZodValidationException || error instanceof ZodSerializationException) {
const zodError = error.getZodError(); const zodError = error.getZodError();
if (zodError instanceof ZodError && zodError.issues.length > 0) { if (zodError instanceof ZodError && zodError.issues.length > 0) {
body['message'] = zodError.issues.map((issue) => body = {
issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message, message: zodError.issues.map((issue) =>
); issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message,
),
error: 'Bad Request',
};
} }
} }
// remove fields that duplicate the HTTP response line or will be reformatted in a later step
delete body['error'];
delete body['statusCode'];
delete body['errors'];
return { status, body }; return { status, body };
} }
+72 -1
View File
@@ -19,6 +19,7 @@ select
( (
select select
"album_user"."role", "album_user"."role",
"album_user"."isFavorite",
( (
select select
to_json(obj) to_json(obj)
@@ -98,6 +99,7 @@ select
( (
select select
"album_user"."role", "album_user"."role",
"album_user"."isFavorite",
( (
select select
to_json(obj) to_json(obj)
@@ -195,6 +197,7 @@ select
( (
select select
"album_user"."role", "album_user"."role",
"album_user"."isFavorite",
( (
select select
to_json(obj) to_json(obj)
@@ -258,6 +261,7 @@ select
( (
select select
"album_user"."role", "album_user"."role",
"album_user"."isFavorite",
( (
select select
to_json(obj) to_json(obj)
@@ -334,6 +338,70 @@ where
order by order by
"album"."createdAt" desc "album"."createdAt" desc
-- AlbumRepository.getFavorites
select
"album".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"album_user"."role",
"album_user"."isFavorite",
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $1 desc,
"user"."name" asc
) as agg
) as "albumUsers",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"shared_link".*
from
"shared_link"
where
"shared_link"."albumId" = "album"."id"
) as agg
) as "sharedLinks"
from
"album"
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
and "album_user"."isFavorite" = true
where
"album"."deletedAt" is null
order by
"album"."createdAt" desc
-- AlbumRepository.getNotShared -- AlbumRepository.getNotShared
select select
"album".*, "album".*,
@@ -357,6 +425,7 @@ select
( (
select select
"album_user"."role", "album_user"."role",
"album_user"."isFavorite",
( (
select select
to_json(obj) to_json(obj)
@@ -461,7 +530,8 @@ with
returning returning
"album_user"."albumId", "album_user"."albumId",
"album_user"."userId", "album_user"."userId",
"album_user"."role" "album_user"."role",
"album_user"."isFavorite"
), ),
"album_asset" as ( "album_asset" as (
insert into insert into
@@ -485,6 +555,7 @@ select
( (
select select
"album_user"."role", "album_user"."role",
"album_user"."isFavorite",
( (
select select
to_json(obj) to_json(obj)
+2
View File
@@ -331,6 +331,7 @@ select
"album_user"."albumId" as "albumId", "album_user"."albumId" as "albumId",
"album_user"."userId" as "userId", "album_user"."userId" as "userId",
"album_user"."role", "album_user"."role",
"album_user"."isFavorite",
"album_user"."updateId" "album_user"."updateId"
from from
"album_user" as "album_user" "album_user" as "album_user"
@@ -368,6 +369,7 @@ select
"album_user"."albumId" as "albumId", "album_user"."albumId" as "albumId",
"album_user"."userId" as "userId", "album_user"."userId" as "userId",
"album_user"."role", "album_user"."role",
"album_user"."isFavorite",
"album_user"."updateId" "album_user"."updateId"
from from
"album_user" as "album_user" "album_user" as "album_user"
@@ -1,46 +0,0 @@
-- NOTE: This file is auto generated by ./sql-generator
-- VideoStreamRepository.getSession
select
*
from
"video_stream_session"
where
"id" = $1
-- VideoStreamRepository.getVariant
select
*
from
"video_stream_variant"
where
"id" = $1
-- VideoStreamRepository.getSegment
select
*
from
"video_stream_segment"
where
"variantId" = $1
and "index" = $2
-- VideoStreamRepository.getExpiredSessions
select
"id"
from
"video_stream_session"
where
"expiresAt" <= $1
-- VideoStreamRepository.extendSession
update "video_stream_session"
set
"expiresAt" = $1
where
"id" = $2
-- VideoStreamRepository.deleteSession
delete from "video_stream_session"
where
"id" = $1
+23 -1
View File
@@ -39,6 +39,7 @@ const withAlbumUsers = (authUserId?: string) => (eb: ExpressionBuilder<DB, 'albu
.innerJoin('user', 'user.id', 'album_user.userId') .innerJoin('user', 'user.id', 'album_user.userId')
.whereRef('album_user.albumId', '=', 'album.id') .whereRef('album_user.albumId', '=', 'album.id')
.select('album_user.role') .select('album_user.role')
.select('album_user.isFavorite')
.select((eb) => jsonObjectFrom(eb.selectFrom(dummy).select(columns.user)).$notNull().as('user')) .select((eb) => jsonObjectFrom(eb.selectFrom(dummy).select(columns.user)).$notNull().as('user'))
.orderBy('album_user.role') .orderBy('album_user.role')
.$if(!!authUserId, (qb) => qb.orderBy((eb) => eb('album_user.userId', '=', authUserId!), 'desc')) .$if(!!authUserId, (qb) => qb.orderBy((eb) => eb('album_user.userId', '=', authUserId!), 'desc'))
@@ -244,6 +245,27 @@ export class AlbumRepository {
.execute(); .execute();
} }
/**
* Get albums the user has favorited (owned or shared).
*/
@GenerateSql({ params: [DummyValue.UUID] })
getFavorites(userId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.innerJoin('album_user', (join) =>
join
.onRef('album_user.albumId', '=', 'album.id')
.on('album_user.userId', '=', userId)
.on('album_user.isFavorite', '=', sql.lit(true)),
)
.where('album.deletedAt', 'is', null)
.select(withAlbumUsers(userId))
.select(withSharedLink)
.orderBy('album.createdAt', 'desc')
.execute();
}
/** /**
* Get albums of owner that are _not_ shared * Get albums of owner that are _not_ shared
*/ */
@@ -380,7 +402,7 @@ export class AlbumRepository {
sql`unnest(${roles}::album_user_role_enum[])`.as('role'), sql`unnest(${roles}::album_user_role_enum[])`.as('role'),
]), ]),
) )
.returning(['album_user.albumId', 'album_user.userId', 'album_user.role']), .returning(['album_user.albumId', 'album_user.userId', 'album_user.role', 'album_user.isFavorite']),
) )
.with('album_asset', (db) => .with('album_asset', (db) =>
db db
+4 -2
View File
@@ -301,9 +301,11 @@ const getEnv = (): EnvData => {
mount: true, mount: true,
generateId: true, generateId: true,
setup: (cls, req: Request, res: Response) => { setup: (cls, req: Request, res: Response) => {
const cid = req.header(ImmichHeader.CorrelationId) || cls.get(CLS_ID); const headerValues = req.headers[ImmichHeader.Cid];
const headerValue = Array.isArray(headerValues) ? headerValues[0] : headerValues;
const cid = headerValue || cls.get(CLS_ID);
cls.set(CLS_ID, cid); cls.set(CLS_ID, cid);
res.header(ImmichHeader.CorrelationId, cid); res.header(ImmichHeader.Cid, cid);
}, },
}, },
}, },
-2
View File
@@ -46,7 +46,6 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { TrashRepository } from 'src/repositories/trash.repository'; import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository'; import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { VideoStreamRepository } from 'src/repositories/video-stream.repository';
import { ViewRepository } from 'src/repositories/view-repository'; import { ViewRepository } from 'src/repositories/view-repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository'; import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository'; import { WorkflowRepository } from 'src/repositories/workflow.repository';
@@ -101,7 +100,6 @@ export const repositories = [
UserRepository, UserRepository,
ViewRepository, ViewRepository,
VersionHistoryRepository, VersionHistoryRepository,
VideoStreamRepository,
WebsocketRepository, WebsocketRepository,
WorkflowRepository, WorkflowRepository,
]; ];
@@ -1,62 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DummyValue, GenerateSql } from 'src/decorators';
import { DB } from 'src/schema';
import {
VideoStreamSegmentTable,
VideoStreamSessionTable,
VideoStreamVariantTable,
} from 'src/schema/tables/video-stream.table';
@Injectable()
export class VideoStreamRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
createSession(session: Insertable<VideoStreamSessionTable>) {
return this.db.insertInto('video_stream_session').values(session).returning(['id']).executeTakeFirstOrThrow();
}
createVariant(variant: Insertable<VideoStreamVariantTable>) {
return this.db.insertInto('video_stream_variant').values(variant).returning(['id']).executeTakeFirstOrThrow();
}
async createSegment(segment: Insertable<VideoStreamSegmentTable>) {
await this.db.insertInto('video_stream_segment').values(segment).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getSession(id: string) {
return this.db.selectFrom('video_stream_session').selectAll().where('id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getVariant(id: string) {
return this.db.selectFrom('video_stream_variant').selectAll().where('id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] })
getSegment(variantId: string, index: number) {
return this.db
.selectFrom('video_stream_segment')
.selectAll()
.where('variantId', '=', variantId)
.where('index', '=', index)
.executeTakeFirst();
}
@GenerateSql()
getExpiredSessions() {
return this.db.selectFrom('video_stream_session').select(['id']).where('expiresAt', '<=', new Date()).execute();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.DATE] })
async extendSession(id: string, expiresAt: Date) {
await this.db.updateTable('video_stream_session').set({ expiresAt }).where('id', '=', id).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async deleteSession(id: string) {
await this.db.deleteFrom('video_stream_session').where('id', '=', id).execute();
}
}
+1 -13
View File
@@ -1,12 +1,5 @@
import { registerEnum } from '@immich/sql-tools'; import { registerEnum } from '@immich/sql-tools';
import { import { AlbumUserRole, AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType } from 'src/enum';
AlbumUserRole,
AssetStatus,
AssetVisibility,
ChecksumAlgorithm,
SourceType,
VideoSegmentCodec,
} from 'src/enum';
export const album_user_role_enum = registerEnum({ export const album_user_role_enum = registerEnum({
name: 'album_user_role_enum', name: 'album_user_role_enum',
@@ -32,8 +25,3 @@ export const asset_checksum_algorithm_enum = registerEnum({
name: 'asset_checksum_algorithm_enum', name: 'asset_checksum_algorithm_enum',
values: Object.values(ChecksumAlgorithm), values: Object.values(ChecksumAlgorithm),
}); });
export const video_stream_variant_codec_enum = registerEnum({
name: 'video_stream_variant_codec_enum',
values: Object.values(VideoSegmentCodec),
});
-12
View File
@@ -76,11 +76,6 @@ import { UserMetadataAuditTable } from 'src/schema/tables/user-metadata-audit.ta
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
import { VersionHistoryTable } from 'src/schema/tables/version-history.table'; import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
import {
VideoStreamSegmentTable,
VideoStreamSessionTable,
VideoStreamVariantTable,
} from 'src/schema/tables/video-stream.table';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql']) @Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
@@ -138,9 +133,6 @@ export class ImmichDatabase {
UserMetadataAuditTable, UserMetadataAuditTable,
UserTable, UserTable,
VersionHistoryTable, VersionHistoryTable,
VideoStreamSessionTable,
VideoStreamVariantTable,
VideoStreamSegmentTable,
PluginTable, PluginTable,
PluginFilterTable, PluginFilterTable,
PluginActionTable, PluginActionTable,
@@ -255,10 +247,6 @@ export interface DB {
version_history: VersionHistoryTable; version_history: VersionHistoryTable;
video_stream_session: VideoStreamSessionTable;
video_stream_variant: VideoStreamVariantTable;
video_stream_segment: VideoStreamSegmentTable;
plugin: PluginTable; plugin: PluginTable;
plugin_filter: PluginFilterTable; plugin_filter: PluginFilterTable;
plugin_action: PluginActionTable; plugin_action: PluginActionTable;
@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "album_user" ADD "isFavorite" boolean NOT NULL DEFAULT false;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "album_user" DROP COLUMN "isFavorite";`.execute(db);
}
@@ -1,40 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TYPE "video_stream_variant_codec_enum" AS ENUM ('av1','hevc','h264');`.execute(db);
await sql`CREATE TABLE "video_stream_session" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"assetId" uuid NOT NULL,
"expiresAt" timestamp with time zone NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT "video_stream_session_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "video_stream_session_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "video_stream_session_assetId_idx" ON "video_stream_session" ("assetId");`.execute(db);
await sql`CREATE INDEX "video_stream_session_expiresAt_idx" ON "video_stream_session" ("expiresAt");`.execute(db);
await sql`CREATE TABLE "video_stream_variant" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"sessionId" uuid NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"bitrate" integer NOT NULL,
"codec" video_stream_variant_codec_enum NOT NULL,
"resolution" smallint NOT NULL,
CONSTRAINT "video_stream_variant_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "video_stream_session" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "video_stream_variant_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "video_stream_variant_sessionId_bitrate_resolution_codec_idx" ON "video_stream_variant" ("sessionId", "bitrate", "resolution", "codec");`.execute(db);
await sql`CREATE TABLE "video_stream_segment" (
"variantId" uuid NOT NULL,
"index" integer NOT NULL,
"durationUs" integer NOT NULL,
CONSTRAINT "video_stream_segment_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "video_stream_variant" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "video_stream_segment_pkey" PRIMARY KEY ("variantId", "index")
);`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TABLE "video_stream_segment";`.execute(db);
await sql`DROP TABLE "video_stream_variant";`.execute(db);
await sql`DROP TABLE "video_stream_session";`.execute(db);
await sql`DROP TYPE "asset_checksum_algorithm_enum";`.execute(db);
}
@@ -58,6 +58,9 @@ export class AlbumUserTable {
@Column({ enum: album_user_role_enum, default: AlbumUserRole.Editor }) @Column({ enum: album_user_role_enum, default: AlbumUserRole.Editor })
role!: Generated<AlbumUserRole>; role!: Generated<AlbumUserRole>;
@Column({ type: 'boolean', default: false })
isFavorite!: Generated<boolean>;
@CreateIdColumn({ index: true }) @CreateIdColumn({ index: true })
createId!: Generated<string>; createId!: Generated<string>;
@@ -1,63 +0,0 @@
import {
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
Index,
PrimaryColumn,
PrimaryGeneratedColumn,
Table,
Timestamp,
} from '@immich/sql-tools';
import { VideoSegmentCodec } from 'src/enum';
import { video_stream_variant_codec_enum } from 'src/schema/enums';
import { AssetTable } from 'src/schema/tables/asset.table';
@Table('video_stream_session')
export class VideoStreamSessionTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE' })
assetId!: string;
@Column({ type: 'timestamp with time zone', index: true })
expiresAt!: Timestamp;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
}
@Index({ columns: ['sessionId', 'bitrate', 'resolution', 'codec'], unique: true })
@Table('video_stream_variant')
export class VideoStreamVariantTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@ForeignKeyColumn(() => VideoStreamSessionTable, { onDelete: 'CASCADE', index: false })
sessionId!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@Column({ type: 'integer' })
bitrate!: number;
@Column({ enum: video_stream_variant_codec_enum })
codec!: VideoSegmentCodec;
@Column({ type: 'smallint' })
resolution!: number;
}
@Table('video_stream_segment')
export class VideoStreamSegmentTable {
@ForeignKeyColumn(() => VideoStreamVariantTable, { onDelete: 'CASCADE', primary: true, index: false })
variantId!: string;
@PrimaryColumn({ type: 'integer' })
index!: number;
@Column({ type: 'integer' })
durationUs!: number;
}
@@ -196,7 +196,6 @@ describe(AlbumService.name, () => {
expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {}); expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {});
expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id); expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId]), false); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId]), false);
expect(mocks.event.emit).toHaveBeenCalledTimes(1);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', {
id: album.id, id: album.id,
userId: albumUser.userId, userId: albumUser.userId,
+29 -9
View File
@@ -38,12 +38,17 @@ export class AlbumService extends BaseService {
}; };
} }
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> { async getAll(
{ user: { id: ownerId } }: AuthDto,
{ assetId, shared, favorite }: GetAlbumsDto,
): Promise<AlbumResponseDto[]> {
await this.albumRepository.updateThumbnails(); await this.albumRepository.updateThumbnails();
let albums: MapAlbumDto[]; let albums: MapAlbumDto[];
if (assetId) { if (assetId) {
albums = await this.albumRepository.getByAssetId(ownerId, assetId); albums = await this.albumRepository.getByAssetId(ownerId, assetId);
} else if (favorite === true) {
albums = await this.albumRepository.getFavorites(ownerId);
} else if (shared === true) { } else if (shared === true) {
albums = await this.albumRepository.getShared(ownerId); albums = await this.albumRepository.getShared(ownerId);
} else if (shared === false) { } else if (shared === false) {
@@ -61,7 +66,7 @@ export class AlbumService extends BaseService {
} }
return albums.map((album) => ({ return albums.map((album) => ({
...mapAlbum(album), ...mapAlbum(album, ownerId),
sharedLinks: undefined, sharedLinks: undefined,
startDate: asDateString(albumMetadata[album.id]?.startDate ?? undefined), startDate: asDateString(albumMetadata[album.id]?.startDate ?? undefined),
endDate: asDateString(albumMetadata[album.id]?.endDate ?? undefined), endDate: asDateString(albumMetadata[album.id]?.endDate ?? undefined),
@@ -82,7 +87,7 @@ export class AlbumService extends BaseService {
const isShared = hasSharedUsers || hasSharedLink; const isShared = hasSharedUsers || hasSharedLink;
return { return {
...mapAlbum(album), ...mapAlbum(album, auth.user.id),
startDate: asDateString(albumMetadataForIds?.startDate ?? undefined), startDate: asDateString(albumMetadataForIds?.startDate ?? undefined),
endDate: asDateString(albumMetadataForIds?.endDate ?? undefined), endDate: asDateString(albumMetadataForIds?.endDate ?? undefined),
assetCount: albumMetadataForIds?.assetCount ?? 0, assetCount: albumMetadataForIds?.assetCount ?? 0,
@@ -114,6 +119,7 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Cannot share album with owner'); throw new BadRequestException('Cannot share album with owner');
} }
} }
albumUsers.unshift({ userId: auth.user.id, role: AlbumUserRole.Owner });
const allowedAssetIdsSet = await this.checkAccess({ const allowedAssetIdsSet = await this.checkAccess({
auth, auth,
@@ -132,7 +138,7 @@ export class AlbumService extends BaseService {
order: getPreferences(userMetadata).albums.defaultAssetOrder, order: getPreferences(userMetadata).albums.defaultAssetOrder,
}, },
assetIds, assetIds,
[{ userId: auth.user.id, role: AlbumUserRole.Owner }, ...albumUsers], albumUsers,
auth.user.id, auth.user.id,
); );
@@ -140,7 +146,7 @@ export class AlbumService extends BaseService {
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId, senderName: auth.user.name }); await this.eventRepository.emit('AlbumInvite', { id: album.id, userId, senderName: auth.user.name });
} }
return mapAlbum(album); return mapAlbum(album, auth.user.id);
} }
async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> { async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
@@ -167,7 +173,7 @@ export class AlbumService extends BaseService {
auth.user.id, auth.user.id,
); );
return mapAlbum({ ...updatedAlbum, assets: album.assets }); return mapAlbum({ ...updatedAlbum, assets: album.assets }, auth.user.id);
} }
async delete(auth: AuthDto, id: string): Promise<void> { async delete(auth: AuthDto, id: string): Promise<void> {
@@ -309,7 +315,7 @@ export class AlbumService extends BaseService {
await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name }); await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name });
} }
return this.findOrFail(id, auth.user.id, { withAssets: true }).then(mapAlbum); return this.findOrFail(id, auth.user.id, { withAssets: true }).then((album) => mapAlbum(album, auth.user.id));
} }
async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise<void> { async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise<void> {
@@ -340,8 +346,22 @@ export class AlbumService extends BaseService {
} }
async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise<void> { async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] }); if (dto.role === undefined && dto.isFavorite === undefined) {
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role }); throw new BadRequestException('No updates provided');
}
if (dto.role !== undefined) {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
}
if (dto.isFavorite !== undefined) {
if (userId !== auth.user.id) {
throw new BadRequestException('Cannot favorite an album on behalf of another user');
}
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
}
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role, isFavorite: dto.isFavorite });
} }
private async findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) { private async findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) {
-3
View File
@@ -53,7 +53,6 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { TrashRepository } from 'src/repositories/trash.repository'; import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository'; import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { VideoStreamRepository } from 'src/repositories/video-stream.repository';
import { ViewRepository } from 'src/repositories/view-repository'; import { ViewRepository } from 'src/repositories/view-repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository'; import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository'; import { WorkflowRepository } from 'src/repositories/workflow.repository';
@@ -110,7 +109,6 @@ export const BASE_SERVICE_DEPENDENCIES = [
TrashRepository, TrashRepository,
UserRepository, UserRepository,
VersionHistoryRepository, VersionHistoryRepository,
VideoStreamRepository,
ViewRepository, ViewRepository,
WebsocketRepository, WebsocketRepository,
WorkflowRepository, WorkflowRepository,
@@ -169,7 +167,6 @@ export class BaseService {
protected trashRepository: TrashRepository, protected trashRepository: TrashRepository,
protected userRepository: UserRepository, protected userRepository: UserRepository,
protected versionRepository: VersionHistoryRepository, protected versionRepository: VersionHistoryRepository,
protected videoStreamRepository: VideoStreamRepository,
protected viewRepository: ViewRepository, protected viewRepository: ViewRepository,
protected websocketRepository: WebsocketRepository, protected websocketRepository: WebsocketRepository,
protected workflowRepository: WorkflowRepository, protected workflowRepository: WorkflowRepository,
@@ -24,6 +24,7 @@ export class AlbumUserFactory {
albumId: newUuid(), albumId: newUuid(),
userId: newUuid(), userId: newUuid(),
role: AlbumUserRole.Editor, role: AlbumUserRole.Editor,
isFavorite: false,
createId: newUuidV7(), createId: newUuidV7(),
createdAt: newDate(), createdAt: newDate(),
updateId: newUuidV7(), updateId: newUuidV7(),
+32
View File
@@ -2,36 +2,68 @@ import { expect } from 'vitest';
export const errorDto = { export const errorDto = {
unauthorized: { unauthorized: {
error: 'Unauthorized',
statusCode: 401,
message: 'Authentication required', message: 'Authentication required',
correlationId: expect.any(String),
}, },
forbidden: { forbidden: {
error: 'Forbidden',
statusCode: 403,
message: expect.any(String), message: expect.any(String),
correlationId: expect.any(String),
}, },
missingPermission: (permission: string) => ({ missingPermission: (permission: string) => ({
error: 'Forbidden',
statusCode: 403,
message: `Missing required permission: ${permission}`, message: `Missing required permission: ${permission}`,
correlationId: expect.any(String),
}), }),
wrongPassword: { wrongPassword: {
error: 'Bad Request',
statusCode: 400,
message: 'Wrong password', message: 'Wrong password',
correlationId: expect.any(String),
}, },
invalidToken: { invalidToken: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid user token', message: 'Invalid user token',
correlationId: expect.any(String),
}, },
invalidShareKey: { invalidShareKey: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid share key', message: 'Invalid share key',
correlationId: expect.any(String),
}, },
invalidSharePassword: { invalidSharePassword: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid password', message: 'Invalid password',
correlationId: expect.any(String),
}, },
badRequest: (message: any = null) => ({ badRequest: (message: any = null) => ({
error: 'Bad Request',
statusCode: 400,
message: message ?? expect.anything(), message: message ?? expect.anything(),
}), }),
noPermission: { noPermission: {
error: 'Bad Request',
statusCode: 400,
message: expect.stringContaining('Not found or no'), message: expect.stringContaining('Not found or no'),
correlationId: expect.any(String),
}, },
incorrectLogin: { incorrectLogin: {
error: 'Unauthorized',
statusCode: 401,
message: 'Incorrect email or password', message: 'Incorrect email or password',
correlationId: expect.any(String),
}, },
alreadyHasAdmin: { alreadyHasAdmin: {
error: 'Bad Request',
statusCode: 400,
message: 'The server already has an admin', message: 'The server already has an admin',
correlationId: expect.any(String),
}, },
}; };
+2
View File
@@ -246,6 +246,8 @@ export const factory = {
date: newDate, date: newDate,
responses: { responses: {
badRequest: (message: any = null) => ({ badRequest: (message: any = null) => ({
error: 'Bad Request',
statusCode: 400,
message: message ?? expect.anything(), message: message ?? expect.anything(),
}), }),
}, },
-4
View File
@@ -64,7 +64,6 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { TrashRepository } from 'src/repositories/trash.repository'; import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository'; import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { VideoStreamRepository } from 'src/repositories/video-stream.repository';
import { ViewRepository } from 'src/repositories/view-repository'; import { ViewRepository } from 'src/repositories/view-repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository'; import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository'; import { WorkflowRepository } from 'src/repositories/workflow.repository';
@@ -261,7 +260,6 @@ export type ServiceOverrides = {
trash: TrashRepository; trash: TrashRepository;
user: UserRepository; user: UserRepository;
versionHistory: VersionHistoryRepository; versionHistory: VersionHistoryRepository;
videoStream: VideoStreamRepository;
view: ViewRepository; view: ViewRepository;
websocket: WebsocketRepository; websocket: WebsocketRepository;
workflow: WorkflowRepository; workflow: WorkflowRepository;
@@ -346,7 +344,6 @@ export const getMocks = () => {
trash: automock(TrashRepository), trash: automock(TrashRepository),
user: automock(UserRepository, { strict: false }), user: automock(UserRepository, { strict: false }),
versionHistory: automock(VersionHistoryRepository), versionHistory: automock(VersionHistoryRepository),
videoStream: automock(VideoStreamRepository),
view: automock(ViewRepository), view: automock(ViewRepository),
// eslint-disable-next-line no-sparse-arrays // eslint-disable-next-line no-sparse-arrays
websocket: automock(WebsocketRepository, { args: [, loggerMock], strict: false }), websocket: automock(WebsocketRepository, { args: [, loggerMock], strict: false }),
@@ -411,7 +408,6 @@ export const newTestService = <T extends BaseService>(
overrides.trash || (mocks.trash as As<TrashRepository>), overrides.trash || (mocks.trash as As<TrashRepository>),
overrides.user || (mocks.user as As<UserRepository>), overrides.user || (mocks.user as As<UserRepository>),
overrides.versionHistory || (mocks.versionHistory as As<VersionHistoryRepository>), overrides.versionHistory || (mocks.versionHistory as As<VersionHistoryRepository>),
overrides.videoStream || (mocks.videoStream as As<VideoStreamRepository>),
overrides.view || (mocks.view as As<ViewRepository>), overrides.view || (mocks.view as As<ViewRepository>),
overrides.websocket || (mocks.websocket as As<WebsocketRepository>), overrides.websocket || (mocks.websocket as As<WebsocketRepository>),
overrides.workflow || (mocks.workflow as As<WorkflowRepository>), overrides.workflow || (mocks.workflow as As<WorkflowRepository>),
+1 -1
View File
@@ -9,7 +9,7 @@
"build:stats": "BUILD_STATS=true vite build", "build:stats": "BUILD_STATS=true vite build",
"package": "svelte-kit package", "package": "svelte-kit package",
"preview": "vite preview", "preview": "vite preview",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings", "check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'state_referenced_locally:ignore'",
"check:typescript": "tsc --noEmit", "check:typescript": "tsc --noEmit",
"check:watch": "pnpm run check:svelte --watch", "check:watch": "pnpm run check:svelte --watch",
"check:code": "pnpm run format && pnpm run lint && pnpm run check:svelte && pnpm run check:typescript", "check:code": "pnpm run format && pnpm run lint && pnpm run check:svelte && pnpm run check:typescript",
@@ -35,7 +35,7 @@
const setSelectedDate = (value: DateTime | undefined) => { const setSelectedDate = (value: DateTime | undefined) => {
selectedPresetValue = null; // Clear preset when manually setting date selectedPresetValue = null; // Clear preset when manually setting date
expiresAt = value ? value.toUTC().toISO() : null; expiresAt = value ? value.toISO() : null;
}; };
const selectPreset = (value: number) => { const selectPreset = (value: number) => {
@@ -44,8 +44,8 @@
expiresAt = null; expiresAt = null;
return; return;
} }
const newDate = DateTime.now().plus({ milliseconds: value }); const newDate = DateTime.now().plus(value);
expiresAt = newDate.toUTC().toISO(); expiresAt = newDate.toISO();
}; };
const isSelected = (value: number) => { const isSelected = (value: number) => {
@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import AlbumCover from '$lib/components/album-page/AlbumCover.svelte'; import AlbumCover from '$lib/components/album-page/AlbumCover.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { toggleAlbumFavorite } from '$lib/services/album.service';
import { getContextMenuPositionFromEvent, type ContextMenuPosition } from '$lib/utils/context-menu'; import { getContextMenuPositionFromEvent, type ContextMenuPosition } from '$lib/utils/context-menu';
import { getShortDateRange } from '$lib/utils/date-time'; import { getShortDateRange } from '$lib/utils/date-time';
import { type AlbumResponseDto } from '@immich/sdk'; import { type AlbumResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui'; import { Icon, IconButton } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js'; import { mdiDotsVertical, mdiHeart, mdiHeartOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
@@ -26,37 +27,70 @@
onShowContextMenu = undefined, onShowContextMenu = undefined,
}: Props = $props(); }: Props = $props();
let togglingFavorite = $state(false);
const showAlbumContextMenu = (e: MouseEvent) => { const showAlbumContextMenu = (e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
onShowContextMenu?.(getContextMenuPositionFromEvent(e)); onShowContextMenu?.(getContextMenuPositionFromEvent(e));
}; };
const onToggleFavorite = async (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
if (togglingFavorite) {
return;
}
togglingFavorite = true;
try {
await toggleAlbumFavorite(album);
} finally {
togglingFavorite = false;
}
};
</script> </script>
<div <div
class="group relative rounded-2xl border border-transparent p-5 hover:bg-gray-100 hover:border-gray-200 dark:hover:border-gray-800 dark:hover:bg-gray-900" class="group relative rounded-2xl border border-transparent p-5 hover:bg-gray-100 hover:border-gray-200 dark:hover:border-gray-800 dark:hover:bg-gray-900"
data-testid="album-card" data-testid="album-card"
> >
{#if onShowContextMenu} <div class="relative">
<div <AlbumCover {album} {preload} class="transition-all duration-300 hover:shadow-lg" />
id="icon-{album.id}" {#if onShowContextMenu}
class="absolute end-6 top-6 opacity-0 group-hover:opacity-100 focus-within:opacity-100" <div
data-testid="context-button-parent" id="icon-{album.id}"
class="absolute inset-e-3 top-3 opacity-0 group-hover:opacity-100 focus-within:opacity-100"
data-testid="context-button-parent"
>
<IconButton
color="secondary"
aria-label={$t('show_album_options')}
icon={mdiDotsVertical}
shape="round"
variant="filled"
size="medium"
class="icon-white-drop-shadow"
onclick={showAlbumContextMenu}
/>
</div>
{/if}
<button
type="button"
class="absolute inset-e-3 bottom-3 inline-flex items-center justify-center rounded-full p-2 bg-white/50 dark:bg-gray-900/90 backdrop-blur-md shadow-lg ring-1 ring-black/5 transition-all duration-150 hover:scale-110 active:scale-95 disabled:opacity-50 {album.isFavorite
? 'text-red-500 dark:text-red-400'
: 'text-gray-500 dark:text-gray-300 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 hover:text-red-500 dark:hover:text-red-400'}"
aria-label={$t(album.isFavorite ? 'unfavorite' : 'favorite')}
aria-pressed={album.isFavorite}
disabled={togglingFavorite}
onclick={onToggleFavorite}
> >
<IconButton <Icon
color="secondary" icon={album.isFavorite ? mdiHeart : mdiHeartOutline}
aria-label={$t('show_album_options')} size="1.125em"
icon={mdiDotsVertical} class={album.isFavorite ? 'drop-shadow-[0_1px_2px_rgba(239,68,68,0.5)]' : ''}
shape="round"
variant="filled"
size="medium"
class="icon-white-drop-shadow"
onclick={showAlbumContextMenu}
/> />
</div> </button>
{/if} </div>
<AlbumCover {album} {preload} class="transition-all duration-300 hover:shadow-lg" />
<div class="mt-4"> <div class="mt-4">
<p <p
@@ -30,7 +30,7 @@
let { sharedLink }: Props = $props(); let { sharedLink }: Props = $props();
const album = $derived(sharedLink.album as AlbumResponseDto); const album = sharedLink.album as AlbumResponseDto;
let { slideshowState, slideshowNavigation } = slideshowStore; let { slideshowState, slideshowNavigation } = slideshowStore;
@@ -131,6 +131,15 @@
case AlbumFilter.Shared: { case AlbumFilter.Shared: {
return sharedAlbums; return sharedAlbums;
} }
case AlbumFilter.Favorites: {
const nonOwnedFavorites = sharedAlbums.filter(
(album) =>
album.isFavorite &&
album.albumUsers.find(({ user: { id } }) => id === authManager.user.id)?.role !== AlbumUserRole.Owner,
);
const ownedFavorites = ownedAlbums.filter((album) => album.isFavorite);
return nonOwnedFavorites.length > 0 ? ownedFavorites.concat(nonOwnedFavorites) : ownedFavorites;
}
default: { default: {
const nonOwnedAlbums = sharedAlbums.filter( const nonOwnedAlbums = sharedAlbums.filter(
(album) => (album) =>
@@ -1,5 +1,5 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { render, waitFor, type RenderResult } from '@testing-library/svelte'; import { waitFor, type RenderResult } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { init, register, waitLocale } from 'svelte-i18n'; import { init, register, waitLocale } from 'svelte-i18n';
import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { sdkMock } from '$lib/__mocks__/sdk.mock';
@@ -40,7 +40,7 @@ describe('AlbumCard component', () => {
shared: true, shared: true,
}, },
])('shows album data without thumbnail with count $count - shared: $shared', async ({ album, count, shared }) => { ])('shows album data without thumbnail with count $count - shared: $shared', async ({ album, count, shared }) => {
sut = render(AlbumCard, { album, showItemCount: true }); sut = renderWithTooltips(AlbumCard, { album, showItemCount: true });
const albumImgElement = sut.getByTestId('album-image'); const albumImgElement = sut.getByTestId('album-image');
const albumNameElement = sut.getByTestId('album-name'); const albumNameElement = sut.getByTestId('album-name');
@@ -64,7 +64,7 @@ describe('AlbumCard component', () => {
shared: false, shared: false,
albumName: 'some album name', albumName: 'some album name',
}); });
sut = render(AlbumCard, { album, showItemCount: true }); sut = renderWithTooltips(AlbumCard, { album, showItemCount: true });
const albumImgElement = sut.getByTestId('album-image'); const albumImgElement = sut.getByTestId('album-image');
const albumNameElement = sut.getByTestId('album-name'); const albumNameElement = sut.getByTestId('album-name');
@@ -79,7 +79,7 @@ describe('AlbumCard component', () => {
it('hides context menu when "onShowContextMenu" is undefined', () => { it('hides context menu when "onShowContextMenu" is undefined', () => {
const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null })); const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null }));
sut = render(AlbumCard, { album }); sut = renderWithTooltips(AlbumCard, { album });
const contextButtonParent = sut.queryByTestId('context-button-parent'); const contextButtonParent = sut.queryByTestId('context-button-parent');
expect(contextButtonParent).not.toBeInTheDocument(); expect(contextButtonParent).not.toBeInTheDocument();
@@ -53,8 +53,7 @@
let activityHeight: number = $state(0); let activityHeight: number = $state(0);
let chatHeight: number = $state(0); let chatHeight: number = $state(0);
let divHeight = $derived(innerHeight - activityHeight); let divHeight = $derived(innerHeight - activityHeight);
// svelte-ignore state_referenced_locally let previousAssetId: string | undefined = $state(assetId);
let previousAssetId = $state(assetId);
let message = $state(''); let message = $state('');
let isSendingMessage = $state(false); let isSendingMessage = $state(false);
const isAlbumOwner = $derived(albumUsers[0].user.id === authManager.user.id); const isAlbumOwner = $derived(albumUsers[0].user.id === authManager.user.id);
@@ -67,7 +67,7 @@
preAction?: PreAction; preAction?: PreAction;
onAction?: OnAction; onAction?: OnAction;
onUndoDelete?: OnUndoDelete; onUndoDelete?: OnUndoDelete;
onClose?: (assetId: string) => void; onClose?: (asset: AssetResponseDto) => void;
onRemoveFromAlbum?: (assetIds: string[]) => void; onRemoveFromAlbum?: (assetIds: string[]) => void;
onRandom?: () => Promise<{ id: string } | undefined>; onRandom?: () => Promise<{ id: string } | undefined>;
} }
@@ -179,7 +179,7 @@
}); });
const closeViewer = () => { const closeViewer = () => {
onClose?.(asset.id); onClose?.(asset);
}; };
const closeEditor = async () => { const closeEditor = async () => {
@@ -474,7 +474,7 @@
onAction={handleAction} onAction={handleAction}
{onUndoDelete} {onUndoDelete}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)} onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onClose={onClose ? () => onClose(stack?.primaryAssetId ?? asset.id) : undefined} onClose={onClose ? () => onClose(asset) : undefined}
{onRemoveFromAlbum} {onRemoveFromAlbum}
{playOriginalVideo} {playOriginalVideo}
{setPlayOriginalVideo} {setPlayOriginalVideo}
@@ -10,8 +10,9 @@
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl } from '$lib/utils'; import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils'; import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units'; import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
@@ -24,15 +25,26 @@
type AssetResponseDto, type AssetResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui'; import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js'; import {
mdiCamera,
mdiCameraIris,
mdiClose,
mdiEye,
mdiEyeOff,
mdiImageOutline,
mdiInformationOutline,
mdiPencil,
mdiPlus,
} from '@mdi/js';
import { DateTime } from 'luxon';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/ImageThumbnail.svelte';
import PersonSidePanel from '../faces-page/PersonSidePanel.svelte'; import PersonSidePanel from '../faces-page/PersonSidePanel.svelte';
import OnEvents from '../OnEvents.svelte'; import OnEvents from '../OnEvents.svelte';
import UserAvatar from '../shared-components/UserAvatar.svelte'; import UserAvatar from '../shared-components/UserAvatar.svelte';
import AlbumListItemDetails from './AlbumListItemDetails.svelte'; import AlbumListItemDetails from './AlbumListItemDetails.svelte';
import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte';
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
@@ -42,6 +54,9 @@
let { asset, currentAlbum = null }: Props = $props(); let { asset, currentAlbum = null }: Props = $props();
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId); let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
let people = $derived(asset.people || []);
let unassignedFaces = $derived(asset.unassignedFaces || []);
let showingHiddenPeople = $state(false);
let latlng = $derived( let latlng = $derived(
(() => { (() => {
const lat = asset.exifInfo?.latitude; const lat = asset.exifInfo?.latitude;
@@ -149,7 +164,108 @@
<DetailPanelDescription {asset} {isOwner} /> <DetailPanelDescription {asset} {isOwner} />
<DetailPanelRating {asset} {isOwner} /> <DetailPanelRating {asset} {isOwner} />
<DetailPanelPeople {asset} {isOwner} {previousRoute} />
{#if !authManager.isSharedLink && isOwner}
<section class="px-4 pt-4 text-sm">
<div class="flex h-10 w-full items-center justify-between">
<Text size="small" color="muted">{$t('people')}</Text>
<div class="flex gap-2 items-center">
{#if people.some((person) => person.isHidden)}
<IconButton
aria-label={$t('show_hidden_people')}
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => (showingHiddenPeople = !showingHiddenPeople)}
/>
{/if}
<IconButton
aria-label={$t('tag_people')}
icon={mdiPlus}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleFaceEditMode()}
/>
{#if people.length > 0 || unassignedFaces.length > 0}
<IconButton
aria-label={$t('edit_people')}
icon={mdiPencil}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.openEditFacesPanel()}
/>
{/if}
</div>
</div>
<div class="mt-2 flex flex-wrap gap-2">
{#each people as person, index (person.id)}
{#if showingHiddenPeople || !person.isHidden}
{@const isHighlighted = people[index].faces.some((f) => $boundingBoxesArray.some((b) => b.id === f.id))}
<a
class="group w-22 outline-none"
href={Route.viewPerson(person, { previousRoute })}
onfocus={() => ($boundingBoxesArray = people[index].faces)}
onblur={() => ($boundingBoxesArray = [])}
onmouseover={() => ($boundingBoxesArray = people[index].faces)}
onmouseleave={() => ($boundingBoxesArray = [])}
>
<div class="relative">
<ImageThumbnail
curve
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
title={person.name}
widthStyle="90px"
heightStyle="90px"
hidden={person.isHidden}
highlighted={isHighlighted}
class="group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-immich-primary dark:group-focus-visible:outline-immich-dark-primary"
/>
</div>
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
{#if person.birthDate}
{@const personBirthDate = DateTime.fromISO(person.birthDate)}
{@const age = Math.floor(DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'years').years)}
{@const ageInMonths = Math.floor(
DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'months').months,
)}
{#if age >= 0}
<p
class="font-light"
title={personBirthDate.toLocaleString(
{
month: 'long',
day: 'numeric',
year: 'numeric',
},
{ locale: $locale },
)}
>
{#if ageInMonths <= 11}
{$t('age_months', { values: { months: ageInMonths } })}
{:else if ageInMonths > 12 && ageInMonths <= 23}
{$t('age_year_months', { values: { months: ageInMonths - 12 } })}
{:else}
{$t('age_years', { values: { years: age } })}
{/if}
</p>
{/if}
{/if}
</a>
{/if}
{/each}
</div>
</section>
{/if}
<div class="px-4 py-4"> <div class="px-4 py-4">
{#if asset.exifInfo} {#if asset.exifInfo}
@@ -1,133 +0,0 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { type AssetResponseDto } from '@immich/sdk';
import { IconButton, Text } from '@immich/ui';
import { mdiEye, mdiEyeOff, mdiPencil, mdiPlus } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
type Props = {
asset: AssetResponseDto;
isOwner: boolean;
previousRoute: string;
};
const { asset, isOwner, previousRoute }: Props = $props();
const unassignedFaces = $derived(asset.unassignedFaces || []);
const people = $derived(asset.people || []);
const visiblePeople = $derived(
people
.filter((p) => assetViewerManager.isShowingHiddenPeople || !p.isHidden)
.map((person) => {
if (!person.birthDate) {
return { formattedBirthDate: undefined, formattedAge: undefined, ...person };
}
const personBirthDate = DateTime.fromISO(person.birthDate);
const ageInYears = Math.floor(DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'years').years);
const ageInMonths = Math.floor(DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'months').months);
let formattedAge;
if (ageInYears < 0) {
return { formattedBirthDate: undefined, formattedAge: undefined, ...person };
} else if (ageInMonths < 12) {
formattedAge = $t('age_months', { values: { months: ageInMonths } });
} else if (ageInMonths > 12 && ageInMonths < 24) {
formattedAge = $t('age_year_months', { values: { months: ageInMonths - 12 } });
} else {
formattedAge = $t('age_years', { values: { years: ageInYears } });
}
const formattedBirthDate = personBirthDate.toLocaleString(
{
month: 'long',
day: 'numeric',
year: 'numeric',
},
{ locale: $locale },
);
return { formattedBirthDate, formattedAge, ...person };
}),
);
</script>
{#if !authManager.isSharedLink && isOwner}
<section class="px-4 pt-4 text-sm">
<div class="flex h-10 w-full items-center justify-between">
<Text size="small" color="muted">{$t('people')}</Text>
<div class="flex gap-2 items-center">
{#if people.some((person) => person.isHidden)}
<IconButton
aria-label={$t('show_hidden_people')}
icon={assetViewerManager.isShowingHiddenPeople ? mdiEyeOff : mdiEye}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleHiddenPeople()}
/>
{/if}
<IconButton
aria-label={$t('tag_people')}
icon={mdiPlus}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleFaceEditMode()}
/>
{#if people.length > 0 || unassignedFaces.length > 0}
<IconButton
aria-label={$t('edit_people')}
icon={mdiPencil}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.openEditFacesPanel()}
/>
{/if}
</div>
</div>
<div class="mt-2 grid {visiblePeople.length <= 6 ? 'grid-cols-3 gap-3' : 'grid-cols-4 gap-2'}">
{#each visiblePeople as person (person.id)}
{@const isHighlighted = person.faces.some((f) =>
assetViewerManager.highlightedFaces.some((b) => b.id === f.id),
)}
<a
class="group outline-none"
href={Route.viewPerson(person, { previousRoute })}
onfocus={() => assetViewerManager.setHighlightedFaces(person.faces)}
onblur={() => assetViewerManager.clearHighlightedFaces()}
onpointerenter={() => assetViewerManager.setHighlightedFaces(person.faces)}
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
>
<ImageThumbnail
curve
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
title={person.name}
widthStyle="100%"
hidden={person.isHidden}
highlighted={isHighlighted}
class="group-focus-visible:outline-2 outline-offset-2 outline-immich-primary dark:outline-immich-dark-primary"
/>
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
{#if person.birthDate && person.formattedAge}
<p class="font-light {visiblePeople.length > 6 ? 'text-xs' : ''}" title={person.formattedBirthDate!}>
{person.formattedAge}
</p>
{/if}
</a>
{/each}
</div>
</section>
{/if}
@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { shortcuts } from '$lib/actions/shortcut'; import { shortcuts } from '$lib/actions/shortcut';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte'; import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager, type Faces } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte'; import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { calculateBoundingBoxMatrix, getOcrBoundingBoxes, type Point } from '$lib/utils/ocr-utils'; import { calculateBoundingBoxMatrix, getOcrBoundingBoxes, type Point } from '$lib/utils/ocr-utils';
import { import {
@@ -54,9 +55,14 @@
let viewer: Viewer; let viewer: Viewer;
let animationInProgress: { cancel: () => void } | undefined; let animationInProgress: { cancel: () => void } | undefined;
let previousFaces: Faces[] = [];
$effect(() => { const boundingBoxesUnsubscribe = boundingBoxesArray.subscribe((faces: Faces[]) => {
const faces: Faces[] = assetViewerManager.highlightedFaces; // Debounce; don't do anything when the data didn't actually change.
if (faces === previousFaces) {
return;
}
previousFaces = faces;
if (animationInProgress) { if (animationInProgress) {
animationInProgress.cancel(); animationInProgress.cancel();
@@ -99,7 +105,7 @@
textureX: x, textureX: x,
textureY: y, textureY: y,
zoom: Math.min(viewer.getZoomLevel(), 75), zoom: Math.min(viewer.getZoomLevel(), 75),
speed: 500, speed: 500, // duration in ms
}); });
} }
}); });
@@ -241,8 +247,7 @@
if (viewer) { if (viewer) {
viewer.destroy(); viewer.destroy();
} }
assetViewerManager.clearHighlightedFaces(); boundingBoxesUnsubscribe();
assetViewerManager.hideHiddenPeople();
assetViewerManager.zoom = 1; assetViewerManager.zoom = 1;
}); });
</script> </script>
@@ -6,9 +6,10 @@
import Thumbhash from '$lib/components/Thumbhash.svelte'; import Thumbhash from '$lib/components/Thumbhash.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/OcrBoundingBox.svelte'; import OcrBoundingBox from '$lib/components/asset-viewer/OcrBoundingBox.svelte';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte'; import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager, type Faces } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte'; import { castManager } from '$lib/managers/cast-manager.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
@@ -49,13 +50,12 @@
untrack(() => { untrack(() => {
assetViewerManager.resetZoomState(); assetViewerManager.resetZoomState();
visibleImageReady = false; visibleImageReady = false;
assetViewerManager.clearHighlightedFaces(); $boundingBoxesArray = [];
}); });
}); });
onDestroy(() => { onDestroy(() => {
assetViewerManager.clearHighlightedFaces(); $boundingBoxesArray = [];
assetViewerManager.hideHiddenPeople();
}); });
let containerWidth = $state(0); let containerWidth = $state(0);
@@ -74,13 +74,15 @@
return scaleToFit(getNaturalSize(assetViewerManager.imgRef), { width: containerWidth, height: containerHeight }); return scaleToFit(getNaturalSize(assetViewerManager.imgRef), { width: containerWidth, height: containerHeight });
}); });
const highlightedBoxes = $derived(getBoundingBox(assetViewerManager.highlightedFaces, overlaySize)); const highlightedBoxes = $derived(getBoundingBox($boundingBoxesArray, overlaySize));
const isHighlighting = $derived(highlightedBoxes.length > 0); const isHighlighting = $derived(highlightedBoxes.length > 0);
let visibleBoxes = $state<BoundingBox[]>([]); let visibleBoxes = $state<BoundingBox[]>([]);
let visibleBoundingBoxes = $state<Faces[]>([]);
$effect(() => { $effect(() => {
if (isHighlighting) { if (isHighlighting) {
visibleBoxes = highlightedBoxes; visibleBoxes = highlightedBoxes;
visibleBoundingBoxes = $boundingBoxesArray;
} }
}); });
@@ -158,9 +160,6 @@
// eslint-disable-next-line svelte/prefer-svelte-reactivity // eslint-disable-next-line svelte/prefer-svelte-reactivity
const map = new Map<Faces, string>(); const map = new Map<Faces, string>();
for (const person of asset.people ?? []) { for (const person of asset.people ?? []) {
if (person.isHidden && !assetViewerManager.isShowingHiddenPeople) {
continue;
}
for (const face of person.faces ?? []) { for (const face of person.faces ?? []) {
map.set(face, person.name); map.set(face, person.name);
} }
@@ -170,31 +169,35 @@
const faces = $derived(Array.from(faceToNameMap.keys())); const faces = $derived(Array.from(faceToNameMap.keys()));
const boundingBoxes = $derived.by(() => { const handleImageMouseMove = (event: MouseEvent) => {
if (assetViewerManager.isFaceEditMode || ocrManager.showOverlay) { $boundingBoxesArray = [];
return []; if (!assetViewerManager.imgRef || !element || assetViewerManager.isFaceEditMode || ocrManager.showOverlay) {
return;
} }
const knownBoxes = getBoundingBox(faces, overlaySize); const natural = getNaturalSize(assetViewerManager.imgRef);
const result = knownBoxes.map((box, index) => ({ const scaled = scaleToFit(natural, container);
...box, const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
face: faces[index],
name: faceToNameMap.get(faces[index]),
}));
if (assetViewerManager.highlightedFaces.length === 0) { const contentOffsetX = (container.width - scaled.width) / 2;
return result; const contentOffsetY = (container.height - scaled.height) / 2;
const containerRect = element.getBoundingClientRect();
const mouseX = (event.clientX - containerRect.left - contentOffsetX * currentZoom - currentPositionX) / currentZoom;
const mouseY = (event.clientY - containerRect.top - contentOffsetY * currentZoom - currentPositionY) / currentZoom;
const faceBoxes = getBoundingBox(faces, overlaySize);
for (const [index, box] of faceBoxes.entries()) {
if (mouseX >= box.left && mouseX <= box.left + box.width && mouseY >= box.top && mouseY <= box.top + box.height) {
$boundingBoxesArray.push(faces[index]);
}
} }
};
const knownIds = new Set(faces.map((f) => f.id)); const handleImageMouseLeave = () => {
const unassignedFaces = assetViewerManager.highlightedFaces.filter((f) => !knownIds.has(f.id)); $boundingBoxesArray = [];
const unassignedBoxes = getBoundingBox(unassignedFaces, overlaySize); };
for (let i = 0; i < unassignedBoxes.length; i++) {
result.push({ ...unassignedBoxes[i], face: unassignedFaces[i], name: undefined });
}
return result;
});
</script> </script>
<AssetViewerEvents {onCopy} {onZoom} {onFaceEditModeChange} /> <AssetViewerEvents {onCopy} {onZoom} {onFaceEditModeChange} />
@@ -215,6 +218,8 @@
bind:clientHeight={containerHeight} bind:clientHeight={containerHeight}
role="presentation" role="presentation"
ondblclick={onZoom} ondblclick={onZoom}
onmousemove={handleImageMouseMove}
onmouseleave={handleImageMouseLeave}
use:zoomImageAction={{ zoomTarget: adaptiveImage }} use:zoomImageAction={{ zoomTarget: adaptiveImage }}
{...useSwipe((event) => onSwipe?.(event))} {...useSwipe((event) => onSwipe?.(event))}
> >
@@ -256,27 +261,22 @@
</defs> </defs>
<rect width="100%" height="100%" fill="rgba(0,0,0,0.4)" mask="url(#face-dim-mask)" /> <rect width="100%" height="100%" fill="rgba(0,0,0,0.4)" mask="url(#face-dim-mask)" />
</svg> </svg>
</div> {#each visibleBoxes as boundingbox, index (boundingbox.id)}
{#each boundingBoxes as boundingbox (boundingbox.id)} <div
{@const isActive = assetViewerManager.highlightedFaces.some((f) => f.id === boundingbox.id)} class="absolute border-solid border-white border-3 rounded-lg"
<!-- svelte-ignore a11y_no_static_element_interactions --> style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
<div ></div>
class="absolute pointer-events-auto rounded-lg {isActive && 'border-solid border-white border-3'}" {#if faceToNameMap.get(visibleBoundingBoxes[index])}
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
onpointerenter={() => assetViewerManager.setHighlightedFaces([boundingbox.face])}
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
>
{#if isActive && boundingbox.name}
<div <div
aria-hidden="true" class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap shadow-lg" style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left +
style="top: {boundingbox.height + 4}px; right: 0;" boundingbox.width}px; transform: translateX(-100%);"
> >
{boundingbox.name} {faceToNameMap.get(visibleBoundingBoxes[index])}
</div> </div>
{/if} {/if}
</div> {/each}
{/each} </div>
{#each ocrBoxes as ocrBox (ocrBox.id)} {#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} /> <OcrBoundingBox {ocrBox} />
@@ -16,7 +16,7 @@
} }
let { asset, onAction, preAction }: Props = $props(); let { asset, onAction, preAction }: Props = $props();
const isLocked = $derived(asset.visibility === AssetVisibility.Locked); const isLocked = asset.visibility === AssetVisibility.Locked;
const toggleLockedVisibility = async () => { const toggleLockedVisibility = async () => {
const isConfirmed = await modalManager.showDialog({ const isConfirmed = await modalManager.showDialog({
@@ -29,7 +29,7 @@
class: className, class: className,
}: Props = $props(); }: Props = $props();
let remainingSeconds = $derived(durationInSeconds); let remainingSeconds = $state(durationInSeconds);
let loading = $state(true); let loading = $state(true);
let error = $state(false); let error = $state(false);
let player: HTMLVideoElement | undefined = $state(); let player: HTMLVideoElement | undefined = $state();
@@ -4,6 +4,7 @@
import { timeBeforeShowLoadingSpinner } from '$lib/constants'; import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils'; import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { zoomImageToBase64 } from '$lib/utils/people-utils'; import { zoomImageToBase64 } from '$lib/utils/people-utils';
@@ -238,15 +239,15 @@
{:else} {:else}
{#each peopleWithFaces as face, index (face.id)} {#each peopleWithFaces as face, index (face.id)}
{@const personName = face.person ? face.person?.name : $t('face_unassigned')} {@const personName = face.person ? face.person?.name : $t('face_unassigned')}
{@const isHighlighted = assetViewerManager.highlightedFaces.some((b) => b.id === face.id)} {@const isHighlighted = $boundingBoxesArray.some((b) => b.id === face.id)}
<div class="relative h-29 w-24"> <div class="relative h-29 w-24">
<div <div
role="button" role="button"
tabindex={index} tabindex={index}
class="absolute start-0 top-0 h-22.5 w-22.5 cursor-default" class="absolute start-0 top-0 h-22.5 w-22.5 cursor-default"
onfocus={() => assetViewerManager.setHighlightedFaces([peopleWithFaces[index]])} onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
onpointerenter={() => assetViewerManager.setHighlightedFaces([peopleWithFaces[index]])} onmouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
onpointerleave={() => assetViewerManager.clearHighlightedFaces()} onmouseleave={() => ($boundingBoxesArray = [])}
> >
<div class="relative"> <div class="relative">
{#if selectedPersonToCreate[face.id]} {#if selectedPersonToCreate[face.id]}

Some files were not shown because too many files have changed in this diff Show More