mirror of
https://github.com/immich-app/immich.git
synced 2026-04-28 20:18:48 -07:00
Compare commits
2 Commits
main
...
debug/back
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef1612a8a7 | ||
|
|
e61793fa9d |
@@ -1,5 +1,5 @@
|
||||
[tools]
|
||||
terragrunt = "1.0.2"
|
||||
terragrunt = "1.0.1"
|
||||
opentofu = "1.11.6"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
|
||||
@@ -85,7 +85,7 @@ services:
|
||||
container_name: immich_prometheus
|
||||
ports:
|
||||
- 9090:9090
|
||||
image: prom/prometheus@sha256:e4254400b85610324913f0dc4acf92603d9984e7519414c5a12811aa6146acc3
|
||||
image: prom/prometheus@sha256:5550dc63da361dc30f6fe02ac0e4dfc736ededfef3c8d12a634db04a67824d78
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus-data:/prometheus
|
||||
|
||||
@@ -2,43 +2,82 @@ import { expect } from 'vitest';
|
||||
|
||||
export const errorDto = {
|
||||
unauthorized: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Authentication required',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
unauthorizedWithMessage: (message: string) => ({
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message,
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
forbidden: {
|
||||
error: 'Forbidden',
|
||||
statusCode: 403,
|
||||
message: expect.any(String),
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
missingPermission: (permission: string) => ({
|
||||
error: 'Forbidden',
|
||||
statusCode: 403,
|
||||
message: `Missing required permission: ${permission}`,
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
wrongPassword: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: 'Wrong password',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidToken: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid user token',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidShareKey: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid share key',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
passwordRequired: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Password required',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
badRequest: (message: any = null) => ({
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: message ?? expect.anything(),
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
noPermission: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('Not found or no'),
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
incorrectLogin: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Incorrect email or password',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
alreadyHasAdmin: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: 'The server already has an admin',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidEmail: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: ['email must be an email'],
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -332,7 +332,9 @@ describe(`/oauth`, () => {
|
||||
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
||||
expect(status).toBe(500);
|
||||
expect(body).toMatchObject({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to finish oauth',
|
||||
statusCode: 500,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -493,10 +495,11 @@ describe(`/oauth`, () => {
|
||||
});
|
||||
|
||||
it('should reject OAuth discovery over HTTP', async () => {
|
||||
const { status } = await request(app)
|
||||
const { status, body } = await request(app)
|
||||
.post('/oauth/authorize')
|
||||
.send({ redirectUri: 'http://127.0.0.1:2285/auth/login' });
|
||||
expect(status).toBe(500);
|
||||
expect(body).toMatchObject({ statusCode: 500 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-core-2_2.28.4+20760_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-opencl-2_2.28.4+20760_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/intel-opencl-icd_26.05.37020.3-0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
|
||||
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/libigdgmm12_22.9.0_amd64.deb && \
|
||||
dpkg -i *.deb && \
|
||||
rm *.deb && \
|
||||
apt-get remove wget -yqq && \
|
||||
|
||||
@@ -183,10 +183,7 @@ async def predict(
|
||||
text: str | None = Form(default=None),
|
||||
) -> Any:
|
||||
if image is not None:
|
||||
decoded = 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
|
||||
inputs: Image | str = await run(lambda: decode_pil(image))
|
||||
elif text is not None:
|
||||
inputs = text
|
||||
else:
|
||||
|
||||
@@ -9,12 +9,12 @@ dependencies = [
|
||||
"aiocache>=0.12.1,<1.0",
|
||||
"fastapi>=0.95.2,<1.0",
|
||||
"gunicorn>=21.1.0",
|
||||
"huggingface-hub>=1.0,<2.0",
|
||||
"huggingface-hub>=0.20.1,<1.0",
|
||||
"insightface>=0.7.3,<1.0",
|
||||
"numpy<2.4.0",
|
||||
"opencv-python-headless>=4.7.0.72,<5.0",
|
||||
"orjson>=3.9.5",
|
||||
"pillow>=12.2,<13",
|
||||
"pillow>=12.2,<12.3",
|
||||
"pydantic>=2.0.0,<3",
|
||||
"pydantic-settings>=2.5.2,<3",
|
||||
"python-multipart>=0.0.6,<1.0",
|
||||
|
||||
@@ -1198,19 +1198,6 @@ class TestLoad:
|
||||
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:
|
||||
response = deployed_app.get("http://localhost:3003")
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ config_roots = [
|
||||
[tools]
|
||||
node = "24.15.0"
|
||||
flutter = "3.41.6"
|
||||
pnpm = "10.33.1"
|
||||
terragrunt = "1.0.2"
|
||||
pnpm = "10.33.0"
|
||||
terragrunt = "1.0.1"
|
||||
opentofu = "1.11.6"
|
||||
java = "21.0.2"
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
A01DD69B2F7F43B40049AB63 /* ImageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A01DD6982F7F43B40049AB63 /* ImageRequest.swift */; };
|
||||
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */; };
|
||||
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
|
||||
B21E34B02E5B09190031FDB9 /* FileLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34B12E5B09100031FDB9 /* FileLogger.swift */; };
|
||||
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
|
||||
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */; };
|
||||
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
|
||||
@@ -103,6 +104,7 @@
|
||||
B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkerApiImpl.swift; sourceTree = "<group>"; };
|
||||
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
|
||||
B21E34B12E5B09100031FDB9 /* FileLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogger.swift; sourceTree = "<group>"; };
|
||||
B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = "<group>"; };
|
||||
B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityApiImpl.swift; sourceTree = "<group>"; };
|
||||
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; };
|
||||
@@ -304,6 +306,7 @@
|
||||
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */,
|
||||
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */,
|
||||
B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */,
|
||||
B21E34B12E5B09100031FDB9 /* FileLogger.swift */,
|
||||
);
|
||||
path = Background;
|
||||
sourceTree = "<group>";
|
||||
@@ -614,6 +617,7 @@
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
A01DD69B2F7F43B40049AB63 /* ImageRequest.swift in Sources */,
|
||||
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
|
||||
B21E34B02E5B09190031FDB9 /* FileLogger.swift in Sources */,
|
||||
FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */,
|
||||
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */,
|
||||
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
|
||||
|
||||
@@ -80,29 +80,34 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||
* starts the engine, and sets up a timeout timer if specified.
|
||||
*/
|
||||
func run() {
|
||||
FileLogger.log("BackgroundWorker:run Starting Flutter engine for taskType=\(taskType) maxSeconds=\(maxSeconds.map(String.init) ?? "nil")")
|
||||
// Start the Flutter engine with the specified callback as the entry point
|
||||
let isRunning = engine.run(
|
||||
withEntrypoint: "backgroundSyncNativeEntrypoint",
|
||||
libraryURI: "package:immich_mobile/domain/services/background_worker.service.dart"
|
||||
)
|
||||
|
||||
|
||||
// Verify that the Flutter engine started successfully
|
||||
if !isRunning {
|
||||
FileLogger.log("BackgroundWorker:run Flutter engine failed to start, completing with success=false")
|
||||
complete(success: false)
|
||||
return
|
||||
}
|
||||
|
||||
FileLogger.log("BackgroundWorker:run Flutter engine started")
|
||||
|
||||
// Register plugins in the new engine
|
||||
GeneratedPluginRegistrant.register(with: engine)
|
||||
// Register custom plugins
|
||||
AppDelegate.registerPlugins(with: engine, messenger: engine.binaryMessenger)
|
||||
flutterApi = BackgroundWorkerFlutterApi(binaryMessenger: engine.binaryMessenger)
|
||||
BackgroundWorkerBgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: self)
|
||||
|
||||
FileLogger.log("BackgroundWorker:run Plugins registered, waiting for Flutter onInitialized")
|
||||
|
||||
// Set up a timeout timer if maxSeconds was specified to prevent runaway background tasks
|
||||
if maxSeconds != nil {
|
||||
// Schedule a timer to cancel the task after the specified timeout period
|
||||
Timer.scheduledTimer(withTimeInterval: TimeInterval(maxSeconds!), repeats: false) { _ in
|
||||
FileLogger.log("BackgroundWorker:run maxSeconds=\(self.maxSeconds!) timer fired, closing task")
|
||||
self.close()
|
||||
}
|
||||
}
|
||||
@@ -114,6 +119,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||
* This method acts as a bridge between the native iOS background task system and Flutter.
|
||||
*/
|
||||
func onInitialized() throws {
|
||||
FileLogger.log("BackgroundWorker:onInitialized Flutter ready, calling onIosUpload isRefresh=\(self.taskType == .refresh)")
|
||||
flutterApi?.onIosUpload(isRefresh: self.taskType == .refresh, maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in
|
||||
self.handleHostResult(result: result)
|
||||
})
|
||||
@@ -126,16 +132,22 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||
*/
|
||||
func close() {
|
||||
if isComplete {
|
||||
FileLogger.log("BackgroundWorker:close Already complete, ignoring close()")
|
||||
return
|
||||
}
|
||||
FileLogger.log("BackgroundWorker:close Cancel requested, signaling Flutter (taskType=\(taskType))")
|
||||
|
||||
flutterApi?.cancel { result in
|
||||
FileLogger.log("BackgroundWorker:close Flutter cancel acknowledged")
|
||||
self.complete(success: false)
|
||||
}
|
||||
|
||||
// Fallback safety mechanism: ensure completion is called within 2 seconds
|
||||
// This prevents the background task from hanging indefinitely if Flutter doesn't respond
|
||||
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
|
||||
if !self.isComplete {
|
||||
FileLogger.log("BackgroundWorker:close 2s fallback fired, Flutter did not acknowledge cancel")
|
||||
}
|
||||
self.complete(success: false)
|
||||
}
|
||||
}
|
||||
@@ -149,8 +161,12 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||
*/
|
||||
private func handleHostResult(result: Result<Void, PigeonError>) {
|
||||
switch result {
|
||||
case .success(): self.complete(success: true)
|
||||
case .failure(_): self.close()
|
||||
case .success():
|
||||
FileLogger.log("BackgroundWorker:handleHostResult Flutter onIosUpload succeeded (taskType=\(taskType))")
|
||||
self.complete(success: true)
|
||||
case .failure(let error):
|
||||
FileLogger.log("BackgroundWorker:handleHostResult Flutter onIosUpload failed: \(error.localizedDescription) (taskType=\(taskType))")
|
||||
self.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +182,8 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
||||
if(isComplete) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
FileLogger.log("BackgroundWorker:complete Tearing down engine, success=\(success) (taskType=\(taskType))")
|
||||
isComplete = true
|
||||
AppDelegate.cancelPlugins(with: engine)
|
||||
engine.destroyContext()
|
||||
|
||||
@@ -5,7 +5,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||
func enable() throws {
|
||||
BackgroundWorkerApiImpl.scheduleRefreshWorker()
|
||||
BackgroundWorkerApiImpl.scheduleProcessingWorker()
|
||||
print("BackgroundWorkerApiImpl:enable Background worker scheduled")
|
||||
FileLogger.log("BackgroundWorkerApiImpl:enable Background worker scheduled")
|
||||
}
|
||||
|
||||
func configure(settings: BackgroundWorkerSettings) throws {
|
||||
@@ -19,7 +19,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||
func disable() throws {
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
|
||||
print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers")
|
||||
FileLogger.log("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers")
|
||||
}
|
||||
|
||||
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
|
||||
@@ -30,6 +30,7 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: processingTaskID, using: nil) { task in
|
||||
if task is BGProcessingTask {
|
||||
FileLogger.log("BackgroundWorkerApiImpl:BGProcessingTask Background Processing task received")
|
||||
handleBackgroundProcessing(task: task as! BGProcessingTask)
|
||||
}
|
||||
}
|
||||
@@ -37,9 +38,11 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: refreshTaskID, using: nil) { task in
|
||||
if task is BGAppRefreshTask {
|
||||
FileLogger.log("BackgroundWorkerApiImpl:BGAppRefreshTask Background Refresh task received")
|
||||
handleBackgroundRefresh(task: task as! BGAppRefreshTask)
|
||||
}
|
||||
}
|
||||
FileLogger.log("BackgroundWorkerApiImpl:registerBackgroundWorkers Background workers registered")
|
||||
}
|
||||
|
||||
private static func scheduleRefreshWorker() {
|
||||
@@ -48,8 +51,9 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(backgroundRefresh)
|
||||
FileLogger.log("BackgroundWorkerApiImpl:scheduleRefreshWorker Scheduled Refresh task")
|
||||
} catch {
|
||||
print("Could not schedule the refresh upload task \(error.localizedDescription)")
|
||||
FileLogger.log("BackgroundWorkerApiImpl:scheduleRefreshWorker Could not schedule the refresh upload task \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,25 +65,32 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(backgroundProcessing)
|
||||
FileLogger.log("BackgroundWorkerApiImpl:scheduleProcessingWorker Scheduled Processing task")
|
||||
} catch {
|
||||
print("Could not schedule the processing upload task \(error.localizedDescription)")
|
||||
FileLogger.log("BackgroundWorkerApiImpl:scheduleProcessingWorker Could not schedule the processing upload task \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleBackgroundRefresh(task: BGAppRefreshTask) {
|
||||
FileLogger.log("BackgroundWorkerApiImpl:handleBackgroundRefresh Entered, re-queuing next refresh task")
|
||||
scheduleRefreshWorker()
|
||||
// If another task is running, cede the background time back to the OS
|
||||
if taskSemaphore.wait(timeout: .now()) == .success {
|
||||
FileLogger.log("BackgroundWorkerApiImpl:handleBackgroundRefresh Starting background worker")
|
||||
// Restrict the refresh task to run only for a maximum of (maxSeconds) seconds
|
||||
runBackgroundWorker(task: task, taskType: .refresh, maxSeconds: 20)
|
||||
} else {
|
||||
task.setTaskCompleted(success: false)
|
||||
FileLogger.log("BackgroundWorkerApiImpl:handleBackgroundRefresh Processing task is in progress")
|
||||
task.setTaskCompleted(success: true)
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleBackgroundProcessing(task: BGProcessingTask) {
|
||||
FileLogger.log("BackgroundWorkerApiImpl:handleBackgroundProcessing Entered, re-queuing next processing task")
|
||||
scheduleProcessingWorker()
|
||||
FileLogger.log("BackgroundWorkerApiImpl:handleBackgroundProcessing Waiting for taskSemaphore")
|
||||
taskSemaphore.wait()
|
||||
FileLogger.log("BackgroundWorkerApiImpl:handleBackgroundProcessing Semaphore acquired, starting background worker")
|
||||
// There are no restrictions for processing tasks. Although, the OS could signal expiration at any time
|
||||
runBackgroundWorker(task: task, taskType: .processing, maxSeconds: nil)
|
||||
}
|
||||
@@ -105,11 +116,12 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||
}
|
||||
|
||||
task.expirationHandler = {
|
||||
FileLogger.log("BackgroundWorkerApiImpl:runBackgroundWorker iOS signaled expiration (taskType=\(taskType)), closing worker")
|
||||
DispatchQueue.main.async {
|
||||
backgroundWorker.close()
|
||||
}
|
||||
isSuccess = false
|
||||
|
||||
|
||||
// Schedule a timer to signal the semaphore after 2 seconds
|
||||
Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
|
||||
semaphore.signal()
|
||||
@@ -122,6 +134,6 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||
|
||||
semaphore.wait()
|
||||
task.setTaskCompleted(success: isSuccess)
|
||||
print("Background task completed with success: \(isSuccess)")
|
||||
FileLogger.log("BackgroundWorkerApiImpl:runBackgroundWorker Background task completed with success: \(isSuccess)")
|
||||
}
|
||||
}
|
||||
|
||||
33
mobile/ios/Runner/Background/FileLogger.swift
Normal file
33
mobile/ios/Runner/Background/FileLogger.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
enum FileLogger {
|
||||
private static let queue = DispatchQueue(label: "app.alextran.immich.FileLogger")
|
||||
private static let isoFormatter: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return f
|
||||
}()
|
||||
|
||||
private static var logFileURL: URL? {
|
||||
guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||
else { return nil }
|
||||
return docs.appendingPathComponent("background_log.txt")
|
||||
}
|
||||
|
||||
static func log(_ message: String) {
|
||||
let line = "[\(isoFormatter.string(from: Date()))] \(message)\n"
|
||||
print(line, terminator: "")
|
||||
queue.async {
|
||||
guard let url = logFileURL, let data = line.data(using: .utf8) else { return }
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
if let handle = try? FileHandle(forWritingTo: url) {
|
||||
defer { try? handle.close() }
|
||||
try? handle.seekToEnd()
|
||||
try? handle.write(contentsOf: data)
|
||||
}
|
||||
} else {
|
||||
try? data.write(to: url, options: .atomic)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,9 @@
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<string>No</string>
|
||||
<true/>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import 'dart:async';
|
||||
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/sync_event.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
@@ -192,22 +191,17 @@ class SyncStreamService {
|
||||
case SyncEntityType.assetV1:
|
||||
final remoteSyncAssets = data.cast<SyncAssetV1>();
|
||||
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
|
||||
await _runWithManageMediaPermission(
|
||||
logContext: "Trashed Assets",
|
||||
action: () async {
|
||||
await _handleRemoteDeleted(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id));
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
final hasPermission = await _localFilesManager.hasManageMediaPermission();
|
||||
if (hasPermission) {
|
||||
await _handleRemoteTrashed(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum));
|
||||
await _applyRemoteRestoreToLocal();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
_logger.warning("sync Trashed Assets cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
}
|
||||
}
|
||||
return;
|
||||
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());
|
||||
case SyncEntityType.assetExifV1:
|
||||
return _syncStreamRepository.updateAssetsExifV1(data.cast());
|
||||
@@ -388,32 +382,28 @@ class SyncStreamService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
Future<void> _handleRemoteTrashed(Iterable<String> checksums) async {
|
||||
if (checksums.isEmpty) {
|
||||
return Future.value();
|
||||
} else {
|
||||
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(remoteIds);
|
||||
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums);
|
||||
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 {
|
||||
_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 {
|
||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||
if (assetsToRestore.isNotEmpty) {
|
||||
@@ -423,21 +413,4 @@ class SyncStreamService {
|
||||
_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();
|
||||
}
|
||||
|
||||
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> remoteIds) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> checksums) async {
|
||||
if (checksums.isEmpty) {
|
||||
return {};
|
||||
}
|
||||
|
||||
final result = <String, List<LocalAsset>>{};
|
||||
|
||||
for (final slice in remoteIds.toSet().slices(kDriftMaxChunk)) {
|
||||
for (final slice in checksums.toSet().slices(kDriftMaxChunk)) {
|
||||
final rows =
|
||||
await (_db.select(_db.localAlbumAssetEntity).join([
|
||||
innerJoin(
|
||||
_db.localAlbumEntity,
|
||||
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
|
||||
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
|
||||
innerJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
|
||||
useColumns: false,
|
||||
),
|
||||
])..where(
|
||||
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
|
||||
_db.remoteAssetEntity.id.isIn(slice),
|
||||
_db.localAssetEntity.checksum.isIn(slice),
|
||||
))
|
||||
.get();
|
||||
|
||||
for (final row in rows) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,21 +2,17 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
class MapBottomSheet extends StatelessWidget {
|
||||
final Key? sheetKey;
|
||||
|
||||
const MapBottomSheet({super.key, this.sheetKey});
|
||||
const MapBottomSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BaseBottomSheet(
|
||||
key: sheetKey,
|
||||
initialChildSize: 0.25,
|
||||
maxChildSize: 0.75,
|
||||
shouldCloseOnMinExtent: false,
|
||||
@@ -53,7 +49,7 @@ class _ScopedMapTimeline extends StatelessWidget {
|
||||
return timelineService;
|
||||
}),
|
||||
],
|
||||
child: const Timeline(appBar: null, bottomSheet: GeneralBottomSheet(minChildSize: 0.23), withScrubber: false),
|
||||
child: const Timeline(appBar: null, bottomSheet: null, withScrubber: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
|
||||
@@ -54,7 +53,6 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
final _reloadMutex = AsyncMutex();
|
||||
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
|
||||
final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25);
|
||||
final GlobalKey _bottomSheetKey = GlobalKey();
|
||||
StreamSubscription? _eventSubscription;
|
||||
|
||||
@override
|
||||
@@ -186,7 +184,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
return Stack(
|
||||
children: [
|
||||
_Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),
|
||||
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset, sheetKey: _bottomSheetKey),
|
||||
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset),
|
||||
_DynamicMyLocationButton(onZoomToLocation: onZoomToLocation, bottomSheetOffset: bottomSheetOffset),
|
||||
],
|
||||
);
|
||||
@@ -226,9 +224,8 @@ class _Map extends StatelessWidget {
|
||||
|
||||
class _DynamicBottomSheet extends StatefulWidget {
|
||||
final ValueNotifier<double> bottomSheetOffset;
|
||||
final GlobalKey sheetKey;
|
||||
|
||||
const _DynamicBottomSheet({required this.bottomSheetOffset, required this.sheetKey});
|
||||
const _DynamicBottomSheet({required this.bottomSheetOffset});
|
||||
|
||||
@override
|
||||
State<_DynamicBottomSheet> createState() => _DynamicBottomSheetState();
|
||||
@@ -239,13 +236,10 @@ class _DynamicBottomSheetState extends State<_DynamicBottomSheet> {
|
||||
Widget build(BuildContext context) {
|
||||
return NotificationListener<DraggableScrollableNotification>(
|
||||
onNotification: (notification) {
|
||||
final sheet = notification.context.findAncestorWidgetOfExactType<BaseBottomSheet>();
|
||||
if (sheet?.key == widget.sheetKey) {
|
||||
widget.bottomSheetOffset.value = notification.extent;
|
||||
}
|
||||
return false;
|
||||
widget.bottomSheetOffset.value = notification.extent;
|
||||
return true;
|
||||
},
|
||||
child: MapBottomSheet(sheetKey: widget.sheetKey),
|
||||
child: const MapBottomSheet(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,7 +469,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
ref.read(timelineStateProvider.notifier).setScrolling(true);
|
||||
},
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
timeline,
|
||||
if (isBottomWidgetVisible)
|
||||
|
||||
12
mobile/openapi/lib/api/people_api.dart
generated
12
mobile/openapi/lib/api/people_api.dart
generated
@@ -183,15 +183,15 @@ class PeopleApi {
|
||||
/// * [String] closestPersonId:
|
||||
/// Closest person ID for similarity search
|
||||
///
|
||||
/// * [int] page:
|
||||
/// * [num] page:
|
||||
/// Page number for pagination
|
||||
///
|
||||
/// * [int] size:
|
||||
/// * [num] size:
|
||||
/// Number of items per page
|
||||
///
|
||||
/// * [bool] withHidden:
|
||||
/// 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
|
||||
final apiPath = r'/people';
|
||||
|
||||
@@ -244,15 +244,15 @@ class PeopleApi {
|
||||
/// * [String] closestPersonId:
|
||||
/// Closest person ID for similarity search
|
||||
///
|
||||
/// * [int] page:
|
||||
/// * [num] page:
|
||||
/// Page number for pagination
|
||||
///
|
||||
/// * [int] size:
|
||||
/// * [num] size:
|
||||
/// Number of items per page
|
||||
///
|
||||
/// * [bool] withHidden:
|
||||
/// 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, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
|
||||
12
mobile/openapi/lib/api/search_api.dart
generated
12
mobile/openapi/lib/api/search_api.dart
generated
@@ -404,10 +404,10 @@ class SearchApi {
|
||||
/// * [List<String>] personIds:
|
||||
/// Filter by person IDs
|
||||
///
|
||||
/// * [int] rating:
|
||||
/// * [num] rating:
|
||||
/// Filter by rating [1-5], or null for unrated
|
||||
///
|
||||
/// * [int] size:
|
||||
/// * [num] size:
|
||||
/// Number of results to return
|
||||
///
|
||||
/// * [String] state:
|
||||
@@ -443,7 +443,7 @@ class SearchApi {
|
||||
///
|
||||
/// * [bool] withExif:
|
||||
/// 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
|
||||
final apiPath = r'/search/large-assets';
|
||||
|
||||
@@ -619,10 +619,10 @@ class SearchApi {
|
||||
/// * [List<String>] personIds:
|
||||
/// Filter by person IDs
|
||||
///
|
||||
/// * [int] rating:
|
||||
/// * [num] rating:
|
||||
/// Filter by rating [1-5], or null for unrated
|
||||
///
|
||||
/// * [int] size:
|
||||
/// * [num] size:
|
||||
/// Number of results to return
|
||||
///
|
||||
/// * [String] state:
|
||||
@@ -658,7 +658,7 @@ class SearchApi {
|
||||
///
|
||||
/// * [bool] withExif:
|
||||
/// 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, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
|
||||
@@ -37,15 +37,12 @@ class AssetBulkUpdateDto {
|
||||
|
||||
/// Relative time offset in seconds
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
int? dateTimeRelative;
|
||||
num? dateTimeRelative;
|
||||
|
||||
/// Asset description
|
||||
///
|
||||
@@ -216,7 +213,7 @@ class AssetBulkUpdateDto {
|
||||
|
||||
return AssetBulkUpdateDto(
|
||||
dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
|
||||
dateTimeRelative: mapValueOfType<int>(json, r'dateTimeRelative'),
|
||||
dateTimeRelative: num.parse('${json[r'dateTimeRelative']}'),
|
||||
description: mapValueOfType<String>(json, r'description'),
|
||||
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
|
||||
ids: json[r'ids'] is Iterable
|
||||
|
||||
@@ -24,26 +24,22 @@ class AssetEditActionItemDtoParameters {
|
||||
/// Height of the crop
|
||||
///
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 9007199254740991
|
||||
int height;
|
||||
num height;
|
||||
|
||||
/// Width of the crop
|
||||
///
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 9007199254740991
|
||||
int width;
|
||||
num width;
|
||||
|
||||
/// Top-Left X coordinate of crop
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 9007199254740991
|
||||
int x;
|
||||
num x;
|
||||
|
||||
/// Top-Left Y coordinate of crop
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 9007199254740991
|
||||
int y;
|
||||
num y;
|
||||
|
||||
/// Rotation angle in degrees
|
||||
num angle;
|
||||
@@ -92,10 +88,10 @@ class AssetEditActionItemDtoParameters {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return AssetEditActionItemDtoParameters(
|
||||
height: mapValueOfType<int>(json, r'height')!,
|
||||
width: mapValueOfType<int>(json, r'width')!,
|
||||
x: mapValueOfType<int>(json, r'x')!,
|
||||
y: mapValueOfType<int>(json, r'y')!,
|
||||
height: num.parse('${json[r'height']}'),
|
||||
width: num.parse('${json[r'width']}'),
|
||||
x: num.parse('${json[r'x']}'),
|
||||
y: num.parse('${json[r'y']}'),
|
||||
angle: num.parse('${json[r'angle']}'),
|
||||
axis: MirrorAxis.fromJson(json[r'axis'])!,
|
||||
);
|
||||
|
||||
14
mobile/openapi/lib/model/asset_response_dto.dart
generated
14
mobile/openapi/lib/model/asset_response_dto.dart
generated
@@ -80,8 +80,7 @@ class AssetResponseDto {
|
||||
/// Asset height
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 9007199254740991
|
||||
int? height;
|
||||
num? height;
|
||||
|
||||
/// Asset ID
|
||||
String id;
|
||||
@@ -166,8 +165,7 @@ class AssetResponseDto {
|
||||
/// Asset width
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 9007199254740991
|
||||
int? width;
|
||||
num? width;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
|
||||
@@ -348,7 +346,9 @@ class AssetResponseDto {
|
||||
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!,
|
||||
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!,
|
||||
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')!,
|
||||
isArchived: mapValueOfType<bool>(json, r'isArchived')!,
|
||||
isEdited: mapValueOfType<bool>(json, r'isEdited')!,
|
||||
@@ -372,7 +372,9 @@ class AssetResponseDto {
|
||||
unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
|
||||
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
|
||||
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;
|
||||
|
||||
20
mobile/openapi/lib/model/crop_parameters.dart
generated
20
mobile/openapi/lib/model/crop_parameters.dart
generated
@@ -22,26 +22,22 @@ class CropParameters {
|
||||
/// Height of the crop
|
||||
///
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 9007199254740991
|
||||
int height;
|
||||
num height;
|
||||
|
||||
/// Width of the crop
|
||||
///
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 9007199254740991
|
||||
int width;
|
||||
num width;
|
||||
|
||||
/// Top-Left X coordinate of crop
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 9007199254740991
|
||||
int x;
|
||||
num x;
|
||||
|
||||
/// Top-Left Y coordinate of crop
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 9007199254740991
|
||||
int y;
|
||||
num y;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is CropParameters &&
|
||||
@@ -79,10 +75,10 @@ class CropParameters {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return CropParameters(
|
||||
height: mapValueOfType<int>(json, r'height')!,
|
||||
width: mapValueOfType<int>(json, r'width')!,
|
||||
x: mapValueOfType<int>(json, r'x')!,
|
||||
y: mapValueOfType<int>(json, r'y')!,
|
||||
height: num.parse('${json[r'height']}'),
|
||||
width: num.parse('${json[r'width']}'),
|
||||
x: num.parse('${json[r'x']}'),
|
||||
y: num.parse('${json[r'y']}'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -27,8 +27,7 @@ class DatabaseBackupConfig {
|
||||
/// Keep last amount
|
||||
///
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 9007199254740991
|
||||
int keepLastAmount;
|
||||
num keepLastAmount;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupConfig &&
|
||||
@@ -65,7 +64,7 @@ class DatabaseBackupConfig {
|
||||
return DatabaseBackupConfig(
|
||||
cronExpression: mapValueOfType<String>(json, r'cronExpression')!,
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
keepLastAmount: mapValueOfType<int>(json, r'keepLastAmount')!,
|
||||
keepLastAmount: num.parse('${json[r'keepLastAmount']}'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -22,10 +22,7 @@ class DatabaseBackupDto {
|
||||
String filename;
|
||||
|
||||
/// Backup file size
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int filesize;
|
||||
num filesize;
|
||||
|
||||
/// Backup timezone
|
||||
String timezone;
|
||||
@@ -64,7 +61,7 @@ class DatabaseBackupDto {
|
||||
|
||||
return DatabaseBackupDto(
|
||||
filename: mapValueOfType<String>(json, r'filename')!,
|
||||
filesize: mapValueOfType<int>(json, r'filesize')!,
|
||||
filesize: num.parse('${json[r'filesize']}'),
|
||||
timezone: mapValueOfType<String>(json, r'timezone')!,
|
||||
);
|
||||
}
|
||||
|
||||
32
mobile/openapi/lib/model/exif_response_dto.dart
generated
32
mobile/openapi/lib/model/exif_response_dto.dart
generated
@@ -52,14 +52,12 @@ class ExifResponseDto {
|
||||
/// Image height in pixels
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 9007199254740991
|
||||
int? exifImageHeight;
|
||||
num? exifImageHeight;
|
||||
|
||||
/// Image width in pixels
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 9007199254740991
|
||||
int? exifImageWidth;
|
||||
num? exifImageWidth;
|
||||
|
||||
/// Exposure time
|
||||
String? exposureTime;
|
||||
@@ -77,10 +75,7 @@ class ExifResponseDto {
|
||||
num? focalLength;
|
||||
|
||||
/// ISO sensitivity
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int? iso;
|
||||
num? iso;
|
||||
|
||||
/// GPS latitude
|
||||
num? latitude;
|
||||
@@ -107,10 +102,7 @@ class ExifResponseDto {
|
||||
String? projectionType;
|
||||
|
||||
/// Rating
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int? rating;
|
||||
num? rating;
|
||||
|
||||
/// State/province name
|
||||
String? state;
|
||||
@@ -300,8 +292,12 @@ class ExifResponseDto {
|
||||
country: mapValueOfType<String>(json, r'country'),
|
||||
dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''),
|
||||
description: mapValueOfType<String>(json, r'description'),
|
||||
exifImageHeight: mapValueOfType<int>(json, r'exifImageHeight'),
|
||||
exifImageWidth: mapValueOfType<int>(json, r'exifImageWidth'),
|
||||
exifImageHeight: json[r'exifImageHeight'] == null
|
||||
? null
|
||||
: num.parse('${json[r'exifImageHeight']}'),
|
||||
exifImageWidth: json[r'exifImageWidth'] == null
|
||||
? null
|
||||
: num.parse('${json[r'exifImageWidth']}'),
|
||||
exposureTime: mapValueOfType<String>(json, r'exposureTime'),
|
||||
fNumber: json[r'fNumber'] == null
|
||||
? null
|
||||
@@ -310,7 +306,9 @@ class ExifResponseDto {
|
||||
focalLength: json[r'focalLength'] == null
|
||||
? null
|
||||
: 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
|
||||
? null
|
||||
: num.parse('${json[r'latitude']}'),
|
||||
@@ -323,7 +321,9 @@ class ExifResponseDto {
|
||||
modifyDate: mapDateTime(json, r'modifyDate', r''),
|
||||
orientation: mapValueOfType<String>(json, r'orientation'),
|
||||
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'),
|
||||
timeZone: mapValueOfType<String>(json, r'timeZone'),
|
||||
);
|
||||
|
||||
@@ -21,13 +21,9 @@ class MachineLearningAvailabilityChecksDto {
|
||||
/// Enabled
|
||||
bool enabled;
|
||||
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int interval;
|
||||
num interval;
|
||||
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int timeout;
|
||||
num timeout;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is MachineLearningAvailabilityChecksDto &&
|
||||
@@ -63,8 +59,8 @@ class MachineLearningAvailabilityChecksDto {
|
||||
|
||||
return MachineLearningAvailabilityChecksDto(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
interval: mapValueOfType<int>(json, r'interval')!,
|
||||
timeout: mapValueOfType<int>(json, r'timeout')!,
|
||||
interval: num.parse('${json[r'interval']}'),
|
||||
timeout: num.parse('${json[r'timeout']}'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -20,10 +20,7 @@ class MaintenanceDetectInstallStorageFolderDto {
|
||||
});
|
||||
|
||||
/// Number of files in the folder
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int files;
|
||||
num files;
|
||||
|
||||
StorageFolder folder;
|
||||
|
||||
@@ -69,7 +66,7 @@ class MaintenanceDetectInstallStorageFolderDto {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return MaintenanceDetectInstallStorageFolderDto(
|
||||
files: mapValueOfType<int>(json, r'files')!,
|
||||
files: num.parse('${json[r'files']}'),
|
||||
folder: StorageFolder.fromJson(json[r'folder'])!,
|
||||
readable: mapValueOfType<bool>(json, r'readable')!,
|
||||
writable: mapValueOfType<bool>(json, r'writable')!,
|
||||
|
||||
@@ -32,15 +32,13 @@ class MaintenanceStatusResponseDto {
|
||||
///
|
||||
String? error;
|
||||
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
int? progress;
|
||||
num? progress;
|
||||
|
||||
///
|
||||
/// 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'])!,
|
||||
active: mapValueOfType<bool>(json, r'active')!,
|
||||
error: mapValueOfType<String>(json, r'error'),
|
||||
progress: mapValueOfType<int>(json, r'progress'),
|
||||
progress: num.parse('${json[r'progress']}'),
|
||||
task: mapValueOfType<String>(json, r'task'),
|
||||
);
|
||||
}
|
||||
|
||||
15
mobile/openapi/lib/model/metadata_search_dto.dart
generated
15
mobile/openapi/lib/model/metadata_search_dto.dart
generated
@@ -215,14 +215,13 @@ class MetadataSearchDto {
|
||||
/// Page number
|
||||
///
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 9007199254740991
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
int? page;
|
||||
num? page;
|
||||
|
||||
/// Filter by person IDs
|
||||
List<String> personIds;
|
||||
@@ -240,7 +239,7 @@ class MetadataSearchDto {
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Maximum value: 5
|
||||
int? rating;
|
||||
num? rating;
|
||||
|
||||
/// Number of results to return
|
||||
///
|
||||
@@ -252,7 +251,7 @@ class MetadataSearchDto {
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
int? size;
|
||||
num? size;
|
||||
|
||||
/// Filter by state/province name
|
||||
String? state;
|
||||
@@ -725,13 +724,15 @@ class MetadataSearchDto {
|
||||
order: AssetOrder.fromJson(json[r'order']),
|
||||
originalFileName: mapValueOfType<String>(json, r'originalFileName'),
|
||||
originalPath: mapValueOfType<String>(json, r'originalPath'),
|
||||
page: mapValueOfType<int>(json, r'page'),
|
||||
page: num.parse('${json[r'page']}'),
|
||||
personIds: json[r'personIds'] is Iterable
|
||||
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
previewPath: mapValueOfType<String>(json, r'previewPath'),
|
||||
rating: mapValueOfType<int>(json, r'rating'),
|
||||
size: mapValueOfType<int>(json, r'size'),
|
||||
rating: json[r'rating'] == null
|
||||
? null
|
||||
: num.parse('${json[r'rating']}'),
|
||||
size: num.parse('${json[r'size']}'),
|
||||
state: mapValueOfType<String>(json, r'state'),
|
||||
tagIds: json[r'tagIds'] is Iterable
|
||||
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
|
||||
10
mobile/openapi/lib/model/random_search_dto.dart
generated
10
mobile/openapi/lib/model/random_search_dto.dart
generated
@@ -147,7 +147,7 @@ class RandomSearchDto {
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Maximum value: 5
|
||||
int? rating;
|
||||
num? rating;
|
||||
|
||||
/// Number of results to return
|
||||
///
|
||||
@@ -159,7 +159,7 @@ class RandomSearchDto {
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
int? size;
|
||||
num? size;
|
||||
|
||||
/// Filter by state/province name
|
||||
String? state;
|
||||
@@ -549,8 +549,10 @@ class RandomSearchDto {
|
||||
personIds: json[r'personIds'] is Iterable
|
||||
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
rating: mapValueOfType<int>(json, r'rating'),
|
||||
size: mapValueOfType<int>(json, r'size'),
|
||||
rating: json[r'rating'] == null
|
||||
? null
|
||||
: num.parse('${json[r'rating']}'),
|
||||
size: num.parse('${json[r'size']}'),
|
||||
state: mapValueOfType<String>(json, r'state'),
|
||||
tagIds: json[r'tagIds'] is Iterable
|
||||
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
|
||||
5
mobile/openapi/lib/model/session_create_dto.dart
generated
5
mobile/openapi/lib/model/session_create_dto.dart
generated
@@ -39,14 +39,13 @@ class SessionCreateDto {
|
||||
/// Session duration in seconds
|
||||
///
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 9007199254740991
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
int? duration;
|
||||
num? duration;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SessionCreateDto &&
|
||||
@@ -95,7 +94,7 @@ class SessionCreateDto {
|
||||
return SessionCreateDto(
|
||||
deviceOS: mapValueOfType<String>(json, r'deviceOS'),
|
||||
deviceType: mapValueOfType<String>(json, r'deviceType'),
|
||||
duration: mapValueOfType<int>(json, r'duration'),
|
||||
duration: num.parse('${json[r'duration']}'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
15
mobile/openapi/lib/model/smart_search_dto.dart
generated
15
mobile/openapi/lib/model/smart_search_dto.dart
generated
@@ -154,14 +154,13 @@ class SmartSearchDto {
|
||||
/// Page number
|
||||
///
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 9007199254740991
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
int? page;
|
||||
num? page;
|
||||
|
||||
/// Filter by person IDs
|
||||
List<String> personIds;
|
||||
@@ -188,7 +187,7 @@ class SmartSearchDto {
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Maximum value: 5
|
||||
int? rating;
|
||||
num? rating;
|
||||
|
||||
/// Number of results to return
|
||||
///
|
||||
@@ -200,7 +199,7 @@ class SmartSearchDto {
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
int? size;
|
||||
num? size;
|
||||
|
||||
/// Filter by state/province name
|
||||
String? state;
|
||||
@@ -584,14 +583,16 @@ class SmartSearchDto {
|
||||
make: mapValueOfType<String>(json, r'make'),
|
||||
model: mapValueOfType<String>(json, r'model'),
|
||||
ocr: mapValueOfType<String>(json, r'ocr'),
|
||||
page: mapValueOfType<int>(json, r'page'),
|
||||
page: num.parse('${json[r'page']}'),
|
||||
personIds: json[r'personIds'] is Iterable
|
||||
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
query: mapValueOfType<String>(json, r'query'),
|
||||
queryAssetId: mapValueOfType<String>(json, r'queryAssetId'),
|
||||
rating: mapValueOfType<int>(json, r'rating'),
|
||||
size: mapValueOfType<int>(json, r'size'),
|
||||
rating: json[r'rating'] == null
|
||||
? null
|
||||
: num.parse('${json[r'rating']}'),
|
||||
size: num.parse('${json[r'size']}'),
|
||||
state: mapValueOfType<String>(json, r'state'),
|
||||
tagIds: json[r'tagIds'] is Iterable
|
||||
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
|
||||
@@ -152,7 +152,7 @@ class StatisticsSearchDto {
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Maximum value: 5
|
||||
int? rating;
|
||||
num? rating;
|
||||
|
||||
/// Filter by state/province name
|
||||
String? state;
|
||||
@@ -479,7 +479,9 @@ class StatisticsSearchDto {
|
||||
personIds: json[r'personIds'] is Iterable
|
||||
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
rating: mapValueOfType<int>(json, r'rating'),
|
||||
rating: json[r'rating'] == null
|
||||
? null
|
||||
: num.parse('${json[r'rating']}'),
|
||||
state: mapValueOfType<String>(json, r'state'),
|
||||
tagIds: json[r'tagIds'] is Iterable
|
||||
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
|
||||
@@ -57,8 +57,7 @@ class SystemConfigOAuthDto {
|
||||
/// Default storage quota
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 9007199254740991
|
||||
int? defaultStorageQuota;
|
||||
num? defaultStorageQuota;
|
||||
|
||||
/// Enabled
|
||||
bool enabled;
|
||||
@@ -201,7 +200,9 @@ class SystemConfigOAuthDto {
|
||||
buttonText: mapValueOfType<String>(json, r'buttonText')!,
|
||||
clientId: mapValueOfType<String>(json, r'clientId')!,
|
||||
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')!,
|
||||
endSessionEndpoint: mapValueOfType<String>(json, r'endSessionEndpoint')!,
|
||||
issuerUrl: mapValueOfType<String>(json, r'issuerUrl')!,
|
||||
|
||||
@@ -34,7 +34,7 @@ class SystemConfigSmtpTransportDto {
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 65535
|
||||
int port;
|
||||
num port;
|
||||
|
||||
/// Whether to use secure connection (TLS/SSL)
|
||||
bool secure;
|
||||
@@ -87,7 +87,7 @@ class SystemConfigSmtpTransportDto {
|
||||
host: mapValueOfType<String>(json, r'host')!,
|
||||
ignoreCert: mapValueOfType<bool>(json, r'ignoreCert')!,
|
||||
password: mapValueOfType<String>(json, r'password')!,
|
||||
port: mapValueOfType<int>(json, r'port')!,
|
||||
port: num.parse('${json[r'port']}'),
|
||||
secure: mapValueOfType<bool>(json, r'secure')!,
|
||||
username: mapValueOfType<String>(json, r'username')!,
|
||||
);
|
||||
|
||||
@@ -26,10 +26,7 @@ class WorkflowActionResponseDto {
|
||||
String id;
|
||||
|
||||
/// Action order
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int order;
|
||||
num order;
|
||||
|
||||
/// Plugin action ID
|
||||
String pluginActionId;
|
||||
@@ -82,7 +79,7 @@ class WorkflowActionResponseDto {
|
||||
return WorkflowActionResponseDto(
|
||||
actionConfig: mapCastOfType<String, Object>(json, r'actionConfig'),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
order: mapValueOfType<int>(json, r'order')!,
|
||||
order: num.parse('${json[r'order']}'),
|
||||
pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!,
|
||||
workflowId: mapValueOfType<String>(json, r'workflowId')!,
|
||||
);
|
||||
|
||||
@@ -26,10 +26,7 @@ class WorkflowFilterResponseDto {
|
||||
String id;
|
||||
|
||||
/// Filter order
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int order;
|
||||
num order;
|
||||
|
||||
/// Plugin filter ID
|
||||
String pluginFilterId;
|
||||
@@ -82,7 +79,7 @@ class WorkflowFilterResponseDto {
|
||||
return WorkflowFilterResponseDto(
|
||||
filterConfig: mapCastOfType<String, Object>(json, r'filterConfig'),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
order: mapValueOfType<int>(json, r'order')!,
|
||||
order: num.parse('${json[r'order']}'),
|
||||
pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!,
|
||||
workflowId: mapValueOfType<String>(json, r'workflowId')!,
|
||||
);
|
||||
|
||||
@@ -419,8 +419,8 @@ void main() {
|
||||
'album-b': [mergedAsset],
|
||||
};
|
||||
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
|
||||
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
|
||||
expect(requestedRemoteIds.toSet(), equals({'remote-1', 'remote-2', 'remote-3'}));
|
||||
final Iterable<String> requestedChecksums = invocation.positionalArguments.first as Iterable<String>;
|
||||
expect(requestedChecksums.toSet(), equals({'checksum-local', 'checksum-merged', 'checksum-remote-only'}));
|
||||
return assetsByAlbum;
|
||||
});
|
||||
|
||||
@@ -482,18 +482,12 @@ void main() {
|
||||
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
|
||||
});
|
||||
|
||||
test("requests local deletions lookup by remote ids 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 {};
|
||||
});
|
||||
|
||||
test("does not request local deletions for permanent remote delete events", () async {
|
||||
final events = [SyncStreamStub.assetDeleteV1];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||
verifyNever(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any()));
|
||||
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
|
||||
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
|
||||
});
|
||||
|
||||
@@ -7964,9 +7964,8 @@
|
||||
"description": "Page number for pagination",
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991,
|
||||
"default": 1,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -7978,7 +7977,7 @@
|
||||
"minimum": 1,
|
||||
"maximum": 1000,
|
||||
"default": 500,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -9373,7 +9372,7 @@
|
||||
],
|
||||
"x-immich-state": "Stable",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"type": "number",
|
||||
"minimum": -1,
|
||||
"maximum": 5,
|
||||
"nullable": true
|
||||
@@ -9387,7 +9386,7 @@
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 1000,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -15637,9 +15636,7 @@
|
||||
},
|
||||
"dateTimeRelative": {
|
||||
"description": "Relative time offset in seconds",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"description": {
|
||||
"description": "Asset description",
|
||||
@@ -16653,10 +16650,9 @@
|
||||
},
|
||||
"height": {
|
||||
"description": "Asset height",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"nullable": true,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"id": {
|
||||
"description": "Asset ID",
|
||||
@@ -16799,10 +16795,9 @@
|
||||
},
|
||||
"width": {
|
||||
"description": "Asset width",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"nullable": true,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -17219,27 +17214,23 @@
|
||||
"properties": {
|
||||
"height": {
|
||||
"description": "Height of the crop",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"width": {
|
||||
"description": "Width of the crop",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"x": {
|
||||
"description": "Top-Left X coordinate of crop",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"y": {
|
||||
"description": "Top-Left Y coordinate of crop",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -17263,9 +17254,8 @@
|
||||
},
|
||||
"keepLastAmount": {
|
||||
"description": "Keep last amount",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -17298,9 +17288,7 @@
|
||||
},
|
||||
"filesize": {
|
||||
"description": "Backup file size",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"timezone": {
|
||||
"description": "Backup timezone",
|
||||
@@ -17639,18 +17627,16 @@
|
||||
"exifImageHeight": {
|
||||
"default": null,
|
||||
"description": "Image height in pixels",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"nullable": true,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"exifImageWidth": {
|
||||
"default": null,
|
||||
"description": "Image width in pixels",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"nullable": true,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"exposureTime": {
|
||||
"default": null,
|
||||
@@ -17681,10 +17667,8 @@
|
||||
"iso": {
|
||||
"default": null,
|
||||
"description": "ISO sensitivity",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"nullable": true,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"latitude": {
|
||||
"default": null,
|
||||
@@ -17738,10 +17722,8 @@
|
||||
"rating": {
|
||||
"default": null,
|
||||
"description": "Rating",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"nullable": true,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"state": {
|
||||
"default": null,
|
||||
@@ -18168,14 +18150,10 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"interval": {
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"timeout": {
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -18225,9 +18203,7 @@
|
||||
"properties": {
|
||||
"files": {
|
||||
"description": "Number of files in the folder",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"folder": {
|
||||
"$ref": "#/components/schemas/StorageFolder"
|
||||
@@ -18270,9 +18246,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"progress": {
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"task": {
|
||||
"type": "string"
|
||||
@@ -18749,9 +18723,8 @@
|
||||
},
|
||||
"page": {
|
||||
"description": "Page number",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"personIds": {
|
||||
"description": "Filter by person IDs",
|
||||
@@ -18771,7 +18744,7 @@
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"type": "number",
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v1",
|
||||
@@ -18793,7 +18766,7 @@
|
||||
"description": "Number of results to return",
|
||||
"maximum": 1000,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"state": {
|
||||
"description": "Filter by state/province name",
|
||||
@@ -20624,7 +20597,7 @@
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"type": "number",
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v1",
|
||||
@@ -20646,7 +20619,7 @@
|
||||
"description": "Number of results to return",
|
||||
"maximum": 1000,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"state": {
|
||||
"description": "Filter by state/province name",
|
||||
@@ -21464,9 +21437,8 @@
|
||||
},
|
||||
"duration": {
|
||||
"description": "Session duration in seconds",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -21980,9 +21952,8 @@
|
||||
},
|
||||
"page": {
|
||||
"description": "Page number",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"personIds": {
|
||||
"description": "Filter by person IDs",
|
||||
@@ -22008,7 +21979,7 @@
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"type": "number",
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v1",
|
||||
@@ -22030,7 +22001,7 @@
|
||||
"description": "Number of results to return",
|
||||
"maximum": 1000,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"state": {
|
||||
"description": "Filter by state/province name",
|
||||
@@ -22268,7 +22239,7 @@
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"type": "number",
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v1",
|
||||
@@ -24400,10 +24371,9 @@
|
||||
},
|
||||
"defaultStorageQuota": {
|
||||
"description": "Default storage quota",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"nullable": true,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Enabled",
|
||||
@@ -24578,7 +24548,7 @@
|
||||
"description": "SMTP server port",
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"secure": {
|
||||
"description": "Whether to use secure connection (TLS/SSL)",
|
||||
@@ -25996,9 +25966,7 @@
|
||||
},
|
||||
"order": {
|
||||
"description": "Action order",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"pluginActionId": {
|
||||
"description": "Plugin action ID",
|
||||
@@ -26097,9 +26065,7 @@
|
||||
},
|
||||
"order": {
|
||||
"description": "Filter order",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"pluginFilterId": {
|
||||
"description": "Plugin filter ID",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "2.7.5",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.1+sha512.05ba3c1d5d1c18f68df06470d74055e62d41fc110a0c660db1b2dfb2785327f04cf0f68345d4609bc52089e7fa0343c31593b2f9594e2c5d5da426230acc9820",
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
|
||||
"engines": {
|
||||
"pnpm": ">=10.0.0"
|
||||
}
|
||||
|
||||
1968
pnpm-lock.yaml
generated
1968
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -46,15 +46,15 @@
|
||||
"@nestjs/platform-express": "^11.0.4",
|
||||
"@nestjs/platform-socket.io": "^11.0.4",
|
||||
"@nestjs/schedule": "^6.0.0",
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/swagger": "11.2.6",
|
||||
"@nestjs/websockets": "^11.0.4",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "^2.0.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.215.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.215.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.63.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.61.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.67.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.62.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.60.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.66.0",
|
||||
"@opentelemetry/resources": "^2.0.1",
|
||||
"@opentelemetry/sdk-metrics": "^2.0.1",
|
||||
"@opentelemetry/sdk-node": "^0.215.0",
|
||||
@@ -114,7 +114,7 @@
|
||||
"thumbhash": "^0.1.1",
|
||||
"transformation-matrix": "^3.1.0",
|
||||
"ua-parser-js": "^2.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"uuid": "^11.1.0",
|
||||
"validator": "^13.12.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
@@ -49,7 +49,7 @@ describe(SearchController.name, () => {
|
||||
});
|
||||
|
||||
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(body).toEqual(errorDto.badRequest(['[size] Too small: expected number to be >=1']));
|
||||
});
|
||||
|
||||
@@ -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)'),
|
||||
livePhotoVideoId: z.string().nullish().describe('Live photo video ID'),
|
||||
hasMetadata: z.boolean().describe('Whether asset has metadata'),
|
||||
width: z.int().min(0).nullable().describe('Asset width'),
|
||||
height: z.int().min(0).nullable().describe('Asset height'),
|
||||
width: z.number().min(0).nullable().describe('Asset width'),
|
||||
height: z.number().min(0).nullable().describe('Asset height'),
|
||||
})
|
||||
.meta({ id: 'SanitizedAssetResponseDto' });
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ const UpdateAssetBaseSchema = z
|
||||
const AssetBulkUpdateBaseSchema = UpdateAssetBaseSchema.extend({
|
||||
ids: z.array(z.uuidv4()).describe('Asset IDs to update'),
|
||||
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)'),
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import z from 'zod';
|
||||
const DatabaseBackupSchema = z
|
||||
.object({
|
||||
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'),
|
||||
})
|
||||
.meta({ id: 'DatabaseBackupDto' });
|
||||
|
||||
@@ -21,10 +21,10 @@ const MirrorAxisSchema = z.enum(['horizontal', 'vertical']).describe('Axis to mi
|
||||
|
||||
const CropParametersSchema = z
|
||||
.object({
|
||||
x: z.int().min(0).describe('Top-Left X coordinate of crop'),
|
||||
y: z.int().min(0).describe('Top-Left Y coordinate of crop'),
|
||||
width: z.int().min(1).describe('Width of the crop'),
|
||||
height: z.int().min(1).describe('Height of the crop'),
|
||||
x: z.number().min(0).describe('Top-Left X coordinate of crop'),
|
||||
y: z.number().min(0).describe('Top-Left Y coordinate of crop'),
|
||||
width: z.number().min(1).describe('Width of the crop'),
|
||||
height: z.number().min(1).describe('Height of the crop'),
|
||||
})
|
||||
.meta({ id: 'CropParameters' });
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ export const ExifResponseSchema = z
|
||||
.object({
|
||||
make: z.string().nullish().default(null).describe('Camera make'),
|
||||
model: z.string().nullish().default(null).describe('Camera model'),
|
||||
exifImageWidth: z.int().min(0).nullish().default(null).describe('Image width in pixels'),
|
||||
exifImageHeight: z.int().min(0).nullish().default(null).describe('Image height in pixels'),
|
||||
exifImageWidth: z.number().min(0).nullish().default(null).describe('Image width 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'),
|
||||
orientation: z.string().nullish().default(null).describe('Image orientation'),
|
||||
// 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'),
|
||||
fNumber: z.number().nullish().default(null).describe('F-number (aperture)'),
|
||||
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'),
|
||||
latitude: z.number().nullish().default(null).describe('GPS latitude'),
|
||||
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'),
|
||||
description: z.string().nullish().default(null).describe('Image description'),
|
||||
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')
|
||||
.meta({ id: 'ExifResponseDto' });
|
||||
|
||||
@@ -29,7 +29,7 @@ const MaintenanceStatusResponseSchema = z
|
||||
.object({
|
||||
active: z.boolean(),
|
||||
action: MaintenanceActionSchema,
|
||||
progress: z.int().optional(),
|
||||
progress: z.number().optional(),
|
||||
task: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
@@ -40,7 +40,7 @@ const MaintenanceDetectInstallStorageFolderSchema = z
|
||||
folder: StorageFolderSchema,
|
||||
readable: z.boolean().describe('Whether the folder is readable'),
|
||||
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' });
|
||||
|
||||
|
||||
@@ -51,8 +51,8 @@ const PersonSearchSchema = z
|
||||
withHidden: stringToBool.optional().describe('Include hidden people'),
|
||||
closestPersonId: z.uuidv4().optional().describe('Closest person 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'),
|
||||
size: z.coerce.number().int().min(1).max(1000).default(500).describe('Number of items per page'),
|
||||
page: z.coerce.number().min(1).default(1).describe('Page number for pagination'),
|
||||
size: z.coerce.number().min(1).max(1000).default(500).describe('Number of items per page'),
|
||||
})
|
||||
.meta({ id: 'PersonSearchDto' });
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ const BaseSearchSchema = z.object({
|
||||
tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'),
|
||||
albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'),
|
||||
rating: z
|
||||
.int()
|
||||
.number()
|
||||
.min(-1)
|
||||
.max(5)
|
||||
.nullish()
|
||||
@@ -52,7 +52,7 @@ const BaseSearchSchema = z.object({
|
||||
const BaseSearchWithResultsSchema = BaseSearchSchema.extend({
|
||||
withDeleted: z.boolean().optional().describe('Include deleted assets'),
|
||||
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({
|
||||
@@ -62,7 +62,7 @@ const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
|
||||
|
||||
const LargeAssetSearchSchema = BaseSearchWithResultsSchema.extend({
|
||||
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' });
|
||||
|
||||
const MetadataSearchSchema = RandomSearchSchema.extend({
|
||||
@@ -75,7 +75,7 @@ const MetadataSearchSchema = RandomSearchSchema.extend({
|
||||
thumbnailPath: z.string().optional().describe('Filter by thumbnail file path'),
|
||||
encodedVideoPath: z.string().optional().describe('Filter by encoded video file path'),
|
||||
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' });
|
||||
|
||||
const StatisticsSearchSchema = BaseSearchSchema.extend({
|
||||
@@ -86,7 +86,7 @@ const SmartSearchSchema = BaseSearchWithResultsSchema.extend({
|
||||
query: z.string().trim().optional().describe('Natural language search query'),
|
||||
queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'),
|
||||
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' });
|
||||
|
||||
const SearchPlacesSchema = z
|
||||
|
||||
@@ -4,7 +4,7 @@ import z from 'zod';
|
||||
|
||||
const SessionCreateSchema = z
|
||||
.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'),
|
||||
deviceOS: z.string().optional().describe('Device OS'),
|
||||
})
|
||||
|
||||
@@ -51,7 +51,7 @@ const DatabaseBackupSchema = z
|
||||
.object({
|
||||
enabled: configBool.describe('Enabled'),
|
||||
cronExpression: cronExpressionSchema,
|
||||
keepLastAmount: z.int().min(1).describe('Keep last amount'),
|
||||
keepLastAmount: z.number().min(1).describe('Keep last amount'),
|
||||
})
|
||||
.meta({ id: 'DatabaseBackupConfig' });
|
||||
|
||||
@@ -130,8 +130,8 @@ const SystemConfigLoggingSchema = z
|
||||
const MachineLearningAvailabilityChecksSchema = z
|
||||
.object({
|
||||
enabled: configBool.describe('Enabled'),
|
||||
timeout: z.int(),
|
||||
interval: z.int(),
|
||||
timeout: z.number(),
|
||||
interval: z.number(),
|
||||
})
|
||||
.meta({ id: 'MachineLearningAvailabilityChecksDto' });
|
||||
|
||||
@@ -180,7 +180,7 @@ const SystemConfigOAuthSchema = z
|
||||
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethodSchema,
|
||||
timeout: z.int().min(1).describe('Timeout'),
|
||||
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'),
|
||||
issuerUrl: z
|
||||
.string()
|
||||
@@ -254,7 +254,7 @@ const SystemConfigSmtpTransportSchema = z
|
||||
.object({
|
||||
ignoreCert: configBool.describe('Whether to ignore SSL certificate errors'),
|
||||
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)'),
|
||||
username: z.string().describe('SMTP username'),
|
||||
password: z.string().describe('SMTP password'),
|
||||
|
||||
@@ -46,7 +46,7 @@ const WorkflowFilterResponseSchema = z
|
||||
workflowId: z.string().describe('Workflow ID'),
|
||||
pluginFilterId: z.string().describe('Plugin filter ID'),
|
||||
filterConfig: FilterConfigSchema.nullable(),
|
||||
order: z.int().describe('Filter order'),
|
||||
order: z.number().describe('Filter order'),
|
||||
})
|
||||
.meta({ id: 'WorkflowFilterResponseDto' });
|
||||
|
||||
@@ -56,7 +56,7 @@ const WorkflowActionResponseSchema = z
|
||||
workflowId: z.string().describe('Workflow ID'),
|
||||
pluginActionId: z.string().describe('Plugin action ID'),
|
||||
actionConfig: ActionConfigSchema.nullable(),
|
||||
order: z.int().describe('Action order'),
|
||||
order: z.number().describe('Action order'),
|
||||
})
|
||||
.meta({ id: 'WorkflowActionResponseDto' });
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export enum ImmichHeader {
|
||||
SharedLinkKey = 'x-immich-share-key',
|
||||
SharedLinkSlug = 'x-immich-share-slug',
|
||||
Checksum = 'x-immich-checksum',
|
||||
CorrelationId = 'X-Correlation-ID',
|
||||
Cid = 'x-immich-cid',
|
||||
}
|
||||
|
||||
export enum ImmichQuery {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/co
|
||||
import { Response } from 'express';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { ZodSerializationException, ZodValidationException } from 'nestjs-zod';
|
||||
import { ImmichHeader } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { logGlobalError } from 'src/utils/logger';
|
||||
import { ZodError } from 'zod';
|
||||
@@ -17,13 +16,18 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
|
||||
}
|
||||
|
||||
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) {
|
||||
const { status, body } = this.fromError(error);
|
||||
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) {
|
||||
const status = error.getStatus();
|
||||
const response = error.getResponse();
|
||||
const body: Record<string, unknown> =
|
||||
typeof response === 'string' ? { message: response } : { ...(response as object) };
|
||||
let body = error.getResponse();
|
||||
|
||||
// unclear what circumstances would return a string
|
||||
if (typeof body === 'string') {
|
||||
body = { message: body };
|
||||
}
|
||||
|
||||
// handle both request and response validation errors
|
||||
if (error instanceof ZodValidationException || error instanceof ZodSerializationException) {
|
||||
const zodError = error.getZodError();
|
||||
if (zodError instanceof ZodError && zodError.issues.length > 0) {
|
||||
body['message'] = zodError.issues.map((issue) =>
|
||||
issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message,
|
||||
);
|
||||
body = {
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -301,9 +301,11 @@ const getEnv = (): EnvData => {
|
||||
mount: true,
|
||||
generateId: true,
|
||||
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);
|
||||
res.header(ImmichHeader.CorrelationId, cid);
|
||||
res.header(ImmichHeader.Cid, cid);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -196,7 +196,6 @@ describe(AlbumService.name, () => {
|
||||
expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {});
|
||||
expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id);
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId]), false);
|
||||
expect(mocks.event.emit).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', {
|
||||
id: album.id,
|
||||
userId: albumUser.userId,
|
||||
|
||||
@@ -114,6 +114,7 @@ export class AlbumService extends BaseService {
|
||||
throw new BadRequestException('Cannot share album with owner');
|
||||
}
|
||||
}
|
||||
albumUsers.unshift({ userId: auth.user.id, role: AlbumUserRole.Owner });
|
||||
|
||||
const allowedAssetIdsSet = await this.checkAccess({
|
||||
auth,
|
||||
@@ -132,7 +133,7 @@ export class AlbumService extends BaseService {
|
||||
order: getPreferences(userMetadata).albums.defaultAssetOrder,
|
||||
},
|
||||
assetIds,
|
||||
[{ userId: auth.user.id, role: AlbumUserRole.Owner }, ...albumUsers],
|
||||
albumUsers,
|
||||
auth.user.id,
|
||||
);
|
||||
|
||||
|
||||
@@ -2,36 +2,68 @@ import { expect } from 'vitest';
|
||||
|
||||
export const errorDto = {
|
||||
unauthorized: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Authentication required',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
forbidden: {
|
||||
error: 'Forbidden',
|
||||
statusCode: 403,
|
||||
message: expect.any(String),
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
missingPermission: (permission: string) => ({
|
||||
error: 'Forbidden',
|
||||
statusCode: 403,
|
||||
message: `Missing required permission: ${permission}`,
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
wrongPassword: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: 'Wrong password',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidToken: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid user token',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidShareKey: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid share key',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidSharePassword: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid password',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
badRequest: (message: any = null) => ({
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: message ?? expect.anything(),
|
||||
}),
|
||||
noPermission: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('Not found or no'),
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
incorrectLogin: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Incorrect email or password',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
alreadyHasAdmin: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: 'The server already has an admin',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -246,6 +246,8 @@ export const factory = {
|
||||
date: newDate,
|
||||
responses: {
|
||||
badRequest: (message: any = null) => ({
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: message ?? expect.anything(),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
const setSelectedDate = (value: DateTime | undefined) => {
|
||||
selectedPresetValue = null; // Clear preset when manually setting date
|
||||
expiresAt = value ? value.toUTC().toISO() : null;
|
||||
expiresAt = value ? value.toISO() : null;
|
||||
};
|
||||
|
||||
const selectPreset = (value: number) => {
|
||||
@@ -44,8 +44,8 @@
|
||||
expiresAt = null;
|
||||
return;
|
||||
}
|
||||
const newDate = DateTime.now().plus({ milliseconds: value });
|
||||
expiresAt = newDate.toUTC().toISO();
|
||||
const newDate = DateTime.now().plus(value);
|
||||
expiresAt = newDate.toISO();
|
||||
};
|
||||
|
||||
const isSelected = (value: number) => {
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.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 { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
@@ -24,15 +25,26 @@
|
||||
type AssetResponseDto,
|
||||
} from '@immich/sdk';
|
||||
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 { t } from 'svelte-i18n';
|
||||
import { slide } from 'svelte/transition';
|
||||
import ImageThumbnail from '../assets/thumbnail/ImageThumbnail.svelte';
|
||||
import PersonSidePanel from '../faces-page/PersonSidePanel.svelte';
|
||||
import OnEvents from '../OnEvents.svelte';
|
||||
import UserAvatar from '../shared-components/UserAvatar.svelte';
|
||||
import AlbumListItemDetails from './AlbumListItemDetails.svelte';
|
||||
import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
@@ -42,6 +54,9 @@
|
||||
let { asset, currentAlbum = null }: Props = $props();
|
||||
|
||||
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(
|
||||
(() => {
|
||||
const lat = asset.exifInfo?.latitude;
|
||||
@@ -149,7 +164,108 @@
|
||||
|
||||
<DetailPanelDescription {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">
|
||||
{#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">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
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 { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
||||
import { calculateBoundingBoxMatrix, getOcrBoundingBoxes, type Point } from '$lib/utils/ocr-utils';
|
||||
import {
|
||||
@@ -54,9 +55,14 @@
|
||||
let viewer: Viewer;
|
||||
|
||||
let animationInProgress: { cancel: () => void } | undefined;
|
||||
let previousFaces: Faces[] = [];
|
||||
|
||||
$effect(() => {
|
||||
const faces: Faces[] = assetViewerManager.highlightedFaces;
|
||||
const boundingBoxesUnsubscribe = boundingBoxesArray.subscribe((faces: Faces[]) => {
|
||||
// Debounce; don't do anything when the data didn't actually change.
|
||||
if (faces === previousFaces) {
|
||||
return;
|
||||
}
|
||||
previousFaces = faces;
|
||||
|
||||
if (animationInProgress) {
|
||||
animationInProgress.cancel();
|
||||
@@ -99,7 +105,7 @@
|
||||
textureX: x,
|
||||
textureY: y,
|
||||
zoom: Math.min(viewer.getZoomLevel(), 75),
|
||||
speed: 500,
|
||||
speed: 500, // duration in ms
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -241,8 +247,7 @@
|
||||
if (viewer) {
|
||||
viewer.destroy();
|
||||
}
|
||||
assetViewerManager.clearHighlightedFaces();
|
||||
assetViewerManager.hideHiddenPeople();
|
||||
boundingBoxesUnsubscribe();
|
||||
assetViewerManager.zoom = 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
import Thumbhash from '$lib/components/Thumbhash.svelte';
|
||||
import OcrBoundingBox from '$lib/components/asset-viewer/OcrBoundingBox.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 { 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 { handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
@@ -49,13 +50,12 @@
|
||||
untrack(() => {
|
||||
assetViewerManager.resetZoomState();
|
||||
visibleImageReady = false;
|
||||
assetViewerManager.clearHighlightedFaces();
|
||||
$boundingBoxesArray = [];
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
assetViewerManager.clearHighlightedFaces();
|
||||
assetViewerManager.hideHiddenPeople();
|
||||
$boundingBoxesArray = [];
|
||||
});
|
||||
|
||||
let containerWidth = $state(0);
|
||||
@@ -74,13 +74,15 @@
|
||||
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);
|
||||
|
||||
let visibleBoxes = $state<BoundingBox[]>([]);
|
||||
let visibleBoundingBoxes = $state<Faces[]>([]);
|
||||
$effect(() => {
|
||||
if (isHighlighting) {
|
||||
visibleBoxes = highlightedBoxes;
|
||||
visibleBoundingBoxes = $boundingBoxesArray;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -158,9 +160,6 @@
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const map = new Map<Faces, string>();
|
||||
for (const person of asset.people ?? []) {
|
||||
if (person.isHidden && !assetViewerManager.isShowingHiddenPeople) {
|
||||
continue;
|
||||
}
|
||||
for (const face of person.faces ?? []) {
|
||||
map.set(face, person.name);
|
||||
}
|
||||
@@ -170,31 +169,35 @@
|
||||
|
||||
const faces = $derived(Array.from(faceToNameMap.keys()));
|
||||
|
||||
const boundingBoxes = $derived.by(() => {
|
||||
if (assetViewerManager.isFaceEditMode || ocrManager.showOverlay) {
|
||||
return [];
|
||||
const handleImageMouseMove = (event: MouseEvent) => {
|
||||
$boundingBoxesArray = [];
|
||||
if (!assetViewerManager.imgRef || !element || assetViewerManager.isFaceEditMode || ocrManager.showOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
const knownBoxes = getBoundingBox(faces, overlaySize);
|
||||
const result = knownBoxes.map((box, index) => ({
|
||||
...box,
|
||||
face: faces[index],
|
||||
name: faceToNameMap.get(faces[index]),
|
||||
}));
|
||||
const natural = getNaturalSize(assetViewerManager.imgRef);
|
||||
const scaled = scaleToFit(natural, container);
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
|
||||
if (assetViewerManager.highlightedFaces.length === 0) {
|
||||
return result;
|
||||
const contentOffsetX = (container.width - scaled.width) / 2;
|
||||
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 unassignedFaces = assetViewerManager.highlightedFaces.filter((f) => !knownIds.has(f.id));
|
||||
const unassignedBoxes = getBoundingBox(unassignedFaces, overlaySize);
|
||||
for (let i = 0; i < unassignedBoxes.length; i++) {
|
||||
result.push({ ...unassignedBoxes[i], face: unassignedFaces[i], name: undefined });
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
const handleImageMouseLeave = () => {
|
||||
$boundingBoxesArray = [];
|
||||
};
|
||||
</script>
|
||||
|
||||
<AssetViewerEvents {onCopy} {onZoom} {onFaceEditModeChange} />
|
||||
@@ -215,6 +218,8 @@
|
||||
bind:clientHeight={containerHeight}
|
||||
role="presentation"
|
||||
ondblclick={onZoom}
|
||||
onmousemove={handleImageMouseMove}
|
||||
onmouseleave={handleImageMouseLeave}
|
||||
use:zoomImageAction={{ zoomTarget: adaptiveImage }}
|
||||
{...useSwipe((event) => onSwipe?.(event))}
|
||||
>
|
||||
@@ -256,27 +261,22 @@
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="rgba(0,0,0,0.4)" mask="url(#face-dim-mask)" />
|
||||
</svg>
|
||||
</div>
|
||||
{#each boundingBoxes as boundingbox (boundingbox.id)}
|
||||
{@const isActive = assetViewerManager.highlightedFaces.some((f) => f.id === boundingbox.id)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute pointer-events-auto rounded-lg {isActive && 'border-solid border-white border-3'}"
|
||||
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}
|
||||
{#each visibleBoxes as boundingbox, index (boundingbox.id)}
|
||||
<div
|
||||
class="absolute border-solid border-white border-3 rounded-lg"
|
||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||
></div>
|
||||
{#if faceToNameMap.get(visibleBoundingBoxes[index])}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap shadow-lg"
|
||||
style="top: {boundingbox.height + 4}px; right: 0;"
|
||||
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
|
||||
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left +
|
||||
boundingbox.width}px; transform: translateX(-100%);"
|
||||
>
|
||||
{boundingbox.name}
|
||||
{faceToNameMap.get(visibleBoundingBoxes[index])}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
||||
<OcrBoundingBox {ocrBox} />
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { zoomImageToBase64 } from '$lib/utils/people-utils';
|
||||
@@ -238,15 +239,15 @@
|
||||
{:else}
|
||||
{#each peopleWithFaces as face, index (face.id)}
|
||||
{@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
|
||||
role="button"
|
||||
tabindex={index}
|
||||
class="absolute start-0 top-0 h-22.5 w-22.5 cursor-default"
|
||||
onfocus={() => assetViewerManager.setHighlightedFaces([peopleWithFaces[index]])}
|
||||
onpointerenter={() => assetViewerManager.setHighlightedFaces([peopleWithFaces[index]])}
|
||||
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
|
||||
onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
||||
onmouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
||||
onmouseleave={() => ($boundingBoxesArray = [])}
|
||||
>
|
||||
<div class="relative">
|
||||
{#if selectedPersonToCreate[face.id]}
|
||||
|
||||
@@ -45,7 +45,10 @@
|
||||
|
||||
await deleteAssets(
|
||||
force,
|
||||
(assetIds) => timelineManager.removeAssets(assetIds),
|
||||
(assetIds) => {
|
||||
timelineManager.removeAssets(assetIds);
|
||||
eventManager.emit('AssetsDelete', assetIds);
|
||||
},
|
||||
selectedAssets,
|
||||
force ? undefined : (assets) => timelineManager.upsertAssets(assets),
|
||||
);
|
||||
|
||||
@@ -8,16 +8,6 @@ import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
|
||||
import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
|
||||
import { PersistedLocalStorage } from '$lib/utils/persisted';
|
||||
|
||||
export interface Faces {
|
||||
id: string;
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
boundingBoxX1: number;
|
||||
boundingBoxX2: number;
|
||||
boundingBoxY1: number;
|
||||
boundingBoxY2: number;
|
||||
}
|
||||
|
||||
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
|
||||
const isShowAssetPath = new PersistedLocalStorage<boolean>('asset-viewer-show-path', false);
|
||||
|
||||
@@ -58,8 +48,6 @@ class AssetViewerManager extends BaseEventManager<Events> {
|
||||
#isEditFacesPanelOpen = $state(false);
|
||||
#viewingAssetStoreState = $state<AssetResponseDto>();
|
||||
#viewState = $state<boolean>(false);
|
||||
#highlightedFaces = $state<Faces[]>([]);
|
||||
#showingHiddenPeople = $state(false);
|
||||
gridScrollTarget = $state<AssetGridRouteSearchParams | null | undefined>();
|
||||
|
||||
get asset() {
|
||||
@@ -221,31 +209,6 @@ class AssetViewerManager extends BaseEventManager<Events> {
|
||||
this.closeFaceEditMode();
|
||||
this.closeEditFacesPanel();
|
||||
}
|
||||
|
||||
get highlightedFaces() {
|
||||
return this.#highlightedFaces;
|
||||
}
|
||||
|
||||
setHighlightedFaces(faces: Faces[]) {
|
||||
this.#highlightedFaces = faces;
|
||||
}
|
||||
|
||||
clearHighlightedFaces() {
|
||||
this.#highlightedFaces = [];
|
||||
}
|
||||
|
||||
get isShowingHiddenPeople() {
|
||||
return this.#showingHiddenPeople;
|
||||
}
|
||||
|
||||
toggleHiddenPeople() {
|
||||
this.#showingHiddenPeople = !this.#showingHiddenPeople;
|
||||
}
|
||||
|
||||
hideHiddenPeople() {
|
||||
this.#showingHiddenPeople = false;
|
||||
}
|
||||
|
||||
setAsset(asset: AssetResponseDto) {
|
||||
this.#viewingAssetStoreState = asset;
|
||||
this.#viewState = true;
|
||||
|
||||
@@ -33,8 +33,6 @@ class MemoryManager {
|
||||
if (authManager.authenticated) {
|
||||
void this.initialize();
|
||||
}
|
||||
|
||||
this.scheduleHourlyRefresh();
|
||||
}
|
||||
|
||||
ready() {
|
||||
@@ -134,29 +132,6 @@ class MemoryManager {
|
||||
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
|
||||
this.memories = memories.filter((memory) => memory.assets.length > 0);
|
||||
}
|
||||
|
||||
private scheduleHourlyRefresh() {
|
||||
const now = DateTime.utc();
|
||||
let nextEvent = now.set({ minute: 0, second: 5 });
|
||||
|
||||
if (nextEvent <= now) {
|
||||
nextEvent = nextEvent.plus({ hours: 1 });
|
||||
}
|
||||
|
||||
const initialDelay = nextEvent.diff(now).as('milliseconds');
|
||||
|
||||
setTimeout(() => {
|
||||
this.#loading = this.load();
|
||||
|
||||
// Schedule subsequent events hourly
|
||||
setInterval(
|
||||
() => {
|
||||
this.#loading = this.load();
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
}, initialDelay);
|
||||
}
|
||||
}
|
||||
|
||||
export const memoryManager = new MemoryManager();
|
||||
|
||||
13
web/src/lib/stores/people.store.ts
Normal file
13
web/src/lib/stores/people.store.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface Faces {
|
||||
id: string;
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
boundingBoxX1: number;
|
||||
boundingBoxX2: number;
|
||||
boundingBoxY1: number;
|
||||
boundingBoxY2: number;
|
||||
}
|
||||
|
||||
export const boundingBoxesArray = writable<Faces[]>([]);
|
||||
@@ -80,8 +80,6 @@ websocket
|
||||
.on('on_new_release', (event) => eventManager.emit('ReleaseEvent', event))
|
||||
.on('on_session_delete', () => eventManager.emit('SessionDelete'))
|
||||
.on('on_user_delete', (id) => eventManager.emit('UserAdminDeleted', { id }))
|
||||
.on('on_asset_delete', (asset) => eventManager.emit('AssetsDelete', [asset]))
|
||||
.on('on_asset_trash', (assets) => eventManager.emit('AssetsDelete', assets))
|
||||
.on('on_asset_update', (asset) => eventManager.emit('AssetUpdate', asset))
|
||||
.on('on_person_thumbnail', (id) => eventManager.emit('PersonThumbnailReady', { id }))
|
||||
.on('on_notification', () => notificationManager.refresh())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Faces } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import type { Faces } from '$lib/stores/people.store';
|
||||
import type { Size } from '$lib/utils/container-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk';
|
||||
import type { Faces } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import type { Faces } from '$lib/stores/people.store';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { mapNormalizedRectToContent, type Rect, type Size } from '$lib/utils/container-utils';
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import LargeAssetData from './LargeAssetData.svelte';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { getNextAsset, getPreviousAsset, navigateToAsset } from '$lib/utils/asset-utils';
|
||||
import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -18,7 +17,7 @@
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let assets = $state(data.assets);
|
||||
let assets = $derived(data.assets);
|
||||
let asset = $derived(data.asset);
|
||||
|
||||
$effect(() => {
|
||||
@@ -37,19 +36,13 @@
|
||||
return asset;
|
||||
};
|
||||
|
||||
const preAction = async (payload: Action) => {
|
||||
const onAction = (payload: Action) => {
|
||||
if (payload.type == 'trash') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
(await navigateToAsset(assetCursor?.nextAsset)) ||
|
||||
(await navigateToAsset(assetCursor?.previousAsset)) ||
|
||||
assetViewerManager.showAssetViewer(false);
|
||||
assets = assets.filter((a) => a.id != payload.asset.id);
|
||||
assetViewerManager.showAssetViewer(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onAssetsDelete = (assetIds: string[]) => {
|
||||
assets = assets.filter(({ id }) => !assetIds.includes(id));
|
||||
};
|
||||
|
||||
const onViewAsset = async (asset: AssetResponseDto) => {
|
||||
await navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
};
|
||||
@@ -61,11 +54,9 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<OnEvents {onAssetsDelete} />
|
||||
|
||||
<UserPageLayout title={data.meta.title} scrollbar={true}>
|
||||
<div class="grid gap-2 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
|
||||
{#if assets && assets.length > 0}
|
||||
{#if assets && data.assets.length > 0}
|
||||
{#each assets as asset (asset.id)}
|
||||
<LargeAssetData {asset} {onViewAsset} />
|
||||
{/each}
|
||||
@@ -84,7 +75,7 @@
|
||||
cursor={assetCursor}
|
||||
showNavigation={assets.length > 1}
|
||||
{onRandom}
|
||||
{preAction}
|
||||
{onAction}
|
||||
onClose={() => {
|
||||
assetViewerManager.showAssetViewer(false);
|
||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
||||
|
||||
Reference in New Issue
Block a user