Compare commits

..

5 Commits

Author SHA1 Message Date
Alex
f3af54c6c9 chore: more logs and feedback 2025-08-29 16:18:52 -05:00
Sergey Katsubo
f5954f4c9b chore(docs): Avoid /data in external library examples (#21357)
* Avoid /data for external libraries

* Remove mention of microservice containers

* Update docs/docs/features/libraries.md

Co-authored-by: Matthew Momjian <50788000+mmomjian@users.noreply.github.com>

---------

Co-authored-by: Matthew Momjian <50788000+mmomjian@users.noreply.github.com>
2025-08-29 10:24:21 -05:00
Min Idzelis
147accd957 fix: fix docker perms for dev (#21359) 2025-08-28 22:07:29 -04:00
Mert
9487241481 fix(server): refresh faces query (#21380) 2025-08-28 20:23:40 -04:00
Sergey Katsubo
460e1d4715 fix(server): folder sort order (#21383) 2025-08-28 20:22:40 -04:00
18 changed files with 152 additions and 44 deletions

View File

@@ -26,7 +26,7 @@ services:
env_file: !reset []
init:
env_file: !reset []
command: sh -c 'for path in /data /data/upload /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 'find /data -maxdepth 1 ! -path "/data/postgres" -type d -exec chown ${UID:-1000}:${GID:-1000} {} + 2>/dev/null || true; 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'
immich-machine-learning:
env_file: !reset []
database:

View File

@@ -569,7 +569,8 @@ jobs:
- name: Build the app
run: pnpm --filter immich build
- name: Run API generation
run: make open-api
run: ./bin/generate-open-api.sh
working-directory: open-api
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files

View File

@@ -60,20 +60,37 @@ VOLUME_DIRS = \
./e2e/node_modules \
./docs/node_modules \
./server/node_modules \
./server/dist \
./open-api/typescript-sdk/node_modules \
./.github/node_modules \
./node_modules \
./cli/node_modules
# create empty directories and chown to current user
# Include .env file if it exists
-include docker/.env
# Helper function to chown, on error suggest remediation and exit
define safe_chown
if chown $(2) $(or $(UID),1000):$(or $(GID),1000) "$(1)" 2>/dev/null; then \
true; \
else \
echo "Permission denied when changing owner of volumes and upload location. Try running 'sudo make prepare-volumes' first."; \
exit 1; \
fi;
endef
# create empty directories and chown
prepare-volumes:
@for dir in $(VOLUME_DIRS); do \
mkdir -p $$dir; \
done
@if [ -n "$(VOLUME_DIRS)" ]; then \
chown -R $$(id -u):$$(id -g) $(VOLUME_DIRS); \
fi
@$(foreach dir,$(VOLUME_DIRS),mkdir -p $(dir);)
@$(foreach dir,$(VOLUME_DIRS),$(call safe_chown,$(dir),-R))
ifneq ($(UPLOAD_LOCATION),)
ifeq ($(filter /%,$(UPLOAD_LOCATION)),)
@mkdir -p "docker/$(UPLOAD_LOCATION)"
@$(call safe_chown,docker/$(UPLOAD_LOCATION),)
else
@mkdir -p "$(UPLOAD_LOCATION)"
@$(call safe_chown,$(UPLOAD_LOCATION),)
endif
endif
MODULES = e2e server web cli sdk docs .github
@@ -150,8 +167,9 @@ clean:
find . -name ".svelte-kit" -type d -prune -exec rm -rf '{}' +
find . -name "coverage" -type d -prune -exec rm -rf '{}' +
find . -name ".pnpm-store" -type d -prune -exec rm -rf '{}' +
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml rm -v -f || true
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml down -v --remove-orphans || true
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml down -v --remove-orphans || true
setup-server-dev: install-server
setup-web-dev: install-sdk build-sdk install-web

View File

@@ -189,7 +189,7 @@ services:
env_file:
- .env
user: 0:0
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 'find /data -maxdepth 1 -type d -exec chown ${UID:-1000}:${GID:-1000} {} + 2>/dev/null || true; 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:
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules

View File

@@ -33,7 +33,7 @@ Sometimes, an external library will not scan correctly. This can happen if Immic
- Are the permissions set correctly?
- Make sure you are using forward slashes (`/`) and not backward slashes.
To validate that Immich can reach your external library, start a shell inside the container. Run `docker exec -it immich_server bash` to a bash shell. If your import path is `/data/import/photos`, check it with `ls /data/import/photos`. Do the same check for the same in any microservices containers.
To validate that Immich can reach your external library, start a shell inside the container. Run `docker exec -it immich_server bash` to a bash shell. If your import path is `/mnt/photos`, check it with `ls /mnt/photos`. If you are using a dedicated microservices container, make sure to add the same mount point and check for availability within the microservices container as well.
### Exclusion Patterns

View File

@@ -639,6 +639,8 @@
"cannot_update_the_description": "Cannot update the description",
"cast": "Cast",
"cast_description": "Configure available cast destinations",
"cellular_data_for_photos": "Cellular data for photos",
"cellular_data_for_videos": "Cellular data for videos",
"change_date": "Change date",
"change_description": "Change description",
"change_display_order": "Change display order",

View File

@@ -65,7 +65,7 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
pnpm install --frozen-lockfile --prefix server
pnpm --prefix server run build
make open-api
( cd ./open-api && bash ./bin/generate-open-api.sh )
jq --arg version "$NEXT_SERVER" '.version = $version' open-api/typescript-sdk/package.json > open-api/typescript-sdk/package.json.tmp && mv open-api/typescript-sdk/package.json.tmp open-api/typescript-sdk/package.json

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@@ -129,6 +129,8 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync;
sourceTree = "<group>";
};
@@ -505,14 +507,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -541,14 +539,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";

View File

@@ -75,8 +75,8 @@ enum StoreKey<T> {
betaPromptShown<bool>._(1001),
betaTimeline<bool>._(1002),
enableBackup<bool>._(1003),
useWifiForUploadVideos<bool>._(1004),
useWifiForUploadPhotos<bool>._(1005);
useCellularForUploadVideos<bool>._(1004),
useCellularForUploadPhotos<bool>._(1005);
const StoreKey._(this.id);
final int id;

View File

@@ -3,6 +3,8 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
@@ -93,7 +95,11 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
const _BackupCard(),
const _RemainderCard(),
const Divider(),
const SizedBox(height: 4),
const _CellularBackupStatus(),
const SizedBox(height: 4),
BackupToggleButton(onStart: () async => await startBackup(), onStop: () async => await stopBackup()),
TextButton.icon(
icon: const Icon(Icons.info_outline_rounded),
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
@@ -109,6 +115,64 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
}
}
class _CellularBackupStatus extends ConsumerWidget {
const _CellularBackupStatus();
@override
Widget build(BuildContext context, WidgetRef ref) {
final cellularReqForVideos = Store.watch(StoreKey.useCellularForUploadVideos);
final cellularReqForPhotos = Store.watch(StoreKey.useCellularForUploadPhotos);
return GestureDetector(
onTap: () => context.pushRoute(const DriftBackupOptionsRoute()),
child: Row(
children: [
StreamBuilder(
stream: cellularReqForVideos,
initialData: Store.tryGet(StoreKey.useCellularForUploadVideos) ?? false,
builder: (context, snapshot) {
return Expanded(
child: ListTile(
visualDensity: VisualDensity.compact,
leading: Icon(
snapshot.data ?? false ? Icons.check_circle : Icons.cancel_outlined,
size: 16,
color: snapshot.data ?? false ? Colors.green : context.colorScheme.onSurfaceVariant,
),
title: Text(
"cellular_data_for_videos".t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
),
);
},
),
StreamBuilder(
stream: cellularReqForPhotos,
initialData: Store.tryGet(StoreKey.useCellularForUploadPhotos) ?? false,
builder: (context, snapshot) {
return Expanded(
child: ListTile(
visualDensity: VisualDensity.compact,
leading: Icon(
snapshot.data ?? false ? Icons.check_circle : Icons.cancel_outlined,
size: 16,
color: snapshot.data ?? false ? Colors.green : context.colorScheme.onSurfaceVariant,
),
title: Text(
"cellular_data_for_photos".t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
),
);
},
),
],
),
);
}
}
class _BackupAlbumSelectionCard extends ConsumerWidget {
const _BackupAlbumSelectionCard();

View File

@@ -17,15 +17,15 @@ class DriftBackupOptionsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
bool hasPopped = false;
final previousWifiReqForVideos = Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false;
final previousWifiReqForPhotos = Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false;
final previousWifiReqForVideos = Store.tryGet(StoreKey.useCellularForUploadVideos) ?? false;
final previousWifiReqForPhotos = Store.tryGet(StoreKey.useCellularForUploadPhotos) ?? false;
return PopScope(
onPopInvokedWithResult: (didPop, result) async {
// There is an issue with Flutter where the pop event
// can be triggered multiple times, so we guard it with _hasPopped
final currentWifiReqForVideos = Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false;
final currentWifiReqForPhotos = Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false;
final currentWifiReqForVideos = Store.tryGet(StoreKey.useCellularForUploadVideos) ?? false;
final currentWifiReqForPhotos = Store.tryGet(StoreKey.useCellularForUploadPhotos) ?? false;
if (currentWifiReqForVideos == previousWifiReqForVideos &&
currentWifiReqForPhotos == previousWifiReqForPhotos) {

View File

@@ -48,8 +48,8 @@ enum AppSettingsEnum<T> {
photoManagerCustomFilter<bool>(StoreKey.photoManagerCustomFilter, null, true),
betaTimeline<bool>(StoreKey.betaTimeline, null, false),
enableBackup<bool>(StoreKey.enableBackup, null, false),
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
useCellularForUploadVideos<bool>(StoreKey.useCellularForUploadVideos, null, false),
useCellularForUploadPhotos<bool>(StoreKey.useCellularForUploadPhotos, null, false),
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);

View File

@@ -19,6 +19,7 @@ import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
final uploadServiceProvider = Provider((ref) {
@@ -57,6 +58,7 @@ class UploadService {
Stream<TaskStatusUpdate> get taskStatusStream => _taskStatusController.stream;
Stream<TaskProgressUpdate> get taskProgressStream => _taskProgressController.stream;
final Logger _log = Logger('UploadService');
bool shouldAbortQueuingTasks = false;
@@ -127,11 +129,16 @@ class UploadService {
final candidates = await _backupRepository.getCandidates(userId);
if (candidates.isEmpty) {
_log.info("No backup candidates found for user $userId");
return;
}
_log.info("Starting backup for ${candidates.length} candidates");
onEnqueueTasks(EnqueueStatus(enqueueCount: 0, totalCount: candidates.length));
const batchSize = 100;
int count = 0;
int skippedAssets = 0;
for (int i = 0; i < candidates.length; i += batchSize) {
if (shouldAbortQueuingTasks) {
break;
@@ -144,16 +151,22 @@ class UploadService {
final task = await _getUploadTask(asset);
if (task != null) {
tasks.add(task);
} else {
skippedAssets++;
_log.warning("Skipped asset ${asset.id} (${asset.name}) - unable to create upload task");
}
}
if (tasks.isNotEmpty && !shouldAbortQueuingTasks) {
count += tasks.length;
await enqueueTasks(tasks);
count += tasks.length;
onEnqueueTasks(EnqueueStatus(enqueueCount: count, totalCount: candidates.length));
onEnqueueTasks(EnqueueStatus(enqueueCount: count, totalCount: candidates.length));
if (tasks.isNotEmpty && !shouldAbortQueuingTasks) {
_log.info("Enqueuing ${tasks.length} upload tasks");
await enqueueTasks(tasks);
}
}
_log.info("Upload queueing completed: $count tasks enqueued, $skippedAssets assets skipped");
}
// Enqueue All does not work from the background on Android yet. This method is a temporary workaround
@@ -165,9 +178,14 @@ class UploadService {
final candidates = await _backupRepository.getCandidates(userId);
if (candidates.isEmpty) {
debugPrint("No backup candidates found for serial backup");
return;
}
debugPrint("Starting serial backup for ${candidates.length} candidates");
int skippedAssets = 0;
int enqueuedTasks = 0;
for (final asset in candidates) {
if (shouldAbortQueuingTasks) {
break;
@@ -176,8 +194,13 @@ class UploadService {
final task = await _getUploadTask(asset);
if (task != null) {
await _uploadRepository.enqueueBackground(task);
enqueuedTasks++;
} else {
skippedAssets++;
}
}
debugPrint("Serial backup completed: $enqueuedTasks tasks enqueued, $skippedAssets assets skipped");
}
/// Cancel all ongoing uploads and reset the upload queue
@@ -245,6 +268,7 @@ class UploadService {
Future<UploadTask?> _getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) {
_log.warning("Cannot get AssetEntity for asset ${asset.id} (${asset.name}) created on ${asset.createdAt}");
return null;
}
@@ -267,6 +291,9 @@ class UploadService {
}
if (file == null) {
_log.warning(
"Cannot get file for asset ${asset.id} (${asset.name}) created on ${asset.createdAt} - file may have been deleted or moved",
);
return null;
}

View File

@@ -22,7 +22,7 @@ class _UseWifiForUploadVideosButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final valueStream = Store.watch(StoreKey.useWifiForUploadVideos);
final valueStream = Store.watch(StoreKey.useCellularForUploadVideos);
return ListTile(
title: Text(
@@ -32,7 +32,7 @@ class _UseWifiForUploadVideosButton extends ConsumerWidget {
subtitle: Text("network_requirement_videos_upload".t(context: context), style: context.textTheme.labelLarge),
trailing: StreamBuilder(
stream: valueStream,
initialData: Store.tryGet(StoreKey.useWifiForUploadVideos) ?? false,
initialData: Store.tryGet(StoreKey.useCellularForUploadVideos) ?? false,
builder: (context, snapshot) {
final value = snapshot.data ?? false;
return Switch(
@@ -54,7 +54,7 @@ class _UseWifiForUploadPhotosButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final valueStream = Store.watch(StoreKey.useWifiForUploadPhotos);
final valueStream = Store.watch(StoreKey.useCellularForUploadPhotos);
return ListTile(
title: Text(
@@ -64,7 +64,7 @@ class _UseWifiForUploadPhotosButton extends ConsumerWidget {
subtitle: Text("network_requirement_photos_upload".t(context: context), style: context.textTheme.labelLarge),
trailing: StreamBuilder(
stream: valueStream,
initialData: Store.tryGet(StoreKey.useWifiForUploadPhotos) ?? false,
initialData: Store.tryGet(StoreKey.useCellularForUploadPhotos) ?? false,
builder: (context, snapshot) {
final value = snapshot.data ?? false;
return Switch(

View File

@@ -468,9 +468,8 @@ where
"asset"."visibility" != $1
and "asset"."deletedAt" is null
and "job_status"."previewAt" is not null
and "job_status"."facesRecognizedAt" is null
order by
"asset"."createdAt" desc
"asset"."fileCreatedAt" desc
-- AssetJobRepository.streamForMigrationJob
select

View File

@@ -12,6 +12,8 @@ where
and "fileCreatedAt" is not null
and "fileModifiedAt" is not null
and "localDateTime" is not null
order by
"directoryPath" asc
-- ViewRepository.getAssetsByOriginalPath
select

View File

@@ -334,9 +334,9 @@ export class AssetJobRepository {
@GenerateSql({ params: [], stream: true })
streamForDetectFacesJob(force?: boolean) {
return this.assetsWithPreviews()
.$if(!force, (qb) => qb.where('job_status.facesRecognizedAt', 'is', null))
.$if(force === false, (qb) => qb.where('job_status.facesRecognizedAt', 'is', null))
.select(['asset.id'])
.orderBy('asset.createdAt', 'desc')
.orderBy('asset.fileCreatedAt', 'desc')
.stream();
}

View File

@@ -20,6 +20,7 @@ export class ViewRepository {
.where('fileCreatedAt', 'is not', null)
.where('fileModifiedAt', 'is not', null)
.where('localDateTime', 'is not', null)
.orderBy('directoryPath', 'asc')
.execute();
return results.map((row) => row.directoryPath.replaceAll(/\/$/g, ''));