Compare commits
18 Commits
test-fix-s
...
fix/map-th
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b0e18949 | ||
|
|
76eaee3657 | ||
|
|
d5fec0edab | ||
|
|
a7821a0b79 | ||
|
|
73e67ebfea | ||
|
|
0eaa054218 | ||
|
|
2024d06cb7 | ||
|
|
204299d500 | ||
|
|
70e59c00d5 | ||
|
|
5405810a38 | ||
|
|
e67265cef2 | ||
|
|
19c53609e1 | ||
|
|
0d0bb0e2d9 | ||
|
|
8f1b505ba0 | ||
|
|
d04675fb41 | ||
|
|
acfd40b77a | ||
|
|
840e43430c | ||
|
|
a3e0c6cef5 |
@@ -12,8 +12,7 @@ services:
|
|||||||
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload
|
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- pnpm-store:/usr/src/app/.pnpm-store
|
- pnpm-store:/usr/src/app/.pnpm-store
|
||||||
- server-node-modules:/usr/src/app/server/node_modules
|
- server-node_modules:/usr/src/app/server/node_modules
|
||||||
- server-dist:/usr/src/app/server/dist
|
|
||||||
- web-node_modules:/usr/src/app/web/node_modules
|
- web-node_modules:/usr/src/app/web/node_modules
|
||||||
- github-node_modules:/usr/src/app/.github/node_modules
|
- github-node_modules:/usr/src/app/.github/node_modules
|
||||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ services:
|
|||||||
- ${UPLOAD_LOCATION}/photos/upload:/data/upload
|
- ${UPLOAD_LOCATION}/photos/upload:/data/upload
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- pnpm-store:/usr/src/app/.pnpm-store
|
- pnpm-store:/usr/src/app/.pnpm-store
|
||||||
- server-node-modules:/usr/src/app/server/node_modules
|
- server-node_modules:/usr/src/app/server/node_modules
|
||||||
- server-dist:/usr/src/app/server/dist
|
|
||||||
- web-node_modules:/usr/src/app/web/node_modules
|
- web-node_modules:/usr/src/app/web/node_modules
|
||||||
- github-node_modules:/usr/src/app/.github/node_modules
|
- github-node_modules:/usr/src/app/.github/node_modules
|
||||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||||
@@ -97,8 +96,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ..:/usr/src/app
|
- ..:/usr/src/app
|
||||||
- pnpm-store:/usr/src/app/.pnpm-store
|
- pnpm-store:/usr/src/app/.pnpm-store
|
||||||
- server-node-modules:/usr/src/app/server/node_modules
|
- server-node_modules:/usr/src/app/server/node_modules
|
||||||
- server-dist:/usr/src/app/server/dist
|
|
||||||
- web-node_modules:/usr/src/app/web/node_modules
|
- web-node_modules:/usr/src/app/web/node_modules
|
||||||
- github-node_modules:/usr/src/app/.github/node_modules
|
- github-node_modules:/usr/src/app/.github/node_modules
|
||||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||||
@@ -194,8 +192,7 @@ services:
|
|||||||
command: sh -c 'for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done'
|
command: sh -c 'for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done'
|
||||||
volumes:
|
volumes:
|
||||||
- pnpm-store:/usr/src/app/.pnpm-store
|
- pnpm-store:/usr/src/app/.pnpm-store
|
||||||
- server-node-modules:/usr/src/app/server/node_modules
|
- server-node_modules:/usr/src/app/server/node_modules
|
||||||
- server-dist:/usr/src/app/server/dist
|
|
||||||
- web-node_modules:/usr/src/app/web/node_modules
|
- web-node_modules:/usr/src/app/web/node_modules
|
||||||
- github-node_modules:/usr/src/app/.github/node_modules
|
- github-node_modules:/usr/src/app/.github/node_modules
|
||||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||||
@@ -210,8 +207,7 @@ volumes:
|
|||||||
prometheus-data:
|
prometheus-data:
|
||||||
grafana-data:
|
grafana-data:
|
||||||
pnpm-store:
|
pnpm-store:
|
||||||
server-node-modules:
|
server-node_modules:
|
||||||
server-dist:
|
|
||||||
web-node_modules:
|
web-node_modules:
|
||||||
github-node_modules:
|
github-node_modules:
|
||||||
cli-node_modules:
|
cli-node_modules:
|
||||||
|
|||||||
@@ -1,5 +1,31 @@
|
|||||||
# FAQ
|
# FAQ
|
||||||
|
|
||||||
|
## Commercial Guidelines
|
||||||
|
|
||||||
|
### Are you open to commercial partnerships and collaborations?
|
||||||
|
|
||||||
|
We are working to commercialize Immich and we'd love for you to help us by making Immich better. FUTO is dedicated to developing sustainable models for developing open source software for our customers. We want our customers to be delighted by the products our engineers deliver, and we want our engineers to be paid when they succeed.
|
||||||
|
|
||||||
|
If you wish to use Immich in a commercial product not owned by FUTO, we have the following requirements:
|
||||||
|
|
||||||
|
- Plugin Integrations: Integrations for other platforms are typically approved, provided proper notification is given.
|
||||||
|
|
||||||
|
- Reseller Partnerships: Must adhere to the guidelines outlined below regarding trademark usage, and proper representation.
|
||||||
|
|
||||||
|
- Strategic Collaborations: We welcome discussions about mutually beneficial partnerships that enhance the value proposition for both organizations.
|
||||||
|
|
||||||
|
### What are your guidelines for resellers and trademark usage?
|
||||||
|
|
||||||
|
For organizations seeking to resell Immich, we have established the following guidelines to protect our brand integrity and ensure proper representation.
|
||||||
|
|
||||||
|
- We request that resellers do not display our trademarks on their websites or marketing materials. If such usage is discovered, we will contact you to request removal.
|
||||||
|
|
||||||
|
- Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team.
|
||||||
|
|
||||||
|
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase licenses directy from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
|
||||||
|
|
||||||
|
When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app
|
||||||
|
|
||||||
## User
|
## User
|
||||||
|
|
||||||
### How can I reset the admin password?
|
### How can I reset the admin password?
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ The `.well-known/openid-configuration` part of the url is optional and will be a
|
|||||||
## Auto Launch
|
## Auto Launch
|
||||||
|
|
||||||
When Auto Launch is enabled, the login page will automatically redirect the user to the OAuth authorization url, to login with OAuth. To access the login screen again, use the browser's back button, or navigate directly to `/auth/login?autoLaunch=0`.
|
When Auto Launch is enabled, the login page will automatically redirect the user to the OAuth authorization url, to login with OAuth. To access the login screen again, use the browser's back button, or navigate directly to `/auth/login?autoLaunch=0`.
|
||||||
Auto Launch can also be enabled on a per-request basis by navigating to `/auth/login?authLaunch=1`, this can be useful in situations where Immich is called from e.g. Nextcloud using the _External sites_ app and the _oidc_ app so as to enable users to directly interact with a logged-in instance of Immich.
|
Auto Launch can also be enabled on a per-request basis by navigating to `/auth/login?autoLaunch=1`, this can be useful in situations where Immich is called from e.g. Nextcloud using the _External sites_ app and the _oidc_ app so as to enable users to directly interact with a logged-in instance of Immich.
|
||||||
|
|
||||||
## Mobile Redirect URI
|
## Mobile Redirect URI
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ services:
|
|||||||
image: redis:6.2-alpine@sha256:7fe72c486b910f6b1a9769c937dad5d63648ddee82e056f47417542dd40825bb
|
image: redis:6.2-alpine@sha256:7fe72c486b910f6b1a9769c937dad5d63648ddee82e056f47417542dd40825bb
|
||||||
|
|
||||||
database:
|
database:
|
||||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:0e763a2383d56f90364fcd72767ac41400cd30d2627f407f7e7960c9f1923c21
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:7a4469b9484e37bf2630a60bc2f02f086dae898143b599ecc1c93f619849ef6b
|
||||||
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
|
|||||||
@@ -1941,7 +1941,9 @@
|
|||||||
"to_change_password": "Change password",
|
"to_change_password": "Change password",
|
||||||
"to_favorite": "Favorite",
|
"to_favorite": "Favorite",
|
||||||
"to_login": "Login",
|
"to_login": "Login",
|
||||||
|
"to_multi_select": "to multi-select",
|
||||||
"to_parent": "Go to parent",
|
"to_parent": "Go to parent",
|
||||||
|
"to_select": "to select",
|
||||||
"to_trash": "Trash",
|
"to_trash": "Trash",
|
||||||
"toggle_settings": "Toggle settings",
|
"toggle_settings": "Toggle settings",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ profileChangedAt: $profileChangedAt
|
|||||||
bool? isPartnerSharedWith,
|
bool? isPartnerSharedWith,
|
||||||
bool? hasProfileImage,
|
bool? hasProfileImage,
|
||||||
DateTime? profileChangedAt,
|
DateTime? profileChangedAt,
|
||||||
|
int? quotaSizeInBytes,
|
||||||
|
int? quotaUsageInBytes,
|
||||||
}) => UserDto(
|
}) => UserDto(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
email: email ?? this.email,
|
email: email ?? this.email,
|
||||||
@@ -88,6 +90,8 @@ profileChangedAt: $profileChangedAt
|
|||||||
isPartnerSharedWith: isPartnerSharedWith ?? this.isPartnerSharedWith,
|
isPartnerSharedWith: isPartnerSharedWith ?? this.isPartnerSharedWith,
|
||||||
hasProfileImage: hasProfileImage ?? this.hasProfileImage,
|
hasProfileImage: hasProfileImage ?? this.hasProfileImage,
|
||||||
profileChangedAt: profileChangedAt ?? this.profileChangedAt,
|
profileChangedAt: profileChangedAt ?? this.profileChangedAt,
|
||||||
|
quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes,
|
||||||
|
quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -105,7 +109,9 @@ profileChangedAt: $profileChangedAt
|
|||||||
other.memoryEnabled == memoryEnabled &&
|
other.memoryEnabled == memoryEnabled &&
|
||||||
other.inTimeline == inTimeline &&
|
other.inTimeline == inTimeline &&
|
||||||
other.hasProfileImage == hasProfileImage &&
|
other.hasProfileImage == hasProfileImage &&
|
||||||
other.profileChangedAt.isAtSameMomentAs(profileChangedAt);
|
other.profileChangedAt.isAtSameMomentAs(profileChangedAt) &&
|
||||||
|
other.quotaSizeInBytes == quotaSizeInBytes &&
|
||||||
|
other.quotaUsageInBytes == quotaUsageInBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -121,7 +127,9 @@ profileChangedAt: $profileChangedAt
|
|||||||
isPartnerSharedBy.hashCode ^
|
isPartnerSharedBy.hashCode ^
|
||||||
isPartnerSharedWith.hashCode ^
|
isPartnerSharedWith.hashCode ^
|
||||||
hasProfileImage.hashCode ^
|
hasProfileImage.hashCode ^
|
||||||
profileChangedAt.hashCode;
|
profileChangedAt.hashCode ^
|
||||||
|
quotaSizeInBytes.hashCode ^
|
||||||
|
quotaUsageInBytes.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
class PartnerUserDto {
|
class PartnerUserDto {
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ class User {
|
|||||||
avatarColor: dto.avatarColor,
|
avatarColor: dto.avatarColor,
|
||||||
memoryEnabled: dto.memoryEnabled,
|
memoryEnabled: dto.memoryEnabled,
|
||||||
inTimeline: dto.inTimeline,
|
inTimeline: dto.inTimeline,
|
||||||
|
quotaUsageInBytes: dto.quotaUsageInBytes,
|
||||||
|
quotaSizeInBytes: dto.quotaSizeInBytes,
|
||||||
);
|
);
|
||||||
|
|
||||||
UserDto toDto() => UserDto(
|
UserDto toDto() => UserDto(
|
||||||
|
|||||||
@@ -65,40 +65,53 @@ class RemoteImageRequest extends ImageRequest {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle unknown content length from reverse proxy
|
final cacheManager = this.cacheManager;
|
||||||
final contentLength = response.contentLength;
|
final streamController = StreamController<List<int>>(sync: true);
|
||||||
|
final Stream<List<int>> stream;
|
||||||
|
cacheManager?.putStreamedFile(url, streamController.stream);
|
||||||
|
stream = response.map((chunk) {
|
||||||
|
if (_isCancelled) {
|
||||||
|
throw StateError('Cancelled request');
|
||||||
|
}
|
||||||
|
if (cacheManager != null) {
|
||||||
|
streamController.add(chunk);
|
||||||
|
}
|
||||||
|
return chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final Uint8List bytes = await _downloadBytes(stream, response.contentLength);
|
||||||
|
streamController.close();
|
||||||
|
return await ImmutableBuffer.fromUint8List(bytes);
|
||||||
|
} catch (e) {
|
||||||
|
streamController.addError(e);
|
||||||
|
streamController.close();
|
||||||
|
if (_isCancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Uint8List> _downloadBytes(Stream<List<int>> stream, int length) async {
|
||||||
final Uint8List bytes;
|
final Uint8List bytes;
|
||||||
int offset = 0;
|
int offset = 0;
|
||||||
|
if (length > 0) {
|
||||||
if (contentLength >= 0) {
|
|
||||||
// Known content length - use pre-allocated buffer
|
// Known content length - use pre-allocated buffer
|
||||||
bytes = Uint8List(contentLength);
|
bytes = Uint8List(length);
|
||||||
final subscription = response.listen((List<int> chunk) {
|
await stream.listen((chunk) {
|
||||||
// this is important to break the response stream if the request is cancelled
|
|
||||||
if (_isCancelled) {
|
|
||||||
throw StateError('Cancelled request');
|
|
||||||
}
|
|
||||||
bytes.setAll(offset, chunk);
|
bytes.setAll(offset, chunk);
|
||||||
offset += chunk.length;
|
offset += chunk.length;
|
||||||
}, cancelOnError: true);
|
}, cancelOnError: true).asFuture();
|
||||||
cacheManager?.putStreamedFile(url, response);
|
|
||||||
await subscription.asFuture();
|
|
||||||
} else {
|
} else {
|
||||||
// Unknown content length - collect chunks dynamically
|
// Unknown content length - collect chunks dynamically
|
||||||
final chunks = <List<int>>[];
|
final chunks = <List<int>>[];
|
||||||
int totalLength = 0;
|
int totalLength = 0;
|
||||||
final subscription = response.listen((List<int> chunk) {
|
await stream.listen((chunk) {
|
||||||
// this is important to break the response stream if the request is cancelled
|
|
||||||
if (_isCancelled) {
|
|
||||||
throw StateError('Cancelled request');
|
|
||||||
}
|
|
||||||
chunks.add(chunk);
|
chunks.add(chunk);
|
||||||
totalLength += chunk.length;
|
totalLength += chunk.length;
|
||||||
}, cancelOnError: true);
|
}, cancelOnError: true).asFuture();
|
||||||
cacheManager?.putStreamedFile(url, response);
|
|
||||||
await subscription.asFuture();
|
|
||||||
|
|
||||||
// Combine all chunks into a single buffer
|
|
||||||
bytes = Uint8List(totalLength);
|
bytes = Uint8List(totalLength);
|
||||||
for (final chunk in chunks) {
|
for (final chunk in chunks) {
|
||||||
bytes.setAll(offset, chunk);
|
bytes.setAll(offset, chunk);
|
||||||
@@ -106,7 +119,7 @@ class RemoteImageRequest extends ImageRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await ImmutableBuffer.fromUint8List(bytes);
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ImageInfo?> _loadCachedFile(
|
Future<ImageInfo?> _loadCachedFile(
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ abstract final class UserConverter {
|
|||||||
isPartnerSharedWith: false,
|
isPartnerSharedWith: false,
|
||||||
profileChangedAt: adminDto.profileChangedAt,
|
profileChangedAt: adminDto.profileChangedAt,
|
||||||
hasProfileImage: adminDto.profileImagePath.isNotEmpty,
|
hasProfileImage: adminDto.profileImagePath.isNotEmpty,
|
||||||
|
quotaSizeInBytes: adminDto.quotaSizeInBytes ?? 0,
|
||||||
|
quotaUsageInBytes: adminDto.quotaUsageInBytes ?? 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
static UserDto fromPartnerDto(PartnerResponseDto dto) => UserDto(
|
static UserDto fromPartnerDto(PartnerResponseDto dto) => UserDto(
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
|
|||||||
imageInfo.dispose();
|
imageInfo.dispose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_fadeController.value = 1.0;
|
||||||
setState(() {
|
setState(() {
|
||||||
_providerImage = imageInfo.image;
|
_providerImage = imageInfo.image;
|
||||||
});
|
});
|
||||||
@@ -115,7 +115,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
|
|||||||
final imageStream = _imageStream = imageProvider.resolve(ImageConfiguration.empty);
|
final imageStream = _imageStream = imageProvider.resolve(ImageConfiguration.empty);
|
||||||
final imageStreamListener = _imageStreamListener = ImageStreamListener(
|
final imageStreamListener = _imageStreamListener = ImageStreamListener(
|
||||||
(ImageInfo imageInfo, bool synchronousCall) {
|
(ImageInfo imageInfo, bool synchronousCall) {
|
||||||
_stopListeningToStream();
|
_stopListeningToThumbhashStream();
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
imageInfo.dispose();
|
imageInfo.dispose();
|
||||||
return;
|
return;
|
||||||
@@ -125,7 +125,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (synchronousCall && _providerImage == null) {
|
if ((synchronousCall && _providerImage == null) || !_isVisible()) {
|
||||||
_fadeController.value = 1.0;
|
_fadeController.value = 1.0;
|
||||||
} else if (_fadeController.isAnimating) {
|
} else if (_fadeController.isAnimating) {
|
||||||
_fadeController.forward();
|
_fadeController.forward();
|
||||||
@@ -201,6 +201,15 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
|
|||||||
_loadFromThumbhashProvider();
|
_loadFromThumbhashProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isVisible() {
|
||||||
|
final renderObject = context.findRenderObject() as RenderBox?;
|
||||||
|
if (renderObject == null || !renderObject.attached) return false;
|
||||||
|
|
||||||
|
final topLeft = renderObject.localToGlobal(Offset.zero);
|
||||||
|
final bottomRight = renderObject.localToGlobal(Offset(renderObject.size.width, renderObject.size.height));
|
||||||
|
return topLeft.dy < context.height && bottomRight.dy > 0;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = context.colorScheme;
|
final colorScheme = context.colorScheme;
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ class TimelineHeader extends StatelessWidget {
|
|||||||
if (isMonthHeader)
|
if (isMonthHeader)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(_formatMonth(context, date), style: context.textTheme.labelLarge?.copyWith(fontSize: 24)),
|
Text(
|
||||||
|
toBeginningOfSentenceCase(_formatMonth(context, date)),
|
||||||
|
style: context.textTheme.labelLarge?.copyWith(fontSize: 24),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (header != HeaderType.monthAndDay) _BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
|
if (header != HeaderType.monthAndDay) _BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
|
||||||
],
|
],
|
||||||
@@ -65,7 +68,10 @@ class TimelineHeader extends StatelessWidget {
|
|||||||
if (isDayHeader)
|
if (isDayHeader)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(_formatDay(context, date), style: context.textTheme.labelLarge?.copyWith(fontSize: 15)),
|
Text(
|
||||||
|
toBeginningOfSentenceCase(_formatDay(context, date)),
|
||||||
|
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
_BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
|
_BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -38,9 +38,21 @@ abstract class RemoteCacheManager extends CacheManager {
|
|||||||
final file = await store.fileSystem.createFile(path);
|
final file = await store.fileSystem.createFile(path);
|
||||||
final sink = file.openWrite();
|
final sink = file.openWrite();
|
||||||
try {
|
try {
|
||||||
await source.pipe(sink);
|
await source.listen(sink.add, cancelOnError: true).asFuture();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
await sink.close();
|
||||||
|
await file.delete();
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe('Failed to delete incomplete cache file: $e');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sink.flush();
|
||||||
await sink.close();
|
await sink.close();
|
||||||
|
} catch (e) {
|
||||||
try {
|
try {
|
||||||
await file.delete();
|
await file.delete();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class _MapThemeOverrideState extends ConsumerState<MapThemeOverride> with Widget
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_theme = widget.themeMode ?? ref.read(mapStateNotifierProvider.select((v) => v.themeMode));
|
_theme = widget.themeMode ?? ref.read(immichThemeModeProvider);
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDarkTheme = checkDarkTheme();
|
_isDarkTheme = checkDarkTheme();
|
||||||
});
|
});
|
||||||
@@ -65,7 +65,7 @@ class _MapThemeOverrideState extends ConsumerState<MapThemeOverride> with Widget
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
_theme = widget.themeMode ?? ref.watch(mapStateNotifierProvider.select((v) => v.themeMode));
|
_theme = widget.themeMode ?? ref.watch(immichThemeModeProvider);
|
||||||
var appTheme = ref.watch(immichThemeProvider);
|
var appTheme = ref.watch(immichThemeProvider);
|
||||||
final locale = ref.watch(localeProvider);
|
final locale = ref.watch(localeProvider);
|
||||||
|
|
||||||
|
|||||||
79
server/src/controllers/user-admin.controller.spec.ts
Normal file
79
server/src/controllers/user-admin.controller.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { UserAdminController } from 'src/controllers/user-admin.controller';
|
||||||
|
import { UserAdminCreateDto } from 'src/dtos/user.dto';
|
||||||
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { UserAdminService } from 'src/services/user-admin.service';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { errorDto } from 'test/medium/responses';
|
||||||
|
import { factory } from 'test/small.factory';
|
||||||
|
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||||
|
|
||||||
|
describe(UserAdminController.name, () => {
|
||||||
|
let ctx: ControllerContext;
|
||||||
|
const service = mockBaseService(UserAdminService);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await controllerSetup(UserAdminController, [
|
||||||
|
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
|
||||||
|
{ provide: UserAdminService, useValue: service },
|
||||||
|
]);
|
||||||
|
return () => ctx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service.resetAllMocks();
|
||||||
|
ctx.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /admin/users', () => {
|
||||||
|
it('should be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).get('/admin/users');
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /admin/users', () => {
|
||||||
|
it('should be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).post('/admin/users');
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should not allow decimal quota`, async () => {
|
||||||
|
const dto: UserAdminCreateDto = {
|
||||||
|
email: 'user@immich.app',
|
||||||
|
password: 'test',
|
||||||
|
name: 'Test User',
|
||||||
|
quotaSizeInBytes: 1.2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { status, body } = await request(ctx.getHttpServer())
|
||||||
|
.post(`/admin/users`)
|
||||||
|
.set('Authorization', `Bearer token`)
|
||||||
|
.send(dto);
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /admin/users/:id', () => {
|
||||||
|
it('should be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).get(`/admin/users/${factory.uuid()}`);
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /admin/users/:id', () => {
|
||||||
|
it('should be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).put(`/admin/users/${factory.uuid()}`);
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should not allow decimal quota`, async () => {
|
||||||
|
const { status, body } = await request(ctx.getHttpServer())
|
||||||
|
.put(`/admin/users/${factory.uuid()}`)
|
||||||
|
.set('Authorization', `Bearer token`)
|
||||||
|
.send({ quotaSizeInBytes: 1.2 });
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
|
import { IsEmail, IsInt, IsNotEmpty, IsString, Min } from 'class-validator';
|
||||||
import { User, UserAdmin } from 'src/database';
|
import { User, UserAdmin } from 'src/database';
|
||||||
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
||||||
import { UserMetadataItem } from 'src/types';
|
import { UserMetadataItem } from 'src/types';
|
||||||
@@ -91,7 +91,7 @@ export class UserAdminCreateDto {
|
|||||||
storageLabel?: string | null;
|
storageLabel?: string | null;
|
||||||
|
|
||||||
@Optional({ nullable: true })
|
@Optional({ nullable: true })
|
||||||
@IsNumber()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
quotaSizeInBytes?: number | null;
|
quotaSizeInBytes?: number | null;
|
||||||
@@ -137,7 +137,7 @@ export class UserAdminUpdateDto {
|
|||||||
shouldChangePassword?: boolean;
|
shouldChangePassword?: boolean;
|
||||||
|
|
||||||
@Optional({ nullable: true })
|
@Optional({ nullable: true })
|
||||||
@IsNumber()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
quotaSizeInBytes?: number | null;
|
quotaSizeInBytes?: number | null;
|
||||||
|
|||||||
@@ -38,7 +38,11 @@ from
|
|||||||
select
|
select
|
||||||
"album".*,
|
"album".*,
|
||||||
coalesce(
|
coalesce(
|
||||||
json_agg("assets") filter (
|
json_agg(
|
||||||
|
"assets"
|
||||||
|
order by
|
||||||
|
"assets"."fileCreatedAt" asc
|
||||||
|
) filter (
|
||||||
where
|
where
|
||||||
"assets"."id" is not null
|
"assets"."id" is not null
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ExpressionBuilder, Insertable, Kysely, Selectable, sql, Updateable } from 'kysely';
|
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable } from 'kysely';
|
||||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||||
@@ -68,12 +68,6 @@ const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
|
|||||||
).as('person');
|
).as('person');
|
||||||
};
|
};
|
||||||
|
|
||||||
const withAsset = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
|
|
||||||
return jsonObjectFrom(eb.selectFrom('asset').selectAll('asset').whereRef('asset.id', '=', 'asset_face.assetId')).as(
|
|
||||||
'asset',
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
|
const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
|
||||||
return jsonObjectFrom(
|
return jsonObjectFrom(
|
||||||
eb.selectFrom('face_search').selectAll('face_search').whereRef('face_search.faceId', '=', 'asset_face.id'),
|
eb.selectFrom('face_search').selectAll('face_search').whereRef('face_search.faceId', '=', 'asset_face.id'),
|
||||||
@@ -481,7 +475,12 @@ export class PersonRepository {
|
|||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset_face')
|
.selectFrom('asset_face')
|
||||||
.selectAll('asset_face')
|
.selectAll('asset_face')
|
||||||
.select(withAsset)
|
.select((eb) =>
|
||||||
|
jsonObjectFrom(eb.selectFrom('asset').selectAll('asset').whereRef('asset.id', '=', 'asset_face.assetId')).as(
|
||||||
|
'asset',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.$narrowType<{ asset: NotNull }>()
|
||||||
.select(withPerson)
|
.select(withPerson)
|
||||||
.where('asset_face.assetId', 'in', assetIds)
|
.where('asset_face.assetId', 'in', assetIds)
|
||||||
.where('asset_face.personId', 'in', personIds)
|
.where('asset_face.personId', 'in', personIds)
|
||||||
|
|||||||
@@ -86,7 +86,16 @@ export class SharedLinkRepository {
|
|||||||
(join) => join.onTrue(),
|
(join) => join.onTrue(),
|
||||||
)
|
)
|
||||||
.select((eb) =>
|
.select((eb) =>
|
||||||
eb.fn.coalesce(eb.fn.jsonAgg('assets').filterWhere('assets.id', 'is not', null), sql`'[]'`).as('assets'),
|
eb.fn
|
||||||
|
.coalesce(
|
||||||
|
eb.fn
|
||||||
|
.jsonAgg('assets')
|
||||||
|
.orderBy('assets.fileCreatedAt', 'asc')
|
||||||
|
.filterWhere('assets.id', 'is not', null),
|
||||||
|
|
||||||
|
sql`'[]'`,
|
||||||
|
)
|
||||||
|
.as('assets'),
|
||||||
)
|
)
|
||||||
.select((eb) => eb.fn.toJson('owner').as('owner'))
|
.select((eb) => eb.fn.toJson('owner').as('owner'))
|
||||||
.groupBy(['album.id', sql`"owner".*`])
|
.groupBy(['album.id', sql`"owner".*`])
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ describe(MediaService.name, () => {
|
|||||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.image]));
|
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.image]));
|
||||||
|
|
||||||
mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail]));
|
mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail]));
|
||||||
mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
|
||||||
|
|
||||||
await sut.handleQueueGenerateThumbnails({ force: true });
|
await sut.handleQueueGenerateThumbnails({ force: true });
|
||||||
|
|
||||||
|
|||||||
@@ -197,6 +197,10 @@ export class PersonService extends BaseService {
|
|||||||
throw new BadRequestException('Invalid assetId for feature face');
|
throw new BadRequestException('Invalid assetId for feature face');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (face.asset.isOffline) {
|
||||||
|
throw new BadRequestException('An offline asset cannot be used for feature face');
|
||||||
|
}
|
||||||
|
|
||||||
faceId = face.id;
|
faceId = face.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
|
|||||||
import { PersonRepository } from 'src/repositories/person.repository';
|
import { PersonRepository } from 'src/repositories/person.repository';
|
||||||
import { SearchRepository } from 'src/repositories/search.repository';
|
import { SearchRepository } from 'src/repositories/search.repository';
|
||||||
import { SessionRepository } from 'src/repositories/session.repository';
|
import { SessionRepository } from 'src/repositories/session.repository';
|
||||||
|
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
|
||||||
import { StackRepository } from 'src/repositories/stack.repository';
|
import { StackRepository } from 'src/repositories/stack.repository';
|
||||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository';
|
import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository';
|
||||||
@@ -286,6 +287,7 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
|
|||||||
case PersonRepository:
|
case PersonRepository:
|
||||||
case SearchRepository:
|
case SearchRepository:
|
||||||
case SessionRepository:
|
case SessionRepository:
|
||||||
|
case SharedLinkRepository:
|
||||||
case StackRepository:
|
case StackRepository:
|
||||||
case SyncRepository:
|
case SyncRepository:
|
||||||
case SyncCheckpointRepository:
|
case SyncCheckpointRepository:
|
||||||
@@ -391,7 +393,7 @@ const assetInsert = (asset: Partial<Insertable<AssetTable>> = {}) => {
|
|||||||
checksum: randomBytes(32),
|
checksum: randomBytes(32),
|
||||||
type: AssetType.Image,
|
type: AssetType.Image,
|
||||||
originalPath: '/path/to/something.jpg',
|
originalPath: '/path/to/something.jpg',
|
||||||
ownerId: '@immich.cloud',
|
ownerId: 'not-a-valid-uuid',
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
fileCreatedAt: now,
|
fileCreatedAt: now,
|
||||||
fileModifiedAt: now,
|
fileModifiedAt: now,
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { SharedLinkType } from 'src/enum';
|
||||||
|
import { AccessRepository } from 'src/repositories/access.repository';
|
||||||
|
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||||
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
|
||||||
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
|
import { DB } from 'src/schema';
|
||||||
|
import { SharedLinkService } from 'src/services/shared-link.service';
|
||||||
|
import { newMediumService } from 'test/medium.factory';
|
||||||
|
import { factory } from 'test/small.factory';
|
||||||
|
import { getKyselyDB } from 'test/utils';
|
||||||
|
|
||||||
|
let defaultDatabase: Kysely<DB>;
|
||||||
|
|
||||||
|
const setup = (db?: Kysely<DB>) => {
|
||||||
|
return newMediumService(SharedLinkService, {
|
||||||
|
database: db || defaultDatabase,
|
||||||
|
real: [AccessRepository, DatabaseRepository, SharedLinkRepository],
|
||||||
|
mock: [LoggingRepository, StorageRepository],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
defaultDatabase = await getKyselyDB();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(SharedLinkService.name, () => {
|
||||||
|
describe('get', () => {
|
||||||
|
it('should return the correct dates on the shared link album', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const auth = factory.auth({ user });
|
||||||
|
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||||
|
|
||||||
|
const dates = ['2021-01-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z'];
|
||||||
|
|
||||||
|
for (const date of dates) {
|
||||||
|
const { asset } = await ctx.newAsset({ fileCreatedAt: date, localDateTime: date, ownerId: user.id });
|
||||||
|
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
|
||||||
|
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||||
|
|
||||||
|
const sharedLink = await sharedLinkRepo.create({
|
||||||
|
key: randomBytes(16),
|
||||||
|
id: factory.uuid(),
|
||||||
|
userId: user.id,
|
||||||
|
albumId: album.id,
|
||||||
|
allowUpload: true,
|
||||||
|
type: SharedLinkType.Album,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({
|
||||||
|
album: expect.objectContaining({
|
||||||
|
startDate: '2020-01-01T00:00:00+00:00',
|
||||||
|
endDate: '2022-01-01T00:00:00+00:00',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,12 +7,10 @@
|
|||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
||||||
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
|
||||||
import { AssetMediaSize } from '@immich/sdk';
|
import { AssetMediaSize } from '@immich/sdk';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import type { SwipeCustomEvent } from 'svelte-gestures';
|
import type { SwipeCustomEvent } from 'svelte-gestures';
|
||||||
import { swipe } from 'svelte-gestures';
|
import { swipe } from 'svelte-gestures';
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -40,7 +38,6 @@
|
|||||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||||
let isLoading = $state(true);
|
let isLoading = $state(true);
|
||||||
let assetFileUrl = $state('');
|
let assetFileUrl = $state('');
|
||||||
let forceMuted = $state(false);
|
|
||||||
let isScrubbing = $state(false);
|
let isScrubbing = $state(false);
|
||||||
let showVideo = $state(false);
|
let showVideo = $state(false);
|
||||||
|
|
||||||
@@ -49,7 +46,6 @@
|
|||||||
showVideo = true;
|
showVideo = true;
|
||||||
assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey });
|
assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey });
|
||||||
if (videoPlayer) {
|
if (videoPlayer) {
|
||||||
forceMuted = false;
|
|
||||||
videoPlayer.load();
|
videoPlayer.load();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -67,23 +63,27 @@
|
|||||||
onVideoStarted();
|
onVideoStarted();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DOMException && error.name === 'NotAllowedError' && !forceMuted) {
|
if (error instanceof DOMException && error.name === 'NotAllowedError') {
|
||||||
await tryForceMutedPlay(video);
|
await tryForceMutedPlay(video);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleError(error, $t('errors.unable_to_play_video'));
|
// auto-play failed
|
||||||
} finally {
|
} finally {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const tryForceMutedPlay = async (video: HTMLVideoElement) => {
|
const tryForceMutedPlay = async (video: HTMLVideoElement) => {
|
||||||
|
if (video.muted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
video.muted = true;
|
video.muted = true;
|
||||||
await handleCanPlay(video);
|
await handleCanPlay(video);
|
||||||
} catch (error) {
|
} catch {
|
||||||
handleError(error, $t('errors.unable_to_play_video'));
|
// muted auto-play failed
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,18 +134,14 @@
|
|||||||
onswipe={onSwipe}
|
onswipe={onSwipe}
|
||||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||||
onended={onVideoEnded}
|
onended={onVideoEnded}
|
||||||
onvolumechange={(e) => {
|
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
|
||||||
if (!forceMuted) {
|
|
||||||
$videoViewerMuted = e.currentTarget.muted;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onseeking={() => (isScrubbing = true)}
|
onseeking={() => (isScrubbing = true)}
|
||||||
onseeked={() => (isScrubbing = false)}
|
onseeked={() => (isScrubbing = false)}
|
||||||
onplaying={(e) => {
|
onplaying={(e) => {
|
||||||
e.currentTarget.focus();
|
e.currentTarget.focus();
|
||||||
}}
|
}}
|
||||||
onclose={() => onClose()}
|
onclose={() => onClose()}
|
||||||
muted={forceMuted || $videoViewerMuted}
|
muted={$videoViewerMuted}
|
||||||
bind:volume={$videoViewerVolume}
|
bind:volume={$videoViewerVolume}
|
||||||
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||||
src={assetFileUrl}
|
src={assetFileUrl}
|
||||||
|
|||||||
@@ -419,14 +419,22 @@ export class TimelineManager {
|
|||||||
if (!this.isInitialized) {
|
if (!this.isInitialized) {
|
||||||
await this.initTask.waitUntilCompletion();
|
await this.initTask.waitUntilCompletion();
|
||||||
}
|
}
|
||||||
|
|
||||||
let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {};
|
let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {};
|
||||||
if (monthGroup) {
|
if (monthGroup) {
|
||||||
return monthGroup;
|
return monthGroup;
|
||||||
}
|
}
|
||||||
const asset = toTimelineAsset(await getAssetInfo({ ...authManager.params, id }));
|
|
||||||
|
const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null);
|
||||||
|
if (!response) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = toTimelineAsset(response);
|
||||||
if (!asset || this.isExcluded(asset)) {
|
if (!asset || this.isExcluded(asset)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
monthGroup = await this.#loadMonthGroupAtTime(asset.localDateTime, { cancelable: false });
|
monthGroup = await this.#loadMonthGroupAtTime(asset.localDateTime, { cancelable: false });
|
||||||
if (monthGroup?.findAssetById({ id })) {
|
if (monthGroup?.findAssetById({ id })) {
|
||||||
return monthGroup;
|
return monthGroup;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
} from '$lib/components/shared-components/album-selection/album-selection-utils';
|
} from '$lib/components/shared-components/album-selection/album-selection-utils';
|
||||||
import { albumViewSettings } from '$lib/stores/preferences.store';
|
import { albumViewSettings } from '$lib/stores/preferences.store';
|
||||||
import { createAlbum, getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
|
import { createAlbum, getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
|
||||||
import { Button, Modal, ModalBody } from '@immich/ui';
|
import { Button, Icon, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
|
||||||
|
import { mdiKeyboardReturn } from '@mdi/js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import AlbumListItem from '../components/asset-viewer/album-list-item.svelte';
|
import AlbumListItem from '../components/asset-viewer/album-list-item.svelte';
|
||||||
@@ -74,9 +75,9 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMultiSubmit = () => {
|
const handleMultiSubmit = () => {
|
||||||
const albums = new Set(albumModalRows.filter((row) => row.multiSelected).map(({ album }) => album!));
|
const selectedAlbums = new Set(albums.filter(({ id }) => multiSelectedAlbumIds.includes(id)));
|
||||||
if (albums.size > 0) {
|
if (selectedAlbums.size > 0) {
|
||||||
onClose([...albums]);
|
onClose([...selectedAlbums]);
|
||||||
} else {
|
} else {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
@@ -199,4 +200,22 @@
|
|||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<div class="flex justify-around w-full">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="flex gap-1 place-items-center">
|
||||||
|
<span class="bg-gray-300 dark:bg-gray-500 rounded p-1">
|
||||||
|
<Icon icon={mdiKeyboardReturn} size="1rem" />
|
||||||
|
</span>
|
||||||
|
<Text size="tiny">{$t('to_select')}</Text>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 place-items-center">
|
||||||
|
<span class="bg-gray-300 dark:bg-gray-500 rounded p-1">
|
||||||
|
<Text size="tiny">CTRL</Text>
|
||||||
|
</span>
|
||||||
|
<Text size="tiny">{$t('to_multi_select')}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -122,7 +122,7 @@
|
|||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label={$t('admin.quota_size_gib')}>
|
<Field label={$t('admin.quota_size_gib')}>
|
||||||
<Input bind:value={quotaSize} type="number" placeholder={$t('unlimited')} min="0" />
|
<Input bind:value={quotaSize} type="number" placeholder={$t('unlimited')} min="0" step="1" />
|
||||||
{#if quotaSizeWarning}
|
{#if quotaSizeWarning}
|
||||||
<HelperText color="danger">{$t('errors.quota_higher_than_disk_size')}</HelperText>
|
<HelperText color="danger">{$t('errors.quota_higher_than_disk_size')}</HelperText>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
name="quotaSize"
|
name="quotaSize"
|
||||||
placeholder={$t('unlimited')}
|
placeholder={$t('unlimited')}
|
||||||
type="number"
|
type="number"
|
||||||
|
step="1"
|
||||||
min="0"
|
min="0"
|
||||||
bind:value={quotaSize}
|
bind:value={quotaSize}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -403,6 +403,7 @@
|
|||||||
const handleShareLink = async () => {
|
const handleShareLink = async () => {
|
||||||
const sharedLink = await modalManager.show(SharedLinkCreateModal, { albumId: album.id });
|
const sharedLink = await modalManager.show(SharedLinkCreateModal, { albumId: album.id });
|
||||||
if (sharedLink) {
|
if (sharedLink) {
|
||||||
|
await refreshAlbum();
|
||||||
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) });
|
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -411,7 +412,7 @@
|
|||||||
const changed = await modalManager.show(AlbumUsersModal, { album });
|
const changed = await modalManager.show(AlbumUsersModal, { album });
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
album = await getAlbumInfo({ id: album.id, withoutAssets: true });
|
await refreshAlbum();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cancelRequest, handleRequest } from './request';
|
import { handleCancel, handlePreload } from './request';
|
||||||
|
|
||||||
export const installBroadcastChannelListener = () => {
|
export const installBroadcastChannelListener = () => {
|
||||||
const broadcast = new BroadcastChannel('immich');
|
const broadcast = new BroadcastChannel('immich');
|
||||||
@@ -7,12 +7,19 @@ export const installBroadcastChannelListener = () => {
|
|||||||
if (!event.data) {
|
if (!event.data) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const urlString = event.data.url;
|
|
||||||
const url = new URL(urlString, event.origin);
|
const url = new URL(event.data.url, event.origin);
|
||||||
if (event.data.type === 'cancel') {
|
|
||||||
cancelRequest(url);
|
switch (event.data.type) {
|
||||||
} else if (event.data.type === 'preload') {
|
case 'preload': {
|
||||||
handleRequest(url);
|
handlePreload(url);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'cancel': {
|
||||||
|
handleCancel(url);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { get, put } from './cache';
|
import { get, put } from './cache';
|
||||||
|
|
||||||
|
const pendingRequests = new Map<string, AbortController>();
|
||||||
|
|
||||||
const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
|
const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
|
||||||
const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
|
const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
|
||||||
|
|
||||||
@@ -21,11 +23,16 @@ const getCacheKey = (request: URL | Request) => {
|
|||||||
throw new Error(`Invalid request: ${request}`);
|
throw new Error(`Invalid request: ${request}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pendingRequests = new Map<string, AbortController>();
|
export const handlePreload = async (request: URL | Request) => {
|
||||||
|
try {
|
||||||
|
return await handleRequest(request);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Preload failed: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const handleRequest = async (request: URL | Request) => {
|
export const handleRequest = async (request: URL | Request) => {
|
||||||
const cacheKey = getCacheKey(request);
|
const cacheKey = getCacheKey(request);
|
||||||
|
|
||||||
const cachedResponse = await get(cacheKey);
|
const cachedResponse = await get(cacheKey);
|
||||||
if (cachedResponse) {
|
if (cachedResponse) {
|
||||||
return cachedResponse;
|
return cachedResponse;
|
||||||
@@ -41,23 +48,26 @@ export const handleRequest = async (request: URL | Request) => {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
if (error.name === 'AbortError') {
|
||||||
return new Response(undefined, {
|
// dummy response avoids network errors in the console for these requests
|
||||||
status: 499,
|
return new Response(undefined, { status: 204 });
|
||||||
statusText: `Request canceled: Instructions unclear, accidentally interrupted myself (${error})`,
|
}
|
||||||
});
|
|
||||||
|
console.log('Not an abort error', error);
|
||||||
|
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
pendingRequests.delete(cacheKey);
|
pendingRequests.delete(cacheKey);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cancelRequest = (url: URL) => {
|
export const handleCancel = (url: URL) => {
|
||||||
const cacheKey = getCacheKey(url);
|
const cacheKey = getCacheKey(url);
|
||||||
const pending = pendingRequests.get(cacheKey);
|
const pendingRequest = pendingRequests.get(cacheKey);
|
||||||
if (!pending) {
|
if (!pendingRequest) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pending.abort();
|
pendingRequest.abort();
|
||||||
pendingRequests.delete(cacheKey);
|
pendingRequests.delete(cacheKey);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user