mirror of
https://github.com/immich-app/immich.git
synced 2026-06-02 21:05:21 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32ac4f694c |
@@ -141,7 +141,6 @@ describe('/server', () => {
|
||||
maintenanceMode: false,
|
||||
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
minFaces: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -230,21 +230,6 @@ describe('/users', () => {
|
||||
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
|
||||
expect(after).toMatchObject({ download: { includeEmbeddedVideos: true } });
|
||||
});
|
||||
|
||||
it('should update minimum face count to display people', async () => {
|
||||
const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
|
||||
expect(before).toMatchObject({ people: { minimumFaces: 3 } });
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put('/users/me/preferences')
|
||||
.send({ people: { minimumFaces: 2 } })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ people: { minimumFaces: 2 } });
|
||||
|
||||
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
|
||||
expect(after).toMatchObject({ people: { minimumFaces: 2 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /users/:id', () => {
|
||||
|
||||
@@ -1592,8 +1592,6 @@
|
||||
"merge_people_prompt": "Do you want to merge these people? This action is irreversible.",
|
||||
"merge_people_successfully": "Merge people successfully",
|
||||
"merged_people_count": "Merged {count, plural, one {# person} other {# people}}",
|
||||
"minFaces": "Minimum faces",
|
||||
"minFaces_description": "The minimum number of recognized faces for a person to be displayed",
|
||||
"minimize": "Minimize",
|
||||
"minute": "Minute",
|
||||
"minutes": "Minutes",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
ARG DEVICE=cpu
|
||||
|
||||
FROM python:3.11-bookworm@sha256:121d86b6d08752968a7dddbc708849e5f3a839bbff47f32212b46d2a1d842bab AS builder-cpu
|
||||
FROM python:3.11-bookworm@sha256:970c99f886b839fc8829289040c1845dadaf2cae46b37acc7710333158ec29b4 AS builder-cpu
|
||||
|
||||
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS builder-openvino
|
||||
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS builder-openvino
|
||||
|
||||
FROM builder-cpu AS builder-cuda
|
||||
|
||||
@@ -39,12 +39,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy
|
||||
|
||||
FROM python:3.11-slim-bookworm@sha256:8dca233de9f3d9bb410665f00a4da6dd06f331083137e0e98ccf227236fcc438 AS prod-cpu
|
||||
FROM python:3.11-slim-bookworm@sha256:9c6f90801e6b68e772b7c0ca74260cbf7af9f320acec894e26fccdaccfbe3b47 AS prod-cpu
|
||||
|
||||
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
||||
MACHINE_LEARNING_MODEL_ARENA=false
|
||||
|
||||
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS prod-openvino
|
||||
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS prod-openvino
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||
|
||||
@@ -49,7 +49,6 @@ try:
|
||||
str(settings.http_keepalive_timeout_s),
|
||||
"--graceful-timeout",
|
||||
"10",
|
||||
"--no-control-socket",
|
||||
],
|
||||
) as cmd:
|
||||
cmd.wait()
|
||||
|
||||
@@ -12,7 +12,7 @@ from zipfile import BadZipFile
|
||||
|
||||
import orjson
|
||||
from fastapi import Depends, FastAPI, File, Form, HTTPException
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from fastapi.responses import ORJSONResponse, PlainTextResponse
|
||||
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile
|
||||
from PIL.Image import Image
|
||||
from pydantic import ValidationError
|
||||
@@ -32,7 +32,6 @@ from .schemas import (
|
||||
ModelIdentity,
|
||||
ModelTask,
|
||||
ModelType,
|
||||
ORJSONResponse,
|
||||
PipelineRequest,
|
||||
T,
|
||||
)
|
||||
|
||||
@@ -89,9 +89,7 @@ class OpenClipTextualEncoder(BaseCLIPTextualEncoder):
|
||||
|
||||
tokenizer: Tokenizer = Tokenizer.from_file(self.tokenizer_file_path.as_posix())
|
||||
|
||||
pad_id = tokenizer.token_to_id(pad_token)
|
||||
if pad_id is None:
|
||||
raise ValueError(f"Pad token '{pad_token}' not found in tokenizer vocab")
|
||||
pad_id: int = tokenizer.token_to_id(pad_token)
|
||||
tokenizer.enable_padding(length=context_length, pad_token=pad_token, pad_id=pad_id)
|
||||
tokenizer.enable_truncation(max_length=context_length)
|
||||
|
||||
|
||||
@@ -3,16 +3,9 @@ from typing import Any, Literal, Protocol, TypeGuard, TypeVar
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
import orjson
|
||||
from fastapi.responses import JSONResponse
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
|
||||
class ORJSONResponse(JSONResponse):
|
||||
def render(self, content: Any) -> bytes:
|
||||
return orjson.dumps(content, option=orjson.OPT_SERIALIZE_NUMPY)
|
||||
|
||||
|
||||
class StrEnum(str, Enum):
|
||||
value: str
|
||||
|
||||
|
||||
Generated
+450
-498
File diff suppressed because it is too large
Load Diff
+131
-11
@@ -207,6 +207,18 @@ enum class PlatformAssetPlaybackStyle(val raw: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
enum class EditState(val raw: Int) {
|
||||
NOT_EDITED(0),
|
||||
EDITED(1),
|
||||
UNKNOWN(2);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): EditState? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class PlatformAsset (
|
||||
val id: String,
|
||||
@@ -472,6 +484,52 @@ data class CloudIdResult (
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class BaseResource (
|
||||
val path: String,
|
||||
val sha1: String,
|
||||
val sizeBytes: Long,
|
||||
val mimeType: String
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): BaseResource {
|
||||
val path = pigeonVar_list[0] as String
|
||||
val sha1 = pigeonVar_list[1] as String
|
||||
val sizeBytes = pigeonVar_list[2] as Long
|
||||
val mimeType = pigeonVar_list[3] as String
|
||||
return BaseResource(path, sha1, sizeBytes, mimeType)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
path,
|
||||
sha1,
|
||||
sizeBytes,
|
||||
mimeType,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as BaseResource
|
||||
return MessagesPigeonUtils.deepEquals(this.path, other.path) && MessagesPigeonUtils.deepEquals(this.sha1, other.sha1) && MessagesPigeonUtils.deepEquals(this.sizeBytes, other.sizeBytes) && MessagesPigeonUtils.deepEquals(this.mimeType, other.mimeType)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.path)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.sha1)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.sizeBytes)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.mimeType)
|
||||
return result
|
||||
}
|
||||
}
|
||||
private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
@@ -481,30 +539,40 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
}
|
||||
}
|
||||
130.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
PlatformAsset.fromList(it)
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
EditState.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
131.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
PlatformAlbum.fromList(it)
|
||||
PlatformAsset.fromList(it)
|
||||
}
|
||||
}
|
||||
132.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SyncDelta.fromList(it)
|
||||
PlatformAlbum.fromList(it)
|
||||
}
|
||||
}
|
||||
133.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
HashResult.fromList(it)
|
||||
SyncDelta.fromList(it)
|
||||
}
|
||||
}
|
||||
134.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
HashResult.fromList(it)
|
||||
}
|
||||
}
|
||||
135.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
CloudIdResult.fromList(it)
|
||||
}
|
||||
}
|
||||
136.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
BaseResource.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
@@ -514,26 +582,34 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
is PlatformAsset -> {
|
||||
is EditState -> {
|
||||
stream.write(130)
|
||||
writeValue(stream, value.toList())
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
is PlatformAlbum -> {
|
||||
is PlatformAsset -> {
|
||||
stream.write(131)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SyncDelta -> {
|
||||
is PlatformAlbum -> {
|
||||
stream.write(132)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is HashResult -> {
|
||||
is SyncDelta -> {
|
||||
stream.write(133)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is CloudIdResult -> {
|
||||
is HashResult -> {
|
||||
stream.write(134)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is CloudIdResult -> {
|
||||
stream.write(135)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is BaseResource -> {
|
||||
stream.write(136)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
@@ -555,6 +631,8 @@ interface NativeSyncApi {
|
||||
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
|
||||
fun getBaseResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit)
|
||||
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit)
|
||||
|
||||
companion object {
|
||||
/** The codec used by NativeSyncApi. */
|
||||
@@ -786,6 +864,48 @@ interface NativeSyncApi {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val assetIdArg = args[0] as String
|
||||
val allowNetworkAccessArg = args[1] as Boolean
|
||||
api.getBaseResource(assetIdArg, allowNetworkAccessArg) { result: Result<BaseResource?> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val assetIdArg = args[0] as String
|
||||
val allowNetworkAccessArg = args[1] as Boolean
|
||||
api.getEditState(assetIdArg, allowNetworkAccessArg) { result: Result<EditState> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,4 +476,14 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// Android has no Photos-style edit original to stack; iOS-only.
|
||||
fun getBaseResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit) {
|
||||
callback(Result.success(null))
|
||||
}
|
||||
|
||||
// iOS-only; Android assets never carry a Photos-style edit.
|
||||
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit) {
|
||||
callback(Result.success(EditState.NOT_EDITED))
|
||||
}
|
||||
}
|
||||
|
||||
+3411
File diff suppressed because it is too large
Load Diff
Generated
+118
-10
@@ -183,6 +183,12 @@ enum PlatformAssetPlaybackStyle: Int {
|
||||
case videoLooping = 5
|
||||
}
|
||||
|
||||
enum EditState: Int {
|
||||
case notEdited = 0
|
||||
case edited = 1
|
||||
case unknown = 2
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct PlatformAsset: Hashable {
|
||||
var id: String
|
||||
@@ -458,6 +464,52 @@ struct CloudIdResult: Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct BaseResource: Hashable {
|
||||
var path: String
|
||||
var sha1: String
|
||||
var sizeBytes: Int64
|
||||
var mimeType: String
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
static func fromList(_ pigeonVar_list: [Any?]) -> BaseResource? {
|
||||
let path = pigeonVar_list[0] as! String
|
||||
let sha1 = pigeonVar_list[1] as! String
|
||||
let sizeBytes = pigeonVar_list[2] as! Int64
|
||||
let mimeType = pigeonVar_list[3] as! String
|
||||
|
||||
return BaseResource(
|
||||
path: path,
|
||||
sha1: sha1,
|
||||
sizeBytes: sizeBytes,
|
||||
mimeType: mimeType
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
return [
|
||||
path,
|
||||
sha1,
|
||||
sizeBytes,
|
||||
mimeType,
|
||||
]
|
||||
}
|
||||
static func == (lhs: BaseResource, rhs: BaseResource) -> Bool {
|
||||
if Swift.type(of: lhs) != Swift.type(of: rhs) {
|
||||
return false
|
||||
}
|
||||
return deepEqualsMessages(lhs.path, rhs.path) && deepEqualsMessages(lhs.sha1, rhs.sha1) && deepEqualsMessages(lhs.sizeBytes, rhs.sizeBytes) && deepEqualsMessages(lhs.mimeType, rhs.mimeType)
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine("BaseResource")
|
||||
deepHashMessages(value: path, hasher: &hasher)
|
||||
deepHashMessages(value: sha1, hasher: &hasher)
|
||||
deepHashMessages(value: sizeBytes, hasher: &hasher)
|
||||
deepHashMessages(value: mimeType, hasher: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||
override func readValue(ofType type: UInt8) -> Any? {
|
||||
switch type {
|
||||
@@ -468,15 +520,23 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||
}
|
||||
return nil
|
||||
case 130:
|
||||
return PlatformAsset.fromList(self.readValue() as! [Any?])
|
||||
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
|
||||
if let enumResultAsInt = enumResultAsInt {
|
||||
return EditState(rawValue: enumResultAsInt)
|
||||
}
|
||||
return nil
|
||||
case 131:
|
||||
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
||||
return PlatformAsset.fromList(self.readValue() as! [Any?])
|
||||
case 132:
|
||||
return SyncDelta.fromList(self.readValue() as! [Any?])
|
||||
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
||||
case 133:
|
||||
return HashResult.fromList(self.readValue() as! [Any?])
|
||||
return SyncDelta.fromList(self.readValue() as! [Any?])
|
||||
case 134:
|
||||
return HashResult.fromList(self.readValue() as! [Any?])
|
||||
case 135:
|
||||
return CloudIdResult.fromList(self.readValue() as! [Any?])
|
||||
case 136:
|
||||
return BaseResource.fromList(self.readValue() as! [Any?])
|
||||
default:
|
||||
return super.readValue(ofType: type)
|
||||
}
|
||||
@@ -488,21 +548,27 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
|
||||
if let value = value as? PlatformAssetPlaybackStyle {
|
||||
super.writeByte(129)
|
||||
super.writeValue(value.rawValue)
|
||||
} else if let value = value as? PlatformAsset {
|
||||
} else if let value = value as? EditState {
|
||||
super.writeByte(130)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? PlatformAlbum {
|
||||
super.writeValue(value.rawValue)
|
||||
} else if let value = value as? PlatformAsset {
|
||||
super.writeByte(131)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? SyncDelta {
|
||||
} else if let value = value as? PlatformAlbum {
|
||||
super.writeByte(132)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? HashResult {
|
||||
} else if let value = value as? SyncDelta {
|
||||
super.writeByte(133)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? CloudIdResult {
|
||||
} else if let value = value as? HashResult {
|
||||
super.writeByte(134)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? CloudIdResult {
|
||||
super.writeByte(135)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? BaseResource {
|
||||
super.writeByte(136)
|
||||
super.writeValue(value.toList())
|
||||
} else {
|
||||
super.writeValue(value)
|
||||
}
|
||||
@@ -539,6 +605,8 @@ protocol NativeSyncApi {
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
|
||||
func getBaseResource(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<BaseResource?, Error>) -> Void)
|
||||
func getEditState(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<EditState, Error>) -> Void)
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
@@ -757,5 +825,45 @@ class NativeSyncApiSetup {
|
||||
} else {
|
||||
getCloudIdForAssetIdsChannel.setMessageHandler(nil)
|
||||
}
|
||||
let getBaseResourceChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
if let api = api {
|
||||
getBaseResourceChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let assetIdArg = args[0] as! String
|
||||
let allowNetworkAccessArg = args[1] as! Bool
|
||||
api.getBaseResource(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getBaseResourceChannel.setMessageHandler(nil)
|
||||
}
|
||||
let getEditStateChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
if let api = api {
|
||||
getEditStateChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let assetIdArg = args[0] as! String
|
||||
let allowNetworkAccessArg = args[1] as! Bool
|
||||
api.getEditState(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getEditStateChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Photos
|
||||
import CryptoKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct AssetWrapper: Hashable, Equatable {
|
||||
let asset: PlatformAsset
|
||||
@@ -419,4 +420,169 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
}
|
||||
return mappings;
|
||||
}
|
||||
|
||||
func getBaseResource(
|
||||
assetId: String,
|
||||
allowNetworkAccess: Bool,
|
||||
completion: @escaping (Result<BaseResource?, Error>) -> Void
|
||||
) {
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
|
||||
return self.completeWhenActive(for: completion, with: .success(nil))
|
||||
}
|
||||
|
||||
let resources = PHAssetResource.assetResources(for: asset)
|
||||
let state = await Self.classifyEdit(resources: resources, allowNetworkAccess: allowNetworkAccess)
|
||||
guard state == .edited, let original = resources.first(where: { $0.type == .photo }) else {
|
||||
return self.completeWhenActive(for: completion, with: .success(nil))
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await self.streamBaseResource(
|
||||
resource: original,
|
||||
localId: asset.localIdentifier,
|
||||
allowNetworkAccess: allowNetworkAccess
|
||||
)
|
||||
self.completeWhenActive(for: completion, with: .success(result))
|
||||
} catch {
|
||||
self.completeWhenActive(for: completion, with: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns whether the asset carries a live Photos edit without reading the photo
|
||||
// itself, only the small adjustment metadata. The revert probe relies on this to
|
||||
// tell "not edited" apart from "couldn't read" (offloaded to iCloud), so it never
|
||||
// mistakes an unreadable edit for a revert.
|
||||
func getEditState(
|
||||
assetId: String,
|
||||
allowNetworkAccess: Bool,
|
||||
completion: @escaping (Result<EditState, Error>) -> Void
|
||||
) {
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
|
||||
// Not in the library, so don't answer "not edited" (the caller acts on that).
|
||||
return self.completeWhenActive(for: completion, with: .success(.unknown))
|
||||
}
|
||||
let state = await Self.classifyEdit(
|
||||
resources: PHAssetResource.assetResources(for: asset),
|
||||
allowNetworkAccess: allowNetworkAccess
|
||||
)
|
||||
self.completeWhenActive(for: completion, with: .success(state))
|
||||
}
|
||||
}
|
||||
|
||||
// adjustmentRenderTypes for a photo with no real edit: a plain capture, a
|
||||
// Photographic Style, or a reverted edit. A real edit changes this value.
|
||||
private static let kNoEditRenderTypes = 27648
|
||||
|
||||
// Works out the edit state from Adjustments.plist only (never reads the photo).
|
||||
// adjustmentRenderTypes is the signal: a real edit moves it off the baseline, while a
|
||||
// plain capture, a Photographic Style, and a reverted edit all sit at the baseline. The
|
||||
// editor id is NOT reliable: com.apple.camera authors both styles and some real edits
|
||||
// (e.g. changing the Photographic Style after capture), so we key off the render types
|
||||
// alone. Cleanup and object-removal write AdjustmentsSecondary.data, which we count as
|
||||
// edited. unknown = couldn't read the plist (offloaded, no network).
|
||||
private static func classifyEdit(resources: [PHAssetResource], allowNetworkAccess: Bool) async -> EditState {
|
||||
if resources.contains(where: { $0.originalFilename == "AdjustmentsSecondary.data" }) {
|
||||
return .edited
|
||||
}
|
||||
guard let adjRes = resources.first(where: { $0.originalFilename == "Adjustments.plist" }) else {
|
||||
return .notEdited
|
||||
}
|
||||
guard let buf = await collectResourceData(adjRes, allowNetworkAccess: allowNetworkAccess),
|
||||
let plist = try? PropertyListSerialization.propertyList(from: buf, options: [], format: nil) as? [String: Any]
|
||||
else {
|
||||
return .unknown
|
||||
}
|
||||
let renderTypes = (plist["adjustmentRenderTypes"] as? NSNumber)?.intValue
|
||||
let isUserEdit = renderTypes != nil && renderTypes != kNoEditRenderTypes
|
||||
return isUserEdit ? .edited : .notEdited
|
||||
}
|
||||
|
||||
private func streamBaseResource(
|
||||
resource: PHAssetResource,
|
||||
localId: String,
|
||||
allowNetworkAccess: Bool
|
||||
) async throws -> BaseResource {
|
||||
let safeId = localId.replacingOccurrences(of: "/", with: "_")
|
||||
let suffix = UTType(resource.uniformTypeIdentifier)?.preferredFilenameExtension ?? "bin"
|
||||
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
.appendingPathComponent("immich_base", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
|
||||
let unique = UUID().uuidString.prefix(8)
|
||||
let tempUrl = tempDir.appendingPathComponent("\(safeId)_\(unique)_base.\(suffix)")
|
||||
|
||||
// Write the resource to disk and hash it chunk by chunk, so a big original (e.g.
|
||||
// ProRAW) never sits fully in memory on the upload thread.
|
||||
FileManager.default.createFile(atPath: tempUrl.path, contents: nil)
|
||||
guard let handle = try? FileHandle(forWritingTo: tempUrl) else {
|
||||
throw NSError(
|
||||
domain: "NativeSyncApi",
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Failed to open temp file for base resource \(localId)"]
|
||||
)
|
||||
}
|
||||
|
||||
var hasher = Insecure.SHA1()
|
||||
var totalBytes: Int64 = 0
|
||||
let options = PHAssetResourceRequestOptions()
|
||||
options.isNetworkAccessAllowed = allowNetworkAccess
|
||||
|
||||
let succeeded = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
|
||||
var writeFailed = false
|
||||
PHAssetResourceManager.default().requestData(
|
||||
for: resource,
|
||||
options: options,
|
||||
dataReceivedHandler: { chunk in
|
||||
if writeFailed { return }
|
||||
do {
|
||||
try handle.write(contentsOf: chunk)
|
||||
hasher.update(data: chunk)
|
||||
totalBytes += Int64(chunk.count)
|
||||
} catch {
|
||||
writeFailed = true
|
||||
}
|
||||
},
|
||||
completionHandler: { error in continuation.resume(returning: error == nil && !writeFailed) }
|
||||
)
|
||||
}
|
||||
|
||||
try? handle.close()
|
||||
|
||||
guard succeeded else {
|
||||
try? FileManager.default.removeItem(at: tempUrl)
|
||||
throw NSError(
|
||||
domain: "NativeSyncApi",
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Failed to read base resource for \(localId)"]
|
||||
)
|
||||
}
|
||||
|
||||
let sha1 = Data(hasher.finalize()).base64EncodedString()
|
||||
let mime = UTType(resource.uniformTypeIdentifier)?.preferredMIMEType ?? "application/octet-stream"
|
||||
return BaseResource(path: tempUrl.path, sha1: sha1, sizeBytes: totalBytes, mimeType: mime)
|
||||
}
|
||||
|
||||
private static func collectResourceData(
|
||||
_ resource: PHAssetResource,
|
||||
allowNetworkAccess: Bool
|
||||
) async -> Data? {
|
||||
let options = PHAssetResourceRequestOptions()
|
||||
options.isNetworkAccessAllowed = allowNetworkAccess
|
||||
var buffer = Data()
|
||||
return await withCheckedContinuation { (continuation: CheckedContinuation<Data?, Never>) in
|
||||
PHAssetResourceManager.default().requestData(
|
||||
for: resource,
|
||||
options: options,
|
||||
dataReceivedHandler: { data in buffer.append(data) },
|
||||
completionHandler: { error in continuation.resume(returning: error == nil ? buffer : nil) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const String kSecuredPinCode = "secured_pin_code";
|
||||
const String kManualUploadGroup = 'manual_upload_group';
|
||||
const String kBackupGroup = 'backup_group';
|
||||
const String kBackupLivePhotoGroup = 'backup_live_photo_group';
|
||||
const String kBackupEditPairGroup = 'backup_edit_pair_group';
|
||||
const String kDownloadGroupImage = 'group_image';
|
||||
const String kDownloadGroupVideo = 'group_video';
|
||||
const String kDownloadGroupLivePhoto = 'group_livephoto';
|
||||
|
||||
@@ -12,6 +12,13 @@ class LocalAsset extends BaseAsset {
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
|
||||
// Remote id of this asset's previous upload; used to stack a new edit under it.
|
||||
final String? priorRemoteId;
|
||||
|
||||
// Local checksum at the last sync action; lets backup skip an already-handled
|
||||
// local whose current render hashes fresh (the iOS revert case).
|
||||
final String? syncedChecksum;
|
||||
|
||||
const LocalAsset({
|
||||
required this.id,
|
||||
String? remoteId,
|
||||
@@ -32,6 +39,8 @@ class LocalAsset extends BaseAsset {
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required super.isEdited,
|
||||
this.priorRemoteId,
|
||||
this.syncedChecksum,
|
||||
}) : remoteAssetId = remoteId;
|
||||
|
||||
@override
|
||||
@@ -120,6 +129,8 @@ class LocalAsset extends BaseAsset {
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
bool? isEdited,
|
||||
String? priorRemoteId,
|
||||
String? syncedChecksum,
|
||||
}) {
|
||||
return LocalAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -140,6 +151,8 @@ class LocalAsset extends BaseAsset {
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
|
||||
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// Handles an edit that was reverted in Photos. The local was uploaded as an edit
|
||||
/// before but isn't edited now, so flip the stack primary back to the original (via
|
||||
/// prior_remote_id) and mark it handled so we don't re-upload the reverted render.
|
||||
/// Nothing is trashed; all the edits stay in the stack.
|
||||
class EditRevertService {
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final DriftStackRepository _stackRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
final _log = Logger('EditRevertService');
|
||||
|
||||
EditRevertService({
|
||||
required this._nativeSyncApi,
|
||||
required this._stackRepository,
|
||||
required this._localAssetRepository,
|
||||
required this._assetApiRepository,
|
||||
});
|
||||
|
||||
/// Returns true if the asset was a revert and was handled (caller skips the
|
||||
/// upload); false to fall through to the normal upload path.
|
||||
Future<bool> tryHandleRevert(LocalAsset asset) async {
|
||||
if (asset.priorRemoteId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only "not edited" is a revert. `edited` is a fresh edit, so let the pair flow
|
||||
// take it. `unknown` means we couldn't read the adjustment (offloaded to iCloud,
|
||||
// network off); bail there too instead of mistaking an unreadable edit for a
|
||||
// revert and flipping the stack. Network off keeps this a cheap offline read.
|
||||
try {
|
||||
final editState = await _nativeSyncApi.getEditState(asset.id, allowNetworkAccess: false);
|
||||
if (editState != EditState.notEdited) {
|
||||
return false;
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_log.warning("edit-state probe failed for ${asset.id}", error, stack);
|
||||
return false;
|
||||
}
|
||||
|
||||
// It's a revert. Styled photos hit this path because iOS re-encodes the revert to
|
||||
// fresh bytes, so it looks like a new backup candidate and reaches upload.
|
||||
// Non-styled reverts hash back to the base instead, aren't candidates, and get
|
||||
// flipped at hash time in HashService._reconcileReverts. Fresh bytes match nothing
|
||||
// remote, so flip by structure: prior_remote_id is the current primary (the latest
|
||||
// edit), flip it back to the base.
|
||||
final String stackId;
|
||||
final String baseId;
|
||||
try {
|
||||
final foundStack = await _stackRepository.findStackIdByRemoteId(asset.priorRemoteId!);
|
||||
if (foundStack == null) {
|
||||
return false;
|
||||
}
|
||||
final base = await _stackRepository.findStackBaseId(foundStack, excludeId: asset.priorRemoteId!);
|
||||
if (base == null) {
|
||||
return false;
|
||||
}
|
||||
stackId = foundStack;
|
||||
baseId = base;
|
||||
} catch (error, stack) {
|
||||
_log.warning("revert stack lookup failed for ${asset.id}", error, stack);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await _assetApiRepository.setStackPrimary(stackId, baseId);
|
||||
await _stackRepository.setPrimary(stackId, baseId);
|
||||
await _localAssetRepository.markSynced(asset.id, priorRemoteId: baseId, syncedChecksum: asset.checksum);
|
||||
} catch (error, stack) {
|
||||
_log.warning("revert primary flip failed for ${asset.id}", error, stack);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
const String _kHashCancelledCode = "HASH_CANCELLED";
|
||||
@@ -17,6 +19,8 @@ class HashService {
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final DriftStackRepository _stackRepository;
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
final bool Function()? _cancelChecker;
|
||||
final _log = Logger('HashService');
|
||||
|
||||
@@ -25,6 +29,8 @@ class HashService {
|
||||
required this._localAssetRepository,
|
||||
required this._trashedLocalAssetRepository,
|
||||
required this._nativeSyncApi,
|
||||
required this._stackRepository,
|
||||
required this._assetApiRepository,
|
||||
this._cancelChecker,
|
||||
int? batchSize,
|
||||
}) : _batchSize = batchSize ?? kBatchHashFileLimit;
|
||||
@@ -40,6 +46,7 @@ class HashService {
|
||||
|
||||
// Sorted by backupSelection followed by isCloud
|
||||
final localAlbums = await _localAlbumRepository.getBackupAlbums();
|
||||
final hashedIds = <String>{};
|
||||
|
||||
for (final album in localAlbums) {
|
||||
if (isCancelled) {
|
||||
@@ -49,7 +56,7 @@ class HashService {
|
||||
|
||||
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
|
||||
if (assetsToHash.isNotEmpty) {
|
||||
await _hashAssets(album, assetsToHash);
|
||||
await _hashAssets(album, assetsToHash, hashedIds: hashedIds);
|
||||
}
|
||||
}
|
||||
if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) {
|
||||
@@ -57,9 +64,18 @@ class HashService {
|
||||
final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds);
|
||||
if (trashedToHash.isNotEmpty) {
|
||||
final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now());
|
||||
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
|
||||
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true, hashedIds: hashedIds);
|
||||
}
|
||||
}
|
||||
|
||||
// Revert reconcile for non-styled photos: the reverted edit hashes back to the
|
||||
// original's exact bytes, which are already the stack base, so it's not a backup
|
||||
// candidate and never reaches upload. Flip the primary here. Styled photos
|
||||
// re-encode to fresh bytes and get flipped on the upload path instead
|
||||
// (EditRevertService.tryHandleRevert).
|
||||
if (CurrentPlatform.isIOS && hashedIds.isNotEmpty && !isCancelled) {
|
||||
await _reconcileReverts(hashedIds);
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == _kHashCancelledCode) {
|
||||
_log.warning("Hashing cancelled by platform");
|
||||
@@ -76,7 +92,12 @@ class HashService {
|
||||
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
|
||||
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
||||
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
||||
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash, {bool isTrashed = false}) async {
|
||||
Future<void> _hashAssets(
|
||||
LocalAlbum album,
|
||||
List<LocalAsset> assetsToHash, {
|
||||
bool isTrashed = false,
|
||||
required Set<String> hashedIds,
|
||||
}) async {
|
||||
final toHash = <String, LocalAsset>{};
|
||||
|
||||
for (final asset in assetsToHash) {
|
||||
@@ -87,16 +108,21 @@ class HashService {
|
||||
|
||||
toHash[asset.id] = asset;
|
||||
if (toHash.length == _batchSize) {
|
||||
await _processBatch(album, toHash, isTrashed);
|
||||
await _processBatch(album, toHash, isTrashed, hashedIds);
|
||||
toHash.clear();
|
||||
}
|
||||
}
|
||||
|
||||
await _processBatch(album, toHash, isTrashed);
|
||||
await _processBatch(album, toHash, isTrashed, hashedIds);
|
||||
}
|
||||
|
||||
/// Processes a batch of assets.
|
||||
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash, bool isTrashed) async {
|
||||
Future<void> _processBatch(
|
||||
LocalAlbum album,
|
||||
Map<String, LocalAsset> toHash,
|
||||
bool isTrashed,
|
||||
Set<String> hashedIds,
|
||||
) async {
|
||||
if (toHash.isEmpty) {
|
||||
return;
|
||||
}
|
||||
@@ -136,5 +162,33 @@ class HashService {
|
||||
} else {
|
||||
await _localAssetRepository.updateHashes(hashed);
|
||||
}
|
||||
hashedIds.addAll(hashed.keys);
|
||||
}
|
||||
|
||||
Future<void> _reconcileReverts(Set<String> localIds) async {
|
||||
final List<StackReconcileTarget> targets;
|
||||
try {
|
||||
targets = await _stackRepository.findRevertReconcileTargets(localIds);
|
||||
} catch (error, stack) {
|
||||
_log.warning("findRevertReconcileTargets failed", error, stack);
|
||||
return;
|
||||
}
|
||||
|
||||
for (final target in targets) {
|
||||
try {
|
||||
await _assetApiRepository.setStackPrimary(target.stackId, target.newPrimaryId);
|
||||
await _stackRepository.setPrimary(target.stackId, target.newPrimaryId);
|
||||
// Roll priorRemoteId forward to the matched member (now the primary) so a
|
||||
// later edit stacks onto THAT (the current render), not the old edit.
|
||||
await _localAssetRepository.markSynced(
|
||||
target.localAssetId,
|
||||
priorRemoteId: target.newPrimaryId,
|
||||
syncedChecksum: target.localAssetChecksum,
|
||||
);
|
||||
} catch (error, stack) {
|
||||
_log.warning("revert reconcile flip failed for stack ${target.stackId}", error, stack);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,10 @@ class BackgroundSyncManager {
|
||||
} on CanceledError {
|
||||
// Ignore cancellation errors
|
||||
}
|
||||
|
||||
// Stop the local-sync and hash slots too. The revert reconcile runs in the hash
|
||||
// task and shouldn't outlive the session.
|
||||
await cancelLocal();
|
||||
}
|
||||
|
||||
Future<void> cancelLocal() async {
|
||||
@@ -186,6 +190,22 @@ class BackgroundSyncManager {
|
||||
});
|
||||
}
|
||||
|
||||
/// Runs a remote sync guaranteed to observe changes up to now. [syncRemote]
|
||||
/// joins an in-flight sync whose snapshot can pre-date a just-received change
|
||||
/// (e.g. a stack update) and miss it, so wait for any in-flight sync to finish
|
||||
/// first, then run a fresh one.
|
||||
Future<void> runFreshRemoteSync() async {
|
||||
final inflight = _syncTask;
|
||||
if (inflight != null) {
|
||||
try {
|
||||
await inflight.future;
|
||||
} catch (_) {
|
||||
// The in-flight sync's outcome doesn't matter; we only need a fresh one after it.
|
||||
}
|
||||
}
|
||||
await syncRemote();
|
||||
}
|
||||
|
||||
Future<void> syncWebsocketBatchV1(List<dynamic> batchData) {
|
||||
if (_syncWebsocketTask != null) {
|
||||
return _syncWebsocketTask!.future;
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)')
|
||||
class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
const LocalAssetEntity();
|
||||
|
||||
@@ -27,6 +28,14 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
|
||||
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
|
||||
|
||||
// remote id of the previous upload (iOS edit-pair stacking)
|
||||
TextColumn get priorRemoteId => text().nullable()();
|
||||
|
||||
// local checksum at the last sync action. Lets the backup query skip a local
|
||||
// whose current hash matches nothing remote but is still "handled": the iOS
|
||||
// revert case, where the reverted render hashes fresh but is already reconciled.
|
||||
TextColumn get syncedChecksum => text().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
@@ -51,5 +60,7 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
||||
longitude: longitude,
|
||||
cloudId: iCloudId,
|
||||
isEdited: false,
|
||||
priorRemoteId: priorRemoteId,
|
||||
syncedChecksum: syncedChecksum,
|
||||
);
|
||||
}
|
||||
|
||||
+155
-3
@@ -26,6 +26,8 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
|
||||
i0.Value<double?> latitude,
|
||||
i0.Value<double?> longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
i0.Value<String?> priorRemoteId,
|
||||
i0.Value<String?> syncedChecksum,
|
||||
});
|
||||
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i1.LocalAssetEntityCompanion Function({
|
||||
@@ -45,6 +47,8 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i0.Value<double?> latitude,
|
||||
i0.Value<double?> longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
i0.Value<String?> priorRemoteId,
|
||||
i0.Value<String?> syncedChecksum,
|
||||
});
|
||||
|
||||
class $$LocalAssetEntityTableFilterComposer
|
||||
@@ -141,6 +145,16 @@ class $$LocalAssetEntityTableFilterComposer
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<String> get priorRemoteId => $composableBuilder(
|
||||
column: $table.priorRemoteId,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<String> get syncedChecksum => $composableBuilder(
|
||||
column: $table.syncedChecksum,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableOrderingComposer
|
||||
@@ -231,6 +245,16 @@ class $$LocalAssetEntityTableOrderingComposer
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<String> get priorRemoteId => $composableBuilder(
|
||||
column: $table.priorRemoteId,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<String> get syncedChecksum => $composableBuilder(
|
||||
column: $table.syncedChecksum,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableAnnotationComposer
|
||||
@@ -300,6 +324,16 @@ class $$LocalAssetEntityTableAnnotationComposer
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
i0.GeneratedColumn<String> get priorRemoteId => $composableBuilder(
|
||||
column: $table.priorRemoteId,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
i0.GeneratedColumn<String> get syncedChecksum => $composableBuilder(
|
||||
column: $table.syncedChecksum,
|
||||
builder: (column) => column,
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableTableManager
|
||||
@@ -359,6 +393,8 @@ class $$LocalAssetEntityTableTableManager
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||
const i0.Value.absent(),
|
||||
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
||||
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityCompanion(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -376,6 +412,8 @@ class $$LocalAssetEntityTableTableManager
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
playbackStyle: playbackStyle,
|
||||
priorRemoteId: priorRemoteId,
|
||||
syncedChecksum: syncedChecksum,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
@@ -396,6 +434,8 @@ class $$LocalAssetEntityTableTableManager
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||
const i0.Value.absent(),
|
||||
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
||||
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityCompanion.insert(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -413,6 +453,8 @@ class $$LocalAssetEntityTableTableManager
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
playbackStyle: playbackStyle,
|
||||
priorRemoteId: priorRemoteId,
|
||||
syncedChecksum: syncedChecksum,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
@@ -637,6 +679,28 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
).withConverter<i2.AssetPlaybackStyle>(
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle,
|
||||
);
|
||||
static const i0.VerificationMeta _priorRemoteIdMeta =
|
||||
const i0.VerificationMeta('priorRemoteId');
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> priorRemoteId =
|
||||
i0.GeneratedColumn<String>(
|
||||
'prior_remote_id',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const i0.VerificationMeta _syncedChecksumMeta =
|
||||
const i0.VerificationMeta('syncedChecksum');
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> syncedChecksum =
|
||||
i0.GeneratedColumn<String>(
|
||||
'synced_checksum',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [
|
||||
name,
|
||||
@@ -655,6 +719,8 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
priorRemoteId,
|
||||
syncedChecksum,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -759,6 +825,24 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('prior_remote_id')) {
|
||||
context.handle(
|
||||
_priorRemoteIdMeta,
|
||||
priorRemoteId.isAcceptableOrUnknown(
|
||||
data['prior_remote_id']!,
|
||||
_priorRemoteIdMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('synced_checksum')) {
|
||||
context.handle(
|
||||
_syncedChecksumMeta,
|
||||
syncedChecksum.isAcceptableOrUnknown(
|
||||
data['synced_checksum']!,
|
||||
_syncedChecksumMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -839,6 +923,14 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
data['${effectivePrefix}playback_style'],
|
||||
)!,
|
||||
),
|
||||
priorRemoteId: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}prior_remote_id'],
|
||||
),
|
||||
syncedChecksum: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}synced_checksum'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -877,6 +969,8 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final i2.AssetPlaybackStyle playbackStyle;
|
||||
final String? priorRemoteId;
|
||||
final String? syncedChecksum;
|
||||
const LocalAssetEntityData({
|
||||
required this.name,
|
||||
required this.type,
|
||||
@@ -894,6 +988,8 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.playbackStyle,
|
||||
this.priorRemoteId,
|
||||
this.syncedChecksum,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -938,6 +1034,12 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(playbackStyle),
|
||||
);
|
||||
}
|
||||
if (!nullToAbsent || priorRemoteId != null) {
|
||||
map['prior_remote_id'] = i0.Variable<String>(priorRemoteId);
|
||||
}
|
||||
if (!nullToAbsent || syncedChecksum != null) {
|
||||
map['synced_checksum'] = i0.Variable<String>(syncedChecksum);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -967,6 +1069,8 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson(
|
||||
serializer.fromJson<int>(json['playbackStyle']),
|
||||
),
|
||||
priorRemoteId: serializer.fromJson<String?>(json['priorRemoteId']),
|
||||
syncedChecksum: serializer.fromJson<String?>(json['syncedChecksum']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -993,6 +1097,8 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
'playbackStyle': serializer.toJson<int>(
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle),
|
||||
),
|
||||
'priorRemoteId': serializer.toJson<String?>(priorRemoteId),
|
||||
'syncedChecksum': serializer.toJson<String?>(syncedChecksum),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1013,6 +1119,8 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
i0.Value<double?> latitude = const i0.Value.absent(),
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i2.AssetPlaybackStyle? playbackStyle,
|
||||
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
||||
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityData(
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
@@ -1032,6 +1140,12 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
latitude: latitude.present ? latitude.value : this.latitude,
|
||||
longitude: longitude.present ? longitude.value : this.longitude,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
priorRemoteId: priorRemoteId.present
|
||||
? priorRemoteId.value
|
||||
: this.priorRemoteId,
|
||||
syncedChecksum: syncedChecksum.present
|
||||
? syncedChecksum.value
|
||||
: this.syncedChecksum,
|
||||
);
|
||||
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
|
||||
return LocalAssetEntityData(
|
||||
@@ -1061,6 +1175,12 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
playbackStyle: data.playbackStyle.present
|
||||
? data.playbackStyle.value
|
||||
: this.playbackStyle,
|
||||
priorRemoteId: data.priorRemoteId.present
|
||||
? data.priorRemoteId.value
|
||||
: this.priorRemoteId,
|
||||
syncedChecksum: data.syncedChecksum.present
|
||||
? data.syncedChecksum.value
|
||||
: this.syncedChecksum,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1082,7 +1202,9 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
..write('adjustmentTime: $adjustmentTime, ')
|
||||
..write('latitude: $latitude, ')
|
||||
..write('longitude: $longitude, ')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write('playbackStyle: $playbackStyle, ')
|
||||
..write('priorRemoteId: $priorRemoteId, ')
|
||||
..write('syncedChecksum: $syncedChecksum')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -1105,6 +1227,8 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
priorRemoteId,
|
||||
syncedChecksum,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -1125,7 +1249,9 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
other.adjustmentTime == this.adjustmentTime &&
|
||||
other.latitude == this.latitude &&
|
||||
other.longitude == this.longitude &&
|
||||
other.playbackStyle == this.playbackStyle);
|
||||
other.playbackStyle == this.playbackStyle &&
|
||||
other.priorRemoteId == this.priorRemoteId &&
|
||||
other.syncedChecksum == this.syncedChecksum);
|
||||
}
|
||||
|
||||
class LocalAssetEntityCompanion
|
||||
@@ -1146,6 +1272,8 @@ class LocalAssetEntityCompanion
|
||||
final i0.Value<double?> latitude;
|
||||
final i0.Value<double?> longitude;
|
||||
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
|
||||
final i0.Value<String?> priorRemoteId;
|
||||
final i0.Value<String?> syncedChecksum;
|
||||
const LocalAssetEntityCompanion({
|
||||
this.name = const i0.Value.absent(),
|
||||
this.type = const i0.Value.absent(),
|
||||
@@ -1163,6 +1291,8 @@ class LocalAssetEntityCompanion
|
||||
this.latitude = const i0.Value.absent(),
|
||||
this.longitude = const i0.Value.absent(),
|
||||
this.playbackStyle = const i0.Value.absent(),
|
||||
this.priorRemoteId = const i0.Value.absent(),
|
||||
this.syncedChecksum = const i0.Value.absent(),
|
||||
});
|
||||
LocalAssetEntityCompanion.insert({
|
||||
required String name,
|
||||
@@ -1181,6 +1311,8 @@ class LocalAssetEntityCompanion
|
||||
this.latitude = const i0.Value.absent(),
|
||||
this.longitude = const i0.Value.absent(),
|
||||
this.playbackStyle = const i0.Value.absent(),
|
||||
this.priorRemoteId = const i0.Value.absent(),
|
||||
this.syncedChecksum = const i0.Value.absent(),
|
||||
}) : name = i0.Value(name),
|
||||
type = i0.Value(type),
|
||||
id = i0.Value(id);
|
||||
@@ -1201,6 +1333,8 @@ class LocalAssetEntityCompanion
|
||||
i0.Expression<double>? latitude,
|
||||
i0.Expression<double>? longitude,
|
||||
i0.Expression<int>? playbackStyle,
|
||||
i0.Expression<String>? priorRemoteId,
|
||||
i0.Expression<String>? syncedChecksum,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({
|
||||
if (name != null) 'name': name,
|
||||
@@ -1219,6 +1353,8 @@ class LocalAssetEntityCompanion
|
||||
if (latitude != null) 'latitude': latitude,
|
||||
if (longitude != null) 'longitude': longitude,
|
||||
if (playbackStyle != null) 'playback_style': playbackStyle,
|
||||
if (priorRemoteId != null) 'prior_remote_id': priorRemoteId,
|
||||
if (syncedChecksum != null) 'synced_checksum': syncedChecksum,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1239,6 +1375,8 @@ class LocalAssetEntityCompanion
|
||||
i0.Value<double?>? latitude,
|
||||
i0.Value<double?>? longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
|
||||
i0.Value<String?>? priorRemoteId,
|
||||
i0.Value<String?>? syncedChecksum,
|
||||
}) {
|
||||
return i1.LocalAssetEntityCompanion(
|
||||
name: name ?? this.name,
|
||||
@@ -1257,6 +1395,8 @@ class LocalAssetEntityCompanion
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
|
||||
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1317,6 +1457,12 @@ class LocalAssetEntityCompanion
|
||||
),
|
||||
);
|
||||
}
|
||||
if (priorRemoteId.present) {
|
||||
map['prior_remote_id'] = i0.Variable<String>(priorRemoteId.value);
|
||||
}
|
||||
if (syncedChecksum.present) {
|
||||
map['synced_checksum'] = i0.Variable<String>(syncedChecksum.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1338,7 +1484,9 @@ class LocalAssetEntityCompanion
|
||||
..write('adjustmentTime: $adjustmentTime, ')
|
||||
..write('latitude: $latitude, ')
|
||||
..write('longitude: $longitude, ')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write('playbackStyle: $playbackStyle, ')
|
||||
..write('priorRemoteId: $priorRemoteId, ')
|
||||
..write('syncedChecksum: $syncedChecksum')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -1348,3 +1496,7 @@ i0.Index get idxLocalAssetCloudId => i0.Index(
|
||||
'idx_local_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
||||
);
|
||||
i0.Index get idxLocalAssetPriorRemoteId => i0.Index(
|
||||
'idx_local_asset_prior_remote_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)',
|
||||
);
|
||||
|
||||
@@ -7,7 +7,13 @@ import 'local_album_asset.entity.dart';
|
||||
mergedAsset:
|
||||
SELECT
|
||||
rae.id as remote_id,
|
||||
(SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1) as local_id,
|
||||
-- local_id links a remote to its on-device copy, normally by checksum. A reverted iOS
|
||||
-- edit re-encodes to fresh bytes so the checksum no longer matches, but its
|
||||
-- prior_remote_id still points at this remote, so fall back to that.
|
||||
COALESCE(
|
||||
(SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1),
|
||||
(SELECT lae.id FROM local_asset_entity lae WHERE lae.prior_remote_id = rae.id LIMIT 1)
|
||||
) as local_id,
|
||||
rae.name,
|
||||
rae."type",
|
||||
rae.created_at as created_at,
|
||||
@@ -83,6 +89,13 @@ AND NOT EXISTS (
|
||||
INNER JOIN local_album_entity la on laa.album_id = la.id
|
||||
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
|
||||
)
|
||||
-- iOS edit-in-progress / revert: if this local was already uploaded (its
|
||||
-- prior_remote_id resolves to a live remote), hide the local tile so the remote
|
||||
-- (the edit, or the flipped-back original) is the single source of truth. Kills
|
||||
-- the transient 2-tile flicker and stops a reverted local from re-appearing.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids AND rae.deleted_at IS NULL
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $limit;
|
||||
|
||||
@@ -136,6 +149,10 @@ FROM
|
||||
INNER JOIN local_album_entity la on laa.album_id = la.id
|
||||
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
|
||||
)
|
||||
-- iOS edit-in-progress / revert: hide a local already represented by a live remote.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids AND rae.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
GROUP BY bucket_date
|
||||
ORDER BY bucket_date DESC;
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
||||
);
|
||||
$arrayStartIndex += generatedlimit.amountOfVariables;
|
||||
return customSelect(
|
||||
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
|
||||
'SELECT rae.id AS remote_id, COALESCE((SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1), (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.prior_remote_id = rae.id LIMIT 1)) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) AND NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN ($expandeduserIds) AND rae.deleted_at IS NULL) ORDER BY created_at DESC ${generatedlimit.sql}',
|
||||
variables: [
|
||||
for (var $ in userIds) i0.Variable<String>($),
|
||||
...generatedlimit.introducedVariables,
|
||||
@@ -81,7 +81,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
||||
final expandeduserIds = $expandVar($arrayStartIndex, userIds.length);
|
||||
$arrayStartIndex += userIds.length;
|
||||
return customSelect(
|
||||
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2)) GROUP BY bucket_date ORDER BY bucket_date DESC',
|
||||
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) AND NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN ($expandeduserIds) AND rae.deleted_at IS NULL)) GROUP BY bucket_date ORDER BY bucket_date DESC',
|
||||
variables: [
|
||||
i0.Variable<int>(groupBy),
|
||||
for (var $ in userIds) i0.Variable<String>($),
|
||||
|
||||
@@ -58,7 +58,8 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
INNER JOIN main.local_album_entity la on laa.album_id = la.id
|
||||
WHERE laa.asset_id = lae.id
|
||||
AND la.backup_selection = ?3
|
||||
);
|
||||
)
|
||||
AND (lae.checksum IS NULL OR lae.synced_checksum IS NULL OR lae.synced_checksum != lae.checksum);
|
||||
''';
|
||||
|
||||
final row = await _db
|
||||
@@ -104,6 +105,10 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
_db.remoteAssetEntity.checksum.equalsExp(lae.checksum) & _db.remoteAssetEntity.ownerId.equals(userId),
|
||||
),
|
||||
) &
|
||||
// iOS revert: a reverted local hashes fresh (matches nothing remote),
|
||||
// but if it was already reconciled (syncedChecksum == current checksum)
|
||||
// it's handled, so don't re-queue it as a fresh upload.
|
||||
(lae.syncedChecksum.isNull() | lae.syncedChecksum.equalsExp(lae.checksum).not()) &
|
||||
lae.id.isNotInQuery(_getExcludedSubquery()),
|
||||
)
|
||||
..orderBy([(localAsset) => OrderingTerm.desc(localAsset.createdAt)]);
|
||||
|
||||
@@ -98,7 +98,7 @@ class Drift extends $Drift {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 27;
|
||||
int get schemaVersion => 28;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -279,6 +279,11 @@ class Drift extends $Drift {
|
||||
from26To27: (m, v27) async {
|
||||
await customStatement('ALTER TABLE metadata RENAME TO settings');
|
||||
},
|
||||
from27To28: (m, v28) async {
|
||||
await m.addColumn(v28.localAssetEntity, v28.localAssetEntity.priorRemoteId);
|
||||
await m.addColumn(v28.localAssetEntity, v28.localAssetEntity.syncedChecksum);
|
||||
await m.createIndex(v28.idxLocalAssetPriorRemoteId);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
i7.idxLocalAlbumAssetAlbumAsset,
|
||||
i4.idxLocalAssetChecksum,
|
||||
i4.idxLocalAssetCloudId,
|
||||
i4.idxLocalAssetPriorRemoteId,
|
||||
i3.idxStackPrimaryAssetId,
|
||||
i2.uQRemoteAssetsOwnerChecksum,
|
||||
i2.uQRemoteAssetsOwnerLibraryChecksum,
|
||||
|
||||
@@ -14083,6 +14083,612 @@ final class Schema27 extends i0.VersionedSchema {
|
||||
);
|
||||
}
|
||||
|
||||
final class Schema28 extends i0.VersionedSchema {
|
||||
Schema28({required super.database}) : super(version: 28);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAlbumAssetAlbumAsset,
|
||||
idxLocalAssetChecksum,
|
||||
idxLocalAssetCloudId,
|
||||
idxLocalAssetPriorRemoteId,
|
||||
idxStackPrimaryAssetId,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
idxRemoteAssetStackId,
|
||||
idxRemoteAssetOwnerVisibilityDeletedCreated,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
remoteAssetCloudIdEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
assetEditEntity,
|
||||
settings,
|
||||
idxPartnerSharedWithId,
|
||||
idxLatLng,
|
||||
idxRemoteExifCity,
|
||||
idxRemoteAlbumAssetAlbumAsset,
|
||||
idxRemoteAssetCloudId,
|
||||
idxPersonOwnerId,
|
||||
idxAssetFacePersonId,
|
||||
idxAssetFaceAssetId,
|
||||
idxAssetFaceVisiblePerson,
|
||||
idxTrashedLocalAssetChecksum,
|
||||
idxTrashedLocalAssetAlbum,
|
||||
idxAssetEditAssetId,
|
||||
];
|
||||
late final Shape33 userEntity = Shape33(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_112,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape50 remoteAssetEntity = Shape50(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_119,
|
||||
_column_120,
|
||||
_column_121,
|
||||
_column_122,
|
||||
_column_123,
|
||||
_column_124,
|
||||
_column_212,
|
||||
_column_125,
|
||||
_column_126,
|
||||
_column_127,
|
||||
_column_128,
|
||||
_column_129,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape35 stackEntity = Shape35(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'stack_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_121,
|
||||
_column_130,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape51 localAssetEntity = Shape51(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_131,
|
||||
_column_120,
|
||||
_column_132,
|
||||
_column_133,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_137,
|
||||
_column_213,
|
||||
_column_214,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape48 remoteAlbumEntity = Shape48(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_138,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_139,
|
||||
_column_140,
|
||||
_column_141,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape38 localAlbumEntity = Shape38(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_115,
|
||||
_column_142,
|
||||
_column_143,
|
||||
_column_144,
|
||||
_column_145,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape39 localAlbumAssetEntity = Shape39(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_146, _column_147, _column_145],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_local_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||
'idx_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxLocalAssetCloudId = i1.Index(
|
||||
'idx_local_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
||||
);
|
||||
final i1.Index idxLocalAssetPriorRemoteId = i1.Index(
|
||||
'idx_local_asset_prior_remote_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)',
|
||||
);
|
||||
final i1.Index idxStackPrimaryAssetId = i1.Index(
|
||||
'idx_stack_primary_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_library_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||
'idx_remote_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetStackId = i1.Index(
|
||||
'idx_remote_asset_stack_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
|
||||
'idx_remote_asset_owner_visibility_deleted_created',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
|
||||
);
|
||||
late final Shape40 authUserEntity = Shape40(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'auth_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_148,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_149,
|
||||
_column_150,
|
||||
_column_151,
|
||||
_column_152,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape4 userMetadataEntity = Shape4(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_metadata_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||
columns: [_column_153, _column_154, _column_155],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape41 partnerEntity = Shape41(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'partner_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||
columns: [_column_156, _column_157, _column_158],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape42 remoteExifEntity = Shape42(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_exif_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_159,
|
||||
_column_160,
|
||||
_column_161,
|
||||
_column_162,
|
||||
_column_163,
|
||||
_column_164,
|
||||
_column_117,
|
||||
_column_116,
|
||||
_column_165,
|
||||
_column_166,
|
||||
_column_167,
|
||||
_column_168,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_169,
|
||||
_column_170,
|
||||
_column_171,
|
||||
_column_172,
|
||||
_column_173,
|
||||
_column_174,
|
||||
_column_175,
|
||||
_column_176,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_159, _column_177],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||
columns: [_column_177, _column_153, _column_178],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape43 remoteAssetCloudIdEntity = Shape43(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_cloud_id_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_159,
|
||||
_column_179,
|
||||
_column_180,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape44 memoryEntity = Shape44(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_124,
|
||||
_column_121,
|
||||
_column_113,
|
||||
_column_181,
|
||||
_column_182,
|
||||
_column_183,
|
||||
_column_184,
|
||||
_column_185,
|
||||
_column_186,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape12 memoryAssetEntity = Shape12(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||
columns: [_column_159, _column_187],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape45 personEntity = Shape45(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'person_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_121,
|
||||
_column_108,
|
||||
_column_188,
|
||||
_column_189,
|
||||
_column_190,
|
||||
_column_191,
|
||||
_column_192,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape46 assetFaceEntity = Shape46(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_face_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_193,
|
||||
_column_194,
|
||||
_column_195,
|
||||
_column_196,
|
||||
_column_197,
|
||||
_column_198,
|
||||
_column_199,
|
||||
_column_200,
|
||||
_column_201,
|
||||
_column_124,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape18 storeEntity = Shape18(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'store_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_202, _column_203, _column_204],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape47 trashedLocalAssetEntity = Shape47(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trashed_local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_205,
|
||||
_column_131,
|
||||
_column_120,
|
||||
_column_132,
|
||||
_column_206,
|
||||
_column_137,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape32 assetEditEntity = Shape32(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_edit_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_207,
|
||||
_column_208,
|
||||
_column_209,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape49 settings = Shape49(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'settings',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY("key")'],
|
||||
columns: [_column_210, _column_211, _column_115],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxPartnerSharedWithId = i1.Index(
|
||||
'idx_partner_shared_with_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
|
||||
);
|
||||
final i1.Index idxLatLng = i1.Index(
|
||||
'idx_lat_lng',
|
||||
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||
);
|
||||
final i1.Index idxRemoteExifCity = i1.Index(
|
||||
'idx_remote_exif_city',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
|
||||
);
|
||||
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_remote_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetCloudId = i1.Index(
|
||||
'idx_remote_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
||||
);
|
||||
final i1.Index idxPersonOwnerId = i1.Index(
|
||||
'idx_person_owner_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
|
||||
);
|
||||
final i1.Index idxAssetFacePersonId = i1.Index(
|
||||
'idx_asset_face_person_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
|
||||
);
|
||||
final i1.Index idxAssetFaceAssetId = i1.Index(
|
||||
'idx_asset_face_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
|
||||
);
|
||||
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
|
||||
'idx_asset_face_visible_person',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
|
||||
'idx_trashed_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
|
||||
'idx_trashed_local_asset_album',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
|
||||
);
|
||||
final i1.Index idxAssetEditAssetId = i1.Index(
|
||||
'idx_asset_edit_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape51 extends i0.VersionedTable {
|
||||
Shape51({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get name =>
|
||||
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get type =>
|
||||
columnsByName['type']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get updatedAt =>
|
||||
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get width =>
|
||||
columnsByName['width']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get height =>
|
||||
columnsByName['height']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get durationMs =>
|
||||
columnsByName['duration_ms']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get checksum =>
|
||||
columnsByName['checksum']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get isFavorite =>
|
||||
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get orientation =>
|
||||
columnsByName['orientation']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get iCloudId =>
|
||||
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get adjustmentTime =>
|
||||
columnsByName['adjustment_time']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<double> get latitude =>
|
||||
columnsByName['latitude']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get longitude =>
|
||||
columnsByName['longitude']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<int> get playbackStyle =>
|
||||
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get priorRemoteId =>
|
||||
columnsByName['prior_remote_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get syncedChecksum =>
|
||||
columnsByName['synced_checksum']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_213(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'prior_remote_id',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_214(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'synced_checksum',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -14110,6 +14716,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
|
||||
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
|
||||
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
|
||||
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -14243,6 +14850,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from26To27(migrator, schema);
|
||||
return 27;
|
||||
case 27:
|
||||
final schema = Schema28(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from27To28(migrator, schema);
|
||||
return 28;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -14276,6 +14888,7 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
|
||||
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
|
||||
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
|
||||
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -14304,5 +14917,6 @@ i1.OnUpgrade stepByStep({
|
||||
from24To25: from24To25,
|
||||
from25To26: from25To26,
|
||||
from26To27: from26To27,
|
||||
from27To28: from27To28,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -64,6 +64,12 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> markSynced(String localId, {required String priorRemoteId, required String? syncedChecksum}) {
|
||||
return (_db.localAssetEntity.update()..where((e) => e.id.equals(localId))).write(
|
||||
LocalAssetEntityCompanion(priorRemoteId: Value(priorRemoteId), syncedChecksum: Value(syncedChecksum)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> delete(List<String> ids) {
|
||||
if (ids.isEmpty) {
|
||||
return Future.value();
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/stack.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class StackReconcileTarget {
|
||||
final String stackId;
|
||||
final String newPrimaryId;
|
||||
final String localAssetId;
|
||||
final String localAssetChecksum;
|
||||
|
||||
const StackReconcileTarget({
|
||||
required this.stackId,
|
||||
required this.newPrimaryId,
|
||||
required this.localAssetId,
|
||||
required this.localAssetChecksum,
|
||||
});
|
||||
}
|
||||
|
||||
class DriftStackRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
const DriftStackRepository(this._db) : super(_db);
|
||||
@@ -14,6 +30,111 @@ class DriftStackRepository extends DriftDatabaseRepository {
|
||||
return stack.toDto();
|
||||
}).get();
|
||||
}
|
||||
|
||||
// Per local id, find a stack member whose checksum matches the local's current
|
||||
// checksum but isn't the stack primary. That's the revert case: the local hashed
|
||||
// back to the base while the primary still points at the edit.
|
||||
Future<List<StackReconcileTarget>> findRevertReconcileTargets(Iterable<String> localAssetIds) async {
|
||||
final ids = localAssetIds.toSet();
|
||||
if (ids.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final targets = <StackReconcileTarget>[];
|
||||
for (final slice in ids.slices(kDriftMaxChunk)) {
|
||||
final placeholders = List.filled(slice.length, '?').join(',');
|
||||
final rows = await _db
|
||||
.customSelect(
|
||||
'''
|
||||
SELECT
|
||||
s.id AS stack_id,
|
||||
member.id AS new_primary,
|
||||
local.id AS local_id,
|
||||
local.checksum AS local_checksum
|
||||
FROM local_asset_entity local
|
||||
INNER JOIN remote_asset_entity prior ON prior.id = local.prior_remote_id AND prior.deleted_at IS NULL
|
||||
INNER JOIN stack_entity s ON s.id = prior.stack_id
|
||||
INNER JOIN remote_asset_entity member
|
||||
ON member.stack_id = s.id
|
||||
AND member.checksum = local.checksum
|
||||
AND member.deleted_at IS NULL
|
||||
WHERE local.id IN ($placeholders)
|
||||
AND s.primary_asset_id != member.id
|
||||
''',
|
||||
variables: slice.map((id) => Variable<String>(id)).toList(),
|
||||
readsFrom: {_db.localAssetEntity, _db.remoteAssetEntity, _db.stackEntity},
|
||||
)
|
||||
.get();
|
||||
|
||||
for (final row in rows) {
|
||||
targets.add(
|
||||
StackReconcileTarget(
|
||||
stackId: row.read<String>('stack_id'),
|
||||
newPrimaryId: row.read<String>('new_primary'),
|
||||
localAssetId: row.read<String>('local_id'),
|
||||
localAssetChecksum: row.read<String>('local_checksum'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
// True only when we have positive evidence the remote was trashed: a synced
|
||||
// row exists with deleted_at set. A missing row returns false on purpose — a
|
||||
// just-uploaded prior isn't synced into remote_asset_entity yet, and treating
|
||||
// "not synced" as "dead" would re-upload a duplicate base every cycle until
|
||||
// the next remote sync lands.
|
||||
Future<bool> isRemoteTrashed(String remoteId) async {
|
||||
final row = await _db
|
||||
.customSelect(
|
||||
'SELECT 1 FROM remote_asset_entity WHERE id = ? AND deleted_at IS NOT NULL LIMIT 1',
|
||||
variables: [Variable<String>(remoteId)],
|
||||
readsFrom: {_db.remoteAssetEntity},
|
||||
)
|
||||
.getSingleOrNull();
|
||||
return row != null;
|
||||
}
|
||||
|
||||
// The stack a remote asset belongs to, if any. Used by the revert path to find
|
||||
// the stack from prior_remote_id when the reverted bytes can't be checksum-matched.
|
||||
Future<String?> findStackIdByRemoteId(String remoteId) async {
|
||||
final row = await _db
|
||||
.customSelect(
|
||||
'SELECT stack_id FROM remote_asset_entity WHERE id = ? AND stack_id IS NOT NULL AND deleted_at IS NULL',
|
||||
variables: [Variable<String>(remoteId)],
|
||||
readsFrom: {_db.remoteAssetEntity},
|
||||
)
|
||||
.getSingleOrNull();
|
||||
return row?.read<String?>('stack_id');
|
||||
}
|
||||
|
||||
// The stack's original base member to flip back to on revert: the earliest-
|
||||
// uploaded member that isn't the (latest-edit) prior. The base is uploaded
|
||||
// before its edits, so oldest uploaded_at = the original.
|
||||
Future<String?> findStackBaseId(String stackId, {required String excludeId}) async {
|
||||
final row = await _db
|
||||
.customSelect(
|
||||
'''
|
||||
SELECT id FROM remote_asset_entity
|
||||
WHERE stack_id = ? AND id != ? AND deleted_at IS NULL
|
||||
ORDER BY uploaded_at IS NULL, uploaded_at ASC, id ASC
|
||||
LIMIT 1
|
||||
''',
|
||||
variables: [Variable<String>(stackId), Variable<String>(excludeId)],
|
||||
readsFrom: {_db.remoteAssetEntity},
|
||||
)
|
||||
.getSingleOrNull();
|
||||
return row?.read<String?>('id');
|
||||
}
|
||||
|
||||
// Optimistic local primary flip so the timeline updates immediately; the
|
||||
// server's stack-update websocket rewrites it shortly after.
|
||||
Future<void> setPrimary(String stackId, String primaryAssetId) {
|
||||
return (_db.stackEntity.update()..where((e) => e.id.equals(stackId))).write(
|
||||
StackEntityCompanion(primaryAssetId: Value(primaryAssetId)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on StackEntityData {
|
||||
|
||||
+110
-10
@@ -88,6 +88,8 @@ int _deepHash(Object? value) {
|
||||
|
||||
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||
|
||||
enum EditState { notEdited, edited, unknown }
|
||||
|
||||
class PlatformAsset {
|
||||
PlatformAsset({
|
||||
required this.id,
|
||||
@@ -395,6 +397,55 @@ class CloudIdResult {
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class BaseResource {
|
||||
BaseResource({required this.path, required this.sha1, required this.sizeBytes, required this.mimeType});
|
||||
|
||||
String path;
|
||||
|
||||
String sha1;
|
||||
|
||||
int sizeBytes;
|
||||
|
||||
String mimeType;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[path, sha1, sizeBytes, mimeType];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static BaseResource decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return BaseResource(
|
||||
path: result[0]! as String,
|
||||
sha1: result[1]! as String,
|
||||
sizeBytes: result[2]! as int,
|
||||
mimeType: result[3]! as String,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! BaseResource || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(path, other.path) &&
|
||||
_deepEquals(sha1, other.sha1) &&
|
||||
_deepEquals(sizeBytes, other.sizeBytes) &&
|
||||
_deepEquals(mimeType, other.mimeType);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
@@ -405,21 +456,27 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
} else if (value is PlatformAssetPlaybackStyle) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is PlatformAsset) {
|
||||
} else if (value is EditState) {
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is PlatformAlbum) {
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is PlatformAsset) {
|
||||
buffer.putUint8(131);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is SyncDelta) {
|
||||
} else if (value is PlatformAlbum) {
|
||||
buffer.putUint8(132);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is HashResult) {
|
||||
} else if (value is SyncDelta) {
|
||||
buffer.putUint8(133);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is CloudIdResult) {
|
||||
} else if (value is HashResult) {
|
||||
buffer.putUint8(134);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is CloudIdResult) {
|
||||
buffer.putUint8(135);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is BaseResource) {
|
||||
buffer.putUint8(136);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
@@ -432,15 +489,20 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : PlatformAssetPlaybackStyle.values[value];
|
||||
case 130:
|
||||
return PlatformAsset.decode(readValue(buffer)!);
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : EditState.values[value];
|
||||
case 131:
|
||||
return PlatformAlbum.decode(readValue(buffer)!);
|
||||
return PlatformAsset.decode(readValue(buffer)!);
|
||||
case 132:
|
||||
return SyncDelta.decode(readValue(buffer)!);
|
||||
return PlatformAlbum.decode(readValue(buffer)!);
|
||||
case 133:
|
||||
return HashResult.decode(readValue(buffer)!);
|
||||
return SyncDelta.decode(readValue(buffer)!);
|
||||
case 134:
|
||||
return HashResult.decode(readValue(buffer)!);
|
||||
case 135:
|
||||
return CloudIdResult.decode(readValue(buffer)!);
|
||||
case 136:
|
||||
return BaseResource.decode(readValue(buffer)!);
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
@@ -691,4 +753,42 @@ class NativeSyncApi {
|
||||
);
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>();
|
||||
}
|
||||
|
||||
Future<BaseResource?> getBaseResource(String assetId, {bool allowNetworkAccess = false}) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
);
|
||||
return pigeonVar_replyValue as BaseResource?;
|
||||
}
|
||||
|
||||
Future<EditState> getEditState(String assetId, {bool allowNetworkAccess = false}) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as EditState;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,19 +63,16 @@ class SheetTile extends ConsumerWidget {
|
||||
subtitleWidget = null;
|
||||
}
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
title: GestureDetector(onLongPress: () => copyTitle(context, ref), child: titleWidget),
|
||||
titleAlignment: ListTileTitleAlignment.center,
|
||||
leading: leading,
|
||||
trailing: trailing,
|
||||
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
|
||||
subtitle: subtitleWidget,
|
||||
onTap: onTap,
|
||||
),
|
||||
return ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
title: GestureDetector(onLongPress: () => copyTitle(context, ref), child: titleWidget),
|
||||
titleAlignment: ListTileTitleAlignment.center,
|
||||
leading: leading,
|
||||
trailing: trailing,
|
||||
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
|
||||
subtitle: subtitleWidget,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||
import 'package:immich_mobile/domain/services/local_sync.service.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
||||
@@ -11,6 +12,8 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/stack.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
|
||||
@@ -45,11 +48,22 @@ final localSyncServiceProvider = Provider(
|
||||
),
|
||||
);
|
||||
|
||||
final editRevertServiceProvider = Provider(
|
||||
(ref) => EditRevertService(
|
||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||
stackRepository: ref.watch(driftStackProvider),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
assetApiRepository: ref.watch(assetApiRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
final hashServiceProvider = Provider(
|
||||
(ref) => HashService(
|
||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
stackRepository: ref.watch(driftStackProvider),
|
||||
assetApiRepository: ref.watch(assetApiRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -53,9 +53,18 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
);
|
||||
final List<dynamic> _batchedAssetUploadReady = [];
|
||||
|
||||
// Batches a burst of stack updates (one per uploaded edit) into a single
|
||||
// remote sync. Kept separate from _batchDebouncer so the two don't overwrite
|
||||
// each other's pending action.
|
||||
final Debouncer _stackUpdateDebouncer = Debouncer(
|
||||
interval: const Duration(seconds: 2),
|
||||
maxWaitTime: const Duration(seconds: 5),
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_batchDebouncer.dispose();
|
||||
_stackUpdateDebouncer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -105,6 +114,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
|
||||
socket.on('on_config_update', _handleOnConfigUpdate);
|
||||
socket.on('on_new_release', _handleReleaseUpdates);
|
||||
socket.on('on_asset_stack_update', _handleAssetStackUpdate);
|
||||
} catch (e) {
|
||||
dPrint(() => "[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
|
||||
}
|
||||
@@ -188,6 +198,14 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data));
|
||||
}
|
||||
|
||||
// Server stacked/restacked assets (e.g. an edit stacked onto its original).
|
||||
// Pull a fresh remote sync so the stack_entity lands and the timeline shows
|
||||
// the stacked primary instead of briefly hiding the asset. Debounced so a
|
||||
// backup of many edits doesn't trigger a sync per event.
|
||||
void _handleAssetStackUpdate(dynamic _) {
|
||||
_stackUpdateDebouncer.run(() => _ref.read(backgroundSyncProvider).runFreshRemoteSync());
|
||||
}
|
||||
|
||||
void _processBatchedAssetUploadReadyV1() {
|
||||
if (_batchedAssetUploadReady.isEmpty) {
|
||||
return;
|
||||
|
||||
@@ -67,6 +67,10 @@ class AssetApiRepository extends ApiRepository {
|
||||
return _stacksApi.deleteStacks(BulkIdsDto(ids: ids));
|
||||
}
|
||||
|
||||
Future<void> setStackPrimary(String stackId, String primaryAssetId) async {
|
||||
await _stacksApi.updateStack(stackId, StackUpdateDto(primaryAssetId: primaryAssetId));
|
||||
}
|
||||
|
||||
Future<Response> downloadAsset(String id, {required bool edited}) {
|
||||
return _api.downloadAssetWithHttpInfo(id, edited: edited);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ class UploadRepository {
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||
);
|
||||
FileDownloader().registerCallbacks(
|
||||
group: kBackupEditPairGroup,
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||
);
|
||||
FileDownloader().registerCallbacks(
|
||||
group: kManualUploadGroup,
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
|
||||
@@ -9,17 +9,24 @@ import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
||||
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/services/edit_revert.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/stack.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/edit_pair.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -31,6 +38,9 @@ final backgroundUploadServiceProvider = Provider((ref) {
|
||||
ref.watch(localAssetRepository),
|
||||
ref.watch(backupRepositoryProvider),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
ref.watch(nativeSyncApiProvider),
|
||||
ref.watch(editRevertServiceProvider),
|
||||
ref.watch(driftStackProvider),
|
||||
);
|
||||
|
||||
ref.onDispose(service.dispose);
|
||||
@@ -43,13 +53,35 @@ class UploadTaskMetadata {
|
||||
final bool isLivePhotos;
|
||||
final String livePhotoVideoId;
|
||||
|
||||
const UploadTaskMetadata({required this.localAssetId, required this.isLivePhotos, required this.livePhotoVideoId});
|
||||
// Marks the base upload of an edit pair. On completion the chained edit
|
||||
// upload is enqueued with stackParentId = this base's remote id.
|
||||
final bool isEditPair;
|
||||
|
||||
UploadTaskMetadata copyWith({String? localAssetId, bool? isLivePhotos, String? livePhotoVideoId}) {
|
||||
// Path of the native temp file backing this task (the edit base), so it can
|
||||
// be cleaned up on terminal status.
|
||||
final String basePath;
|
||||
|
||||
const UploadTaskMetadata({
|
||||
required this.localAssetId,
|
||||
required this.isLivePhotos,
|
||||
required this.livePhotoVideoId,
|
||||
this.isEditPair = false,
|
||||
this.basePath = '',
|
||||
});
|
||||
|
||||
UploadTaskMetadata copyWith({
|
||||
String? localAssetId,
|
||||
bool? isLivePhotos,
|
||||
String? livePhotoVideoId,
|
||||
bool? isEditPair,
|
||||
String? basePath,
|
||||
}) {
|
||||
return UploadTaskMetadata(
|
||||
localAssetId: localAssetId ?? this.localAssetId,
|
||||
isLivePhotos: isLivePhotos ?? this.isLivePhotos,
|
||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||
isEditPair: isEditPair ?? this.isEditPair,
|
||||
basePath: basePath ?? this.basePath,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +90,8 @@ class UploadTaskMetadata {
|
||||
'localAssetId': localAssetId,
|
||||
'isLivePhotos': isLivePhotos,
|
||||
'livePhotoVideoId': livePhotoVideoId,
|
||||
'isEditPair': isEditPair,
|
||||
'basePath': basePath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,6 +100,8 @@ class UploadTaskMetadata {
|
||||
localAssetId: map['localAssetId'] as String,
|
||||
isLivePhotos: map['isLivePhotos'] as bool,
|
||||
livePhotoVideoId: map['livePhotoVideoId'] as String,
|
||||
isEditPair: (map['isEditPair'] as bool?) ?? false,
|
||||
basePath: (map['basePath'] as String?) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,7 +112,7 @@ class UploadTaskMetadata {
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)';
|
||||
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId, isEditPair: $isEditPair, basePath: $basePath)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant UploadTaskMetadata other) {
|
||||
@@ -86,11 +122,18 @@ class UploadTaskMetadata {
|
||||
|
||||
return other.localAssetId == localAssetId &&
|
||||
other.isLivePhotos == isLivePhotos &&
|
||||
other.livePhotoVideoId == livePhotoVideoId;
|
||||
other.livePhotoVideoId == livePhotoVideoId &&
|
||||
other.isEditPair == isEditPair &&
|
||||
other.basePath == basePath;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode;
|
||||
int get hashCode =>
|
||||
localAssetId.hashCode ^
|
||||
isLivePhotos.hashCode ^
|
||||
livePhotoVideoId.hashCode ^
|
||||
isEditPair.hashCode ^
|
||||
basePath.hashCode;
|
||||
}
|
||||
|
||||
/// Service for handling background uploads using iOS URLSession (background_downloader)
|
||||
@@ -104,6 +147,9 @@ class BackgroundUploadService {
|
||||
this._localAssetRepository,
|
||||
this._backupRepository,
|
||||
this._assetMediaRepository,
|
||||
this._nativeSyncApi,
|
||||
this._editRevertService,
|
||||
this._stackRepository,
|
||||
) {
|
||||
_uploadRepository.onUploadStatus = _onUploadCallback;
|
||||
_uploadRepository.onTaskProgress = _onTaskProgressCallback;
|
||||
@@ -114,6 +160,9 @@ class BackgroundUploadService {
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftBackupRepository _backupRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final EditRevertService _editRevertService;
|
||||
final DriftStackRepository _stackRepository;
|
||||
final Logger _logger = Logger('BackgroundUploadService');
|
||||
|
||||
final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast();
|
||||
@@ -193,10 +242,13 @@ class BackgroundUploadService {
|
||||
|
||||
await _storageRepository.clearCache();
|
||||
await _uploadRepository.reset(kBackupGroup);
|
||||
await _uploadRepository.reset(kBackupEditPairGroup);
|
||||
await _uploadRepository.deleteDatabaseRecords(kBackupGroup);
|
||||
await _uploadRepository.deleteDatabaseRecords(kBackupEditPairGroup);
|
||||
|
||||
final activeTasks = await _uploadRepository.getActiveTasks(kBackupGroup);
|
||||
return activeTasks.length;
|
||||
final activeEditTasks = await _uploadRepository.getActiveTasks(kBackupEditPairGroup);
|
||||
return activeTasks.length + activeEditTasks.length;
|
||||
}
|
||||
|
||||
/// Resume background backup processing
|
||||
@@ -205,11 +257,25 @@ class BackgroundUploadService {
|
||||
}
|
||||
|
||||
void _handleTaskStatusUpdate(TaskStatusUpdate update) async {
|
||||
UploadTaskMetadata? metadata;
|
||||
if (update.task.metaData.isNotEmpty) {
|
||||
try {
|
||||
metadata = UploadTaskMetadata.fromJson(update.task.metaData);
|
||||
} catch (_) {
|
||||
metadata = null;
|
||||
}
|
||||
}
|
||||
|
||||
switch (update.status) {
|
||||
case TaskStatus.complete:
|
||||
unawaited(_handleLivePhoto(update));
|
||||
unawaited(_handleLivePhoto(update, metadata));
|
||||
unawaited(handleEditPair(update, metadata));
|
||||
unawaited(recordPriorRemoteIdOnSuccess(update, metadata));
|
||||
|
||||
if (CurrentPlatform.isIOS) {
|
||||
// Edit-pair bases live in the native temp dir and are deleted by
|
||||
// handleEditPair via metadata.basePath; deleting here too just races it
|
||||
// and logs a spurious SEVERE on the loser.
|
||||
if (CurrentPlatform.isIOS && !(metadata?.isEditPair ?? false)) {
|
||||
try {
|
||||
final path = await update.task.filePath();
|
||||
await File(path).delete();
|
||||
@@ -220,19 +286,20 @@ class BackgroundUploadService {
|
||||
|
||||
break;
|
||||
|
||||
case TaskStatus.failed:
|
||||
case TaskStatus.canceled:
|
||||
case TaskStatus.notFound:
|
||||
unawaited(_cleanupTempResourceOnFailure(metadata));
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleLivePhoto(TaskStatusUpdate update) async {
|
||||
Future<void> _handleLivePhoto(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
|
||||
try {
|
||||
if (update.task.metaData.isEmpty || update.task.metaData == '') {
|
||||
return;
|
||||
}
|
||||
|
||||
final metadata = UploadTaskMetadata.fromJson(update.task.metaData);
|
||||
if (!metadata.isLivePhotos) {
|
||||
if (metadata == null || !metadata.isLivePhotos) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -258,6 +325,143 @@ class BackgroundUploadService {
|
||||
}
|
||||
}
|
||||
|
||||
/// When an edit-pair base upload finishes, enqueue the edit on top of it
|
||||
/// (stackParentId = the base's new remote id).
|
||||
@visibleForTesting
|
||||
Future<void> handleEditPair(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
|
||||
try {
|
||||
if (metadata == null || !metadata.isEditPair) {
|
||||
return;
|
||||
}
|
||||
if (metadata.basePath.isNotEmpty) {
|
||||
try {
|
||||
await File(metadata.basePath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
final baseRemoteId = _remoteIdFromResponse(update);
|
||||
if (baseRemoteId == null) {
|
||||
return;
|
||||
}
|
||||
final localAsset = await _localAssetRepository.getById(metadata.localAssetId);
|
||||
if (localAsset == null) {
|
||||
return;
|
||||
}
|
||||
final editTask = await getEditUploadTask(localAsset, baseRemoteId);
|
||||
if (editTask != null) {
|
||||
await enqueueTasks([editTask]);
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
dPrint(() => "Error handling edit pair task: $error $stackTrace");
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the uploaded remote id as the asset's priorRemoteId so a later edit
|
||||
/// stacks onto it. Skipped for edit-pair base uploads; the chained edit records it.
|
||||
@visibleForTesting
|
||||
Future<void> recordPriorRemoteIdOnSuccess(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
|
||||
try {
|
||||
if (metadata == null || metadata.isEditPair || metadata.isLivePhotos || metadata.localAssetId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final remoteId = _remoteIdFromResponse(update);
|
||||
if (remoteId == null) {
|
||||
return;
|
||||
}
|
||||
final localAsset = await _localAssetRepository.getById(metadata.localAssetId);
|
||||
await _localAssetRepository.markSynced(
|
||||
metadata.localAssetId,
|
||||
priorRemoteId: remoteId,
|
||||
syncedChecksum: localAsset?.checksum,
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
dPrint(() => "Error recording priorRemoteId: $error $stackTrace");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cleanupTempResourceOnFailure(UploadTaskMetadata? metadata) async {
|
||||
if (metadata == null || metadata.basePath.isEmpty) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await File(metadata.basePath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// The new asset's remote id from an upload's response body, or null if the
|
||||
/// body is missing/malformed.
|
||||
String? _remoteIdFromResponse(TaskStatusUpdate update) {
|
||||
final body = update.responseBody;
|
||||
if (body == null || body.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return jsonDecode(body)['id'] as String?;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<UploadTask> _buildBaseUploadTask(LocalAsset asset, BaseResource base) async {
|
||||
final metadata = UploadTaskMetadata(
|
||||
localAssetId: asset.id,
|
||||
isLivePhotos: false,
|
||||
livePhotoVideoId: '',
|
||||
isEditPair: true,
|
||||
basePath: base.path,
|
||||
).toJson();
|
||||
|
||||
// The base is the unedited original (no adjustmentTime); the `_base`
|
||||
// deviceAssetId keeps it distinct from the chained edit task.
|
||||
return buildUploadTask(
|
||||
File(base.path),
|
||||
createdAt: asset.createdAt,
|
||||
modifiedAt: asset.updatedAt,
|
||||
originalFileName: p.setExtension(asset.name, p.extension(base.path)),
|
||||
deviceAssetId: '${asset.id}_base',
|
||||
metadata: metadata,
|
||||
group: kBackupGroup,
|
||||
isFavorite: asset.isFavorite,
|
||||
requiresWiFi: _shouldRequireWiFi(asset),
|
||||
cloudId: asset.cloudId,
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<UploadTask?> getEditUploadTask(LocalAsset asset, String stackParentId) async {
|
||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
final file = await _storageRepository.getFileForAsset(asset.id);
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final fields = {'stackParentId': stackParentId};
|
||||
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
final metadata = UploadTaskMetadata(localAssetId: asset.id, isLivePhotos: false, livePhotoVideoId: '').toJson();
|
||||
|
||||
return buildUploadTask(
|
||||
file,
|
||||
createdAt: asset.createdAt,
|
||||
modifiedAt: asset.updatedAt,
|
||||
originalFileName: originalFileName,
|
||||
deviceAssetId: asset.id,
|
||||
metadata: metadata,
|
||||
fields: fields,
|
||||
group: kBackupEditPairGroup,
|
||||
priority: 0,
|
||||
isFavorite: asset.isFavorite,
|
||||
requiresWiFi: _shouldRequireWiFi(asset),
|
||||
cloudId: asset.cloudId,
|
||||
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
|
||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||
@@ -266,6 +470,24 @@ class BackgroundUploadService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// iOS edit pair: stack a user edit onto its original. resolveEditPair decides
|
||||
// whether to reuse a prior upload or upload the base first. Live photos skip this.
|
||||
if (!entity.isLivePhoto && CurrentPlatform.isIOS) {
|
||||
// A reverted edit flips the stack back to the original and skips the upload.
|
||||
if (asset.priorRemoteId != null && await _editRevertService.tryHandleRevert(asset)) {
|
||||
return null;
|
||||
}
|
||||
final plan = await resolveEditPair(_nativeSyncApi, asset, stackRepository: _stackRepository, log: _logger);
|
||||
switch (plan) {
|
||||
case UploadBaseFirst(:final base):
|
||||
return _buildBaseUploadTask(asset, base);
|
||||
case AbsorbIntoPrior(:final parentId):
|
||||
return getEditUploadTask(asset, parentId);
|
||||
case NoEditPair():
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
File? file;
|
||||
|
||||
/// iOS LivePhoto has two files: a photo and a video.
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// What to do with an edited iOS photo when backing it up.
|
||||
sealed class EditPairPlan {
|
||||
const EditPairPlan();
|
||||
}
|
||||
|
||||
/// Not something we stack: not edited, identical bytes, or couldn't read it.
|
||||
class NoEditPair extends EditPairPlan {
|
||||
const NoEditPair();
|
||||
}
|
||||
|
||||
/// Already uploaded before; stack the edit onto that remote id.
|
||||
class AbsorbIntoPrior extends EditPairPlan {
|
||||
final String parentId;
|
||||
const AbsorbIntoPrior(this.parentId);
|
||||
}
|
||||
|
||||
/// Upload the original first; [base] is its temp file.
|
||||
class UploadBaseFirst extends EditPairPlan {
|
||||
final BaseResource base;
|
||||
const UploadBaseFirst(this.base);
|
||||
}
|
||||
|
||||
/// Works out how an edited photo should stack: reuse a prior upload, upload the
|
||||
/// original first, or do nothing. Shared by the foreground and background upload
|
||||
/// paths. The caller already checked it's iOS and not a live photo.
|
||||
///
|
||||
/// A photo that was never edited only carries the capture-time Photographic Style,
|
||||
/// which iOS stamps at [LocalAsset.createdAt]; a real edit moves [LocalAsset.adjustmentTime]
|
||||
/// later. When they match (or there's no adjustment at all) there's nothing to stack, so
|
||||
/// we skip the native read. Anything that moved the timestamp (edit, retime, revert) falls
|
||||
/// through to [NativeSyncApi.getBaseResource], which reads the adjustment plist and decides.
|
||||
Future<EditPairPlan> resolveEditPair(
|
||||
NativeSyncApi nativeSyncApi,
|
||||
LocalAsset asset, {
|
||||
required DriftStackRepository stackRepository,
|
||||
Logger? log,
|
||||
}) async {
|
||||
final priorRemoteId = asset.priorRemoteId;
|
||||
if (priorRemoteId != null) {
|
||||
// Reuse the prior upload unless it was trashed on the server. A dead parent
|
||||
// makes the edit upload 400 ("Cannot stack onto a trashed or missing asset")
|
||||
// forever; fall through to uploading the base again so the stack rebuilds.
|
||||
bool priorTrashed;
|
||||
try {
|
||||
priorTrashed = await stackRepository.isRemoteTrashed(priorRemoteId);
|
||||
} catch (error, stack) {
|
||||
log?.warning(() => "Failed to check prior remote $priorRemoteId for ${asset.id}", error, stack);
|
||||
priorTrashed = false;
|
||||
}
|
||||
if (!priorTrashed) {
|
||||
return AbsorbIntoPrior(priorRemoteId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_mightBeEdited(asset)) {
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
BaseResource? base;
|
||||
try {
|
||||
base = await nativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: true);
|
||||
} catch (error, stack) {
|
||||
log?.warning(() => "Failed to read base resource for ${asset.id}", error, stack);
|
||||
return const NoEditPair();
|
||||
}
|
||||
if (base == null) {
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
// Identical bytes (e.g. auto-HDR), nothing real to stack. Drop the temp copy.
|
||||
if (base.sha1 == asset.checksum) {
|
||||
try {
|
||||
await File(base.path).delete();
|
||||
} catch (_) {}
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
return UploadBaseFirst(base);
|
||||
}
|
||||
|
||||
/// iOS stamps the capture-time Photographic Style at the creation time and moves the
|
||||
/// adjustment timestamp on any later change. A gap past a small tolerance (capture jitter
|
||||
/// is sub-second, real edits are seconds apart) is worth a native check; no adjustment at
|
||||
/// all means the photo was never touched.
|
||||
bool _mightBeEdited(LocalAsset asset) {
|
||||
final adjustedAt = asset.adjustmentTime;
|
||||
if (adjustedAt == null) {
|
||||
return false;
|
||||
}
|
||||
return adjustedAt.difference(asset.createdAt).inSeconds.abs() > _editTimestampToleranceSeconds;
|
||||
}
|
||||
|
||||
const _editTimestampToleranceSeconds = 2;
|
||||
@@ -6,18 +6,26 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
||||
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/services/edit_revert.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/stack.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:immich_mobile/services/edit_pair.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||
@@ -39,6 +47,10 @@ final foregroundUploadServiceProvider = Provider((ref) {
|
||||
ref.watch(backupRepositoryProvider),
|
||||
ref.watch(connectivityApiProvider),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
ref.watch(nativeSyncApiProvider),
|
||||
ref.watch(localAssetRepository),
|
||||
ref.watch(editRevertServiceProvider),
|
||||
ref.watch(driftStackProvider),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -54,6 +66,10 @@ class ForegroundUploadService {
|
||||
this._backupRepository,
|
||||
this._connectivityApi,
|
||||
this._assetMediaRepository,
|
||||
this._nativeSyncApi,
|
||||
this._localAssetRepository,
|
||||
this._editRevertService,
|
||||
this._stackRepository,
|
||||
);
|
||||
|
||||
final UploadRepository _uploadRepository;
|
||||
@@ -61,6 +77,10 @@ class ForegroundUploadService {
|
||||
final DriftBackupRepository _backupRepository;
|
||||
final ConnectivityApi _connectivityApi;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final EditRevertService _editRevertService;
|
||||
final DriftStackRepository _stackRepository;
|
||||
final Logger _logger = Logger('ForegroundUploadService');
|
||||
|
||||
bool shouldAbortUpload = false;
|
||||
@@ -250,6 +270,16 @@ class ForegroundUploadService {
|
||||
return;
|
||||
}
|
||||
|
||||
// A reverted iOS edit flips the stack back to the original and skips the upload.
|
||||
// Live photos don't go through the edit-pair flow, so skip the native probe.
|
||||
if (CurrentPlatform.isIOS &&
|
||||
!entity.isLivePhoto &&
|
||||
asset.priorRemoteId != null &&
|
||||
await _editRevertService.tryHandleRevert(asset)) {
|
||||
callbacks.onSuccess?.call(asset.localId!, asset.priorRemoteId!);
|
||||
return;
|
||||
}
|
||||
|
||||
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
|
||||
|
||||
if (!isAvailableLocally && CurrentPlatform.isIOS) {
|
||||
@@ -355,20 +385,27 @@ class ForegroundUploadService {
|
||||
fields['livePhotoVideoId'] = livePhotoVideoId;
|
||||
}
|
||||
|
||||
// Edit pair: upload the unedited original first and stack the edit onto it.
|
||||
// Done before the edit's metadata is added below so the base isn't stamped
|
||||
// with the edit's adjustmentTime.
|
||||
if (!entity.isLivePhoto) {
|
||||
final base = await _resolveStackParent(asset, Map.of(fields), cancelToken);
|
||||
if (base.baseFailed) {
|
||||
// The original couldn't be uploaded. Don't upload the edit on its own
|
||||
// and mark it synced — that would permanently drop the original from
|
||||
// backup. Leave the whole pair as a candidate to retry next cycle.
|
||||
_logger.warning(() => "Base upload failed for ${asset.localId}, retrying the pair later");
|
||||
return;
|
||||
}
|
||||
if (base.stackParentId != null) {
|
||||
fields['stackParentId'] = base.stackParentId!;
|
||||
}
|
||||
}
|
||||
|
||||
// Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image.
|
||||
if (CurrentPlatform.isIOS && asset.cloudId != null) {
|
||||
fields['metadata'] = jsonEncode([
|
||||
RemoteAssetMetadataItem(
|
||||
key: RemoteAssetMetadataKey.mobileApp,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: asset.cloudId,
|
||||
createdAt: asset.createdAt.toIso8601String(),
|
||||
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
),
|
||||
),
|
||||
]);
|
||||
final metadata = _cloudMetadata(asset, includeAdjustment: true);
|
||||
if (metadata != null) {
|
||||
fields['metadata'] = metadata;
|
||||
}
|
||||
|
||||
final onProgress = callbacks.onProgress;
|
||||
@@ -384,6 +421,14 @@ class ForegroundUploadService {
|
||||
);
|
||||
|
||||
if (result.isSuccess && result.remoteAssetId != null) {
|
||||
unawaited(
|
||||
_localAssetRepository
|
||||
.markSynced(asset.localId!, priorRemoteId: result.remoteAssetId!, syncedChecksum: asset.checksum)
|
||||
.catchError(
|
||||
(Object error, StackTrace stack) =>
|
||||
_logger.warning(() => "Failed to mark ${asset.localId} synced", error, stack),
|
||||
),
|
||||
);
|
||||
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
|
||||
} else if (result.isCancelled) {
|
||||
_logger.warning(() => "Backup was cancelled by the user");
|
||||
@@ -415,6 +460,71 @@ class ForegroundUploadService {
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS still-image cloudId metadata as a JSON field, or null when there's
|
||||
/// nothing to attach. The base resource omits adjustmentTime (it's the
|
||||
/// unedited original); the edit includes it.
|
||||
String? _cloudMetadata(LocalAsset asset, {required bool includeAdjustment}) {
|
||||
if (!CurrentPlatform.isIOS || asset.cloudId == null) {
|
||||
return null;
|
||||
}
|
||||
return jsonEncode([
|
||||
RemoteAssetMetadataItem(
|
||||
key: RemoteAssetMetadataKey.mobileApp,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: asset.cloudId,
|
||||
createdAt: asset.createdAt.toIso8601String(),
|
||||
adjustmentTime: includeAdjustment ? asset.adjustmentTime?.toIso8601String() : null,
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/// For an edited iOS photo, uploads the original camera bytes so the edit can
|
||||
/// stack onto it. See [_StackParent] for the outcome.
|
||||
Future<_StackParent> _resolveStackParent(
|
||||
LocalAsset asset,
|
||||
Map<String, String> baseFields,
|
||||
Completer<void>? cancelToken,
|
||||
) async {
|
||||
if (!CurrentPlatform.isIOS) {
|
||||
return const _StackParent.none();
|
||||
}
|
||||
|
||||
final plan = await resolveEditPair(_nativeSyncApi, asset, stackRepository: _stackRepository, log: _logger);
|
||||
switch (plan) {
|
||||
case NoEditPair():
|
||||
return const _StackParent.none();
|
||||
case AbsorbIntoPrior(:final parentId):
|
||||
return _StackParent.parent(parentId);
|
||||
case UploadBaseFirst(:final base):
|
||||
final baseFile = File(base.path);
|
||||
try {
|
||||
final fields = Map.of(baseFields);
|
||||
final metadata = _cloudMetadata(asset, includeAdjustment: false);
|
||||
if (metadata != null) {
|
||||
fields['metadata'] = metadata;
|
||||
}
|
||||
final result = await _uploadRepository.uploadFile(
|
||||
file: baseFile,
|
||||
originalFileName: p.setExtension(asset.name, p.extension(base.path)),
|
||||
fields: fields,
|
||||
cancelToken: cancelToken,
|
||||
logContext: 'baseResource[${asset.localId}]',
|
||||
);
|
||||
if (result.isSuccess && result.remoteAssetId != null) {
|
||||
return _StackParent.parent(result.remoteAssetId!);
|
||||
}
|
||||
return const _StackParent.failed();
|
||||
} finally {
|
||||
try {
|
||||
await baseFile.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<UploadResult> _uploadSingleFile(
|
||||
File file, {
|
||||
required String deviceAssetId,
|
||||
@@ -461,3 +571,16 @@ class ForegroundUploadService {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of resolving an edit's stack parent. [stackParentId] is the remote id
|
||||
/// to stack onto (null when the asset isn't an edit). [baseFailed] is true only
|
||||
/// when the original was found but its upload failed, so the edit must not be
|
||||
/// uploaded on its own.
|
||||
class _StackParent {
|
||||
final String? stackParentId;
|
||||
final bool baseFailed;
|
||||
|
||||
const _StackParent.none() : stackParentId = null, baseFailed = false;
|
||||
const _StackParent.parent(String this.stackParentId) : baseFailed = false;
|
||||
const _StackParent.failed() : stackParentId = null, baseFailed = true;
|
||||
}
|
||||
|
||||
Generated
+13
-3
@@ -1586,8 +1586,11 @@ class AssetsApi {
|
||||
/// * [MultipartFile] sidecarData:
|
||||
/// Sidecar file data
|
||||
///
|
||||
/// * [String] stackParentId:
|
||||
/// Stack this asset onto the parent asset, with the new asset as the stack primary
|
||||
///
|
||||
/// * [AssetVisibility] visibility:
|
||||
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, Future<void>? abortTrigger, }) async {
|
||||
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? stackParentId, AssetVisibility? visibility, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/assets';
|
||||
|
||||
@@ -1651,6 +1654,10 @@ class AssetsApi {
|
||||
mp.fields[r'sidecarData'] = sidecarData.field;
|
||||
mp.files.add(sidecarData);
|
||||
}
|
||||
if (stackParentId != null) {
|
||||
hasFields = true;
|
||||
mp.fields[r'stackParentId'] = parameterToString(stackParentId);
|
||||
}
|
||||
if (visibility != null) {
|
||||
hasFields = true;
|
||||
mp.fields[r'visibility'] = parameterToString(visibility);
|
||||
@@ -1711,9 +1718,12 @@ class AssetsApi {
|
||||
/// * [MultipartFile] sidecarData:
|
||||
/// Sidecar file data
|
||||
///
|
||||
/// * [String] stackParentId:
|
||||
/// Stack this asset onto the parent asset, with the new asset as the stack primary
|
||||
///
|
||||
/// * [AssetVisibility] visibility:
|
||||
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, Future<void>? abortTrigger, }) async {
|
||||
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, visibility: visibility, abortTrigger: abortTrigger,);
|
||||
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? stackParentId, AssetVisibility? visibility, Future<void>? abortTrigger, }) async {
|
||||
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, stackParentId: stackParentId, visibility: visibility, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
+1
-22
@@ -14,52 +14,32 @@ class PeopleResponse {
|
||||
/// Returns a new [PeopleResponse] instance.
|
||||
PeopleResponse({
|
||||
required this.enabled,
|
||||
this.minimumFaces,
|
||||
required this.sidebarWeb,
|
||||
});
|
||||
|
||||
/// Whether people are enabled
|
||||
bool enabled;
|
||||
|
||||
/// People face threshold
|
||||
///
|
||||
/// 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? minimumFaces;
|
||||
|
||||
/// Whether people appear in web sidebar
|
||||
bool sidebarWeb;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PeopleResponse &&
|
||||
other.enabled == enabled &&
|
||||
other.minimumFaces == minimumFaces &&
|
||||
other.sidebarWeb == sidebarWeb;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled.hashCode) +
|
||||
(minimumFaces == null ? 0 : minimumFaces!.hashCode) +
|
||||
(sidebarWeb.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PeopleResponse[enabled=$enabled, minimumFaces=$minimumFaces, sidebarWeb=$sidebarWeb]';
|
||||
String toString() => 'PeopleResponse[enabled=$enabled, sidebarWeb=$sidebarWeb]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'enabled'] = this.enabled;
|
||||
if (this.minimumFaces != null) {
|
||||
json[r'minimumFaces'] = this.minimumFaces;
|
||||
} else {
|
||||
// json[r'minimumFaces'] = null;
|
||||
}
|
||||
json[r'sidebarWeb'] = this.sidebarWeb;
|
||||
return json;
|
||||
}
|
||||
@@ -74,7 +54,6 @@ class PeopleResponse {
|
||||
|
||||
return PeopleResponse(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
minimumFaces: mapValueOfType<int>(json, r'minimumFaces'),
|
||||
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb')!,
|
||||
);
|
||||
}
|
||||
|
||||
+1
-22
@@ -14,7 +14,6 @@ class PeopleUpdate {
|
||||
/// Returns a new [PeopleUpdate] instance.
|
||||
PeopleUpdate({
|
||||
this.enabled,
|
||||
this.minimumFaces,
|
||||
this.sidebarWeb,
|
||||
});
|
||||
|
||||
@@ -27,18 +26,6 @@ class PeopleUpdate {
|
||||
///
|
||||
bool? enabled;
|
||||
|
||||
/// People face threshold
|
||||
///
|
||||
/// 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? minimumFaces;
|
||||
|
||||
/// Whether people appear in web sidebar
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
@@ -51,18 +38,16 @@ class PeopleUpdate {
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PeopleUpdate &&
|
||||
other.enabled == enabled &&
|
||||
other.minimumFaces == minimumFaces &&
|
||||
other.sidebarWeb == sidebarWeb;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled == null ? 0 : enabled!.hashCode) +
|
||||
(minimumFaces == null ? 0 : minimumFaces!.hashCode) +
|
||||
(sidebarWeb == null ? 0 : sidebarWeb!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PeopleUpdate[enabled=$enabled, minimumFaces=$minimumFaces, sidebarWeb=$sidebarWeb]';
|
||||
String toString() => 'PeopleUpdate[enabled=$enabled, sidebarWeb=$sidebarWeb]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -71,11 +56,6 @@ class PeopleUpdate {
|
||||
} else {
|
||||
// json[r'enabled'] = null;
|
||||
}
|
||||
if (this.minimumFaces != null) {
|
||||
json[r'minimumFaces'] = this.minimumFaces;
|
||||
} else {
|
||||
// json[r'minimumFaces'] = null;
|
||||
}
|
||||
if (this.sidebarWeb != null) {
|
||||
json[r'sidebarWeb'] = this.sidebarWeb;
|
||||
} else {
|
||||
@@ -94,7 +74,6 @@ class PeopleUpdate {
|
||||
|
||||
return PeopleUpdate(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled'),
|
||||
minimumFaces: mapValueOfType<int>(json, r'minimumFaces'),
|
||||
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb'),
|
||||
);
|
||||
}
|
||||
|
||||
+1
-13
@@ -20,7 +20,6 @@ class ServerConfigDto {
|
||||
required this.maintenanceMode,
|
||||
required this.mapDarkStyleUrl,
|
||||
required this.mapLightStyleUrl,
|
||||
required this.minFaces,
|
||||
required this.oauthButtonText,
|
||||
required this.publicUsers,
|
||||
required this.trashDays,
|
||||
@@ -48,12 +47,6 @@ class ServerConfigDto {
|
||||
/// Map light style URL
|
||||
String mapLightStyleUrl;
|
||||
|
||||
/// People min faces server default
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int minFaces;
|
||||
|
||||
/// OAuth button text
|
||||
String oauthButtonText;
|
||||
|
||||
@@ -81,7 +74,6 @@ class ServerConfigDto {
|
||||
other.maintenanceMode == maintenanceMode &&
|
||||
other.mapDarkStyleUrl == mapDarkStyleUrl &&
|
||||
other.mapLightStyleUrl == mapLightStyleUrl &&
|
||||
other.minFaces == minFaces &&
|
||||
other.oauthButtonText == oauthButtonText &&
|
||||
other.publicUsers == publicUsers &&
|
||||
other.trashDays == trashDays &&
|
||||
@@ -97,14 +89,13 @@ class ServerConfigDto {
|
||||
(maintenanceMode.hashCode) +
|
||||
(mapDarkStyleUrl.hashCode) +
|
||||
(mapLightStyleUrl.hashCode) +
|
||||
(minFaces.hashCode) +
|
||||
(oauthButtonText.hashCode) +
|
||||
(publicUsers.hashCode) +
|
||||
(trashDays.hashCode) +
|
||||
(userDeleteDelay.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, maintenanceMode=$maintenanceMode, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, minFaces=$minFaces, oauthButtonText=$oauthButtonText, publicUsers=$publicUsers, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
|
||||
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, maintenanceMode=$maintenanceMode, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, oauthButtonText=$oauthButtonText, publicUsers=$publicUsers, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -115,7 +106,6 @@ class ServerConfigDto {
|
||||
json[r'maintenanceMode'] = this.maintenanceMode;
|
||||
json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl;
|
||||
json[r'mapLightStyleUrl'] = this.mapLightStyleUrl;
|
||||
json[r'minFaces'] = this.minFaces;
|
||||
json[r'oauthButtonText'] = this.oauthButtonText;
|
||||
json[r'publicUsers'] = this.publicUsers;
|
||||
json[r'trashDays'] = this.trashDays;
|
||||
@@ -139,7 +129,6 @@ class ServerConfigDto {
|
||||
maintenanceMode: mapValueOfType<bool>(json, r'maintenanceMode')!,
|
||||
mapDarkStyleUrl: mapValueOfType<String>(json, r'mapDarkStyleUrl')!,
|
||||
mapLightStyleUrl: mapValueOfType<String>(json, r'mapLightStyleUrl')!,
|
||||
minFaces: mapValueOfType<int>(json, r'minFaces')!,
|
||||
oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
|
||||
publicUsers: mapValueOfType<bool>(json, r'publicUsers')!,
|
||||
trashDays: mapValueOfType<int>(json, r'trashDays')!,
|
||||
@@ -198,7 +187,6 @@ class ServerConfigDto {
|
||||
'maintenanceMode',
|
||||
'mapDarkStyleUrl',
|
||||
'mapLightStyleUrl',
|
||||
'minFaces',
|
||||
'oauthButtonText',
|
||||
'publicUsers',
|
||||
'trashDays',
|
||||
|
||||
@@ -103,6 +103,21 @@ class CloudIdResult {
|
||||
const CloudIdResult({required this.assetId, this.error, this.cloudId});
|
||||
}
|
||||
|
||||
class BaseResource {
|
||||
final String path;
|
||||
final String sha1;
|
||||
final int sizeBytes;
|
||||
final String mimeType;
|
||||
|
||||
const BaseResource({required this.path, required this.sha1, required this.sizeBytes, required this.mimeType});
|
||||
}
|
||||
|
||||
// Whether an iOS asset currently carries a user edit, as opposed to a
|
||||
// capture-time Photographic Style or a reverted edit. `unknown` means the
|
||||
// adjustment data couldn't be read (e.g. the asset is offloaded to iCloud and
|
||||
// network wasn't allowed), so callers must not treat it as "not edited".
|
||||
enum EditState { notEdited, edited, unknown }
|
||||
|
||||
@HostApi()
|
||||
abstract class NativeSyncApi {
|
||||
bool shouldFullSync();
|
||||
@@ -140,4 +155,12 @@ abstract class NativeSyncApi {
|
||||
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
|
||||
|
||||
@async
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
BaseResource? getBaseResource(String assetId, {bool allowNetworkAccess = false});
|
||||
|
||||
@async
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
EditState getEditState(String assetId, {bool allowNetworkAccess = false});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class MockSyncApi extends Mock implements SyncApi {}
|
||||
|
||||
class MockServerApi extends Mock implements ServerApi {}
|
||||
|
||||
class MockConnectivityApi extends Mock implements ConnectivityApi {}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
@@ -11,3 +12,5 @@ class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
|
||||
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
||||
|
||||
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
||||
|
||||
class MockEditRevertService extends Mock implements EditRevertService {}
|
||||
|
||||
+4
@@ -31,6 +31,7 @@ import 'schema_v24.dart' as v24;
|
||||
import 'schema_v25.dart' as v25;
|
||||
import 'schema_v26.dart' as v26;
|
||||
import 'schema_v27.dart' as v27;
|
||||
import 'schema_v28.dart' as v28;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -90,6 +91,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v26.DatabaseAtV26(db);
|
||||
case 27:
|
||||
return v27.DatabaseAtV27(db);
|
||||
case 28:
|
||||
return v28.DatabaseAtV28(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
@@ -123,5 +126,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
25,
|
||||
26,
|
||||
27,
|
||||
28,
|
||||
];
|
||||
}
|
||||
|
||||
+9471
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||
@@ -36,6 +37,8 @@ class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
|
||||
|
||||
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
|
||||
|
||||
class MockDriftStackRepository extends Mock implements DriftStackRepository {}
|
||||
|
||||
class MockStorageRepository extends Mock implements StorageRepository {}
|
||||
|
||||
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
|
||||
import '../repository_context.dart';
|
||||
|
||||
void main() {
|
||||
late MediumRepositoryContext ctx;
|
||||
late DriftStackRepository sut;
|
||||
late String userId;
|
||||
|
||||
setUp(() async {
|
||||
ctx = MediumRepositoryContext();
|
||||
sut = DriftStackRepository(ctx.db);
|
||||
final user = await ctx.newUser();
|
||||
userId = user.id;
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
group('isRemoteTrashed', () {
|
||||
test('is false for a live remote', () async {
|
||||
await ctx.newRemoteAsset(id: 'live', ownerId: userId);
|
||||
expect(await sut.isRemoteTrashed('live'), isFalse);
|
||||
});
|
||||
|
||||
test('is false for a remote that was never synced', () async {
|
||||
expect(await sut.isRemoteTrashed('missing'), isFalse);
|
||||
});
|
||||
|
||||
test('is true only when the synced remote is trashed', () async {
|
||||
await ctx.newRemoteAsset(id: 'trashed', ownerId: userId, deletedAt: DateTime(2025, 6));
|
||||
expect(await sut.isRemoteTrashed('trashed'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('findStackIdByRemoteId', () {
|
||||
test('returns the stack id for a stacked remote', () async {
|
||||
final base = await ctx.newRemoteAsset(id: 'base', ownerId: userId);
|
||||
final stack = await ctx.newStack(ownerId: userId, primaryAssetId: base.id);
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: stack.id);
|
||||
expect(await sut.findStackIdByRemoteId('edit'), stack.id);
|
||||
});
|
||||
|
||||
test('returns null for an unstacked remote', () async {
|
||||
await ctx.newRemoteAsset(id: 'lonely', ownerId: userId);
|
||||
expect(await sut.findStackIdByRemoteId('lonely'), isNull);
|
||||
});
|
||||
|
||||
test('returns null for a trashed remote', () async {
|
||||
final base = await ctx.newRemoteAsset(id: 'base', ownerId: userId);
|
||||
final stack = await ctx.newStack(ownerId: userId, primaryAssetId: base.id);
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: stack.id, deletedAt: DateTime(2025, 6));
|
||||
expect(await sut.findStackIdByRemoteId('edit'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('findStackBaseId', () {
|
||||
test('returns the earliest-uploaded member that is not the excluded one', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025));
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 2));
|
||||
|
||||
// base uploaded before the edit → it's the flip target.
|
||||
expect(await sut.findStackBaseId('stack-1', excludeId: 'edit'), 'base');
|
||||
});
|
||||
|
||||
test('returns null when the only member is excluded', () async {
|
||||
final base = await ctx.newRemoteAsset(id: 'solo', ownerId: userId, stackId: 'stack-1');
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: base.id);
|
||||
expect(await sut.findStackBaseId('stack-1', excludeId: 'solo'), isNull);
|
||||
});
|
||||
|
||||
test('skips trashed members', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(
|
||||
id: 'base',
|
||||
ownerId: userId,
|
||||
stackId: 'stack-1',
|
||||
uploadedAt: DateTime(2025),
|
||||
deletedAt: DateTime(2025, 6),
|
||||
);
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 2));
|
||||
expect(await sut.findStackBaseId('stack-1', excludeId: 'edit'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('findRevertReconcileTargets', () {
|
||||
test('finds a local that hashed back to a non-primary stack member', () async {
|
||||
// Stack: primary = edit, also holds base. The local's checksum matches base.
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
|
||||
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', priorRemoteId: 'edit');
|
||||
|
||||
final targets = await sut.findRevertReconcileTargets(['local-1']);
|
||||
|
||||
expect(targets, hasLength(1));
|
||||
expect(targets.first.stackId, 'stack-1');
|
||||
expect(targets.first.newPrimaryId, 'base');
|
||||
expect(targets.first.localAssetId, 'local-1');
|
||||
});
|
||||
|
||||
test('returns nothing when the local already matches the primary', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
|
||||
await ctx.newLocalAsset(id: 'local-1', checksum: 'edit-sum', priorRemoteId: 'edit');
|
||||
|
||||
expect(await sut.findRevertReconcileTargets(['local-1']), isEmpty);
|
||||
});
|
||||
|
||||
test('ignores a local whose prior remote was trashed', () async {
|
||||
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
|
||||
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
|
||||
await ctx.newRemoteAsset(
|
||||
id: 'edit',
|
||||
ownerId: userId,
|
||||
stackId: 'stack-1',
|
||||
checksum: 'edit-sum',
|
||||
deletedAt: DateTime(2025, 6),
|
||||
);
|
||||
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', priorRemoteId: 'edit');
|
||||
|
||||
expect(await sut.findRevertReconcileTargets(['local-1']), isEmpty);
|
||||
});
|
||||
|
||||
test('returns nothing for an empty id set', () async {
|
||||
expect(await sut.findRevertReconcileTargets(const []), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
@@ -86,6 +87,7 @@ class MediumRepositoryContext {
|
||||
String? stackId,
|
||||
String? thumbHash,
|
||||
String? libraryId,
|
||||
DateTime? uploadedAt,
|
||||
}) async {
|
||||
id ??= TestUtils.uuid();
|
||||
createdAt ??= TestUtils.date();
|
||||
@@ -112,6 +114,19 @@ class MediumRepositoryContext {
|
||||
localDateTime: .new(createdAt.toLocal()),
|
||||
thumbHash: .new(TestUtils.uuid(thumbHash)),
|
||||
libraryId: .new(TestUtils.uuid(libraryId)),
|
||||
uploadedAt: .new(uploadedAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<StackEntityData> newStack({String? id, String? ownerId, required String primaryAssetId}) {
|
||||
return db
|
||||
.into(db.stackEntity)
|
||||
.insertReturning(
|
||||
StackEntityCompanion(
|
||||
id: .new(TestUtils.uuid(id)),
|
||||
ownerId: .new(TestUtils.uuid(ownerId)),
|
||||
primaryAssetId: .new(primaryAssetId),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -245,6 +260,8 @@ class MediumRepositoryContext {
|
||||
int? durationMs,
|
||||
int? orientation,
|
||||
DateTime? updatedAt,
|
||||
String? priorRemoteId,
|
||||
String? syncedChecksum,
|
||||
}) async {
|
||||
id ??= TestUtils.uuid();
|
||||
return db
|
||||
@@ -266,6 +283,8 @@ class MediumRepositoryContext {
|
||||
adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()),
|
||||
latitude: .new(latitude ?? TestUtils.randDouble(-90, 90)),
|
||||
longitude: .new(longitude ?? TestUtils.randDouble(-180, 180)),
|
||||
priorRemoteId: .new(priorRemoteId),
|
||||
syncedChecksum: .new(syncedChecksum),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
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/services/store.service.dart';
|
||||
@@ -13,9 +15,11 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../domain/service.mock.dart';
|
||||
import '../fixtures/asset.stub.dart';
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../mocks/asset_entity.mock.dart';
|
||||
@@ -28,10 +32,15 @@ void main() {
|
||||
late MockDriftLocalAssetRepository mockLocalAssetRepository;
|
||||
late MockDriftBackupRepository mockBackupRepository;
|
||||
late MockAssetMediaRepository mockAssetMediaRepository;
|
||||
late MockNativeSyncApi mockNativeSyncApi;
|
||||
late MockEditRevertService mockEditRevertService;
|
||||
late MockDriftStackRepository mockStackRepository;
|
||||
late Drift db;
|
||||
|
||||
setUpAll(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
registerFallbackValue(LocalAssetStub.image1);
|
||||
registerFallbackValue(<UploadTask>[]);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/path_provider'),
|
||||
(MethodCall methodCall) async => 'test',
|
||||
@@ -50,6 +59,9 @@ void main() {
|
||||
mockLocalAssetRepository = MockDriftLocalAssetRepository();
|
||||
mockBackupRepository = MockDriftBackupRepository();
|
||||
mockAssetMediaRepository = MockAssetMediaRepository();
|
||||
mockNativeSyncApi = MockNativeSyncApi();
|
||||
mockEditRevertService = MockEditRevertService();
|
||||
mockStackRepository = MockDriftStackRepository();
|
||||
|
||||
sut = BackgroundUploadService(
|
||||
mockUploadRepository,
|
||||
@@ -57,8 +69,22 @@ void main() {
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAssetMediaRepository,
|
||||
mockNativeSyncApi,
|
||||
mockEditRevertService,
|
||||
mockStackRepository,
|
||||
);
|
||||
|
||||
// Default: no edit base, so getUploadTask falls through to the normal path.
|
||||
when(
|
||||
() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer((_) async => null);
|
||||
|
||||
// Default: not a revert, so getUploadTask proceeds with the normal flow.
|
||||
when(() => mockEditRevertService.tryHandleRevert(any())).thenAnswer((_) async => false);
|
||||
|
||||
// Default: prior remotes are alive, so absorb is allowed.
|
||||
when(() => mockStackRepository.isRemoteTrashed(any())).thenAnswer((_) async => false);
|
||||
|
||||
mockUploadRepository.onUploadStatus = (_) {};
|
||||
mockUploadRepository.onTaskProgress = (_) {};
|
||||
});
|
||||
@@ -122,6 +148,234 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('getUploadTask edit pair', () {
|
||||
test('absorption: stacks the edit under the prior upload via stackParentId', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final asset = LocalAssetStub.image1.copyWith(priorRemoteId: 'prior-remote-1');
|
||||
final mockEntity = MockAssetEntity();
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/edit.jpg'));
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'edit.jpg');
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.group, kBackupEditPairGroup);
|
||||
expect(task.fields['stackParentId'], 'prior-remote-1');
|
||||
verifyNever(() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
});
|
||||
|
||||
test('builds a base upload task for an unsynced edit', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final asset = LocalAssetStub.image1.copyWith(
|
||||
checksum: 'edited-sha1',
|
||||
adjustmentTime: DateTime(2025, 1, 1, 0, 0, 30),
|
||||
);
|
||||
final mockEntity = MockAssetEntity();
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(
|
||||
() => mockNativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer(
|
||||
(_) async => BaseResource(path: '/tmp/base.jpg', sha1: 'original-sha1', sizeBytes: 100, mimeType: 'image/jpeg'),
|
||||
);
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.group, kBackupGroup);
|
||||
expect(task.metaData, contains('"isEditPair":true'));
|
||||
});
|
||||
|
||||
test('falls through to a normal upload when base bytes match the checksum', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final asset = LocalAssetStub.image1.copyWith(
|
||||
checksum: 'same-sha1',
|
||||
adjustmentTime: DateTime(2025, 1, 1, 0, 0, 30),
|
||||
);
|
||||
final mockEntity = MockAssetEntity();
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/file.jpg'));
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
|
||||
when(
|
||||
() => mockNativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer(
|
||||
(_) async => BaseResource(path: '/tmp/base.jpg', sha1: 'same-sha1', sizeBytes: 100, mimeType: 'image/jpeg'),
|
||||
);
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.group, kBackupGroup);
|
||||
expect(task.fields.containsKey('stackParentId'), isFalse);
|
||||
});
|
||||
|
||||
test('gate: skips the native read for an unedited photo (adjustmentTime == createdAt)', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final asset = LocalAssetStub.image1.copyWith(adjustmentTime: LocalAssetStub.image1.createdAt);
|
||||
final mockEntity = MockAssetEntity();
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/file.jpg'));
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.group, kBackupGroup);
|
||||
expect(task.fields.containsKey('stackParentId'), isFalse);
|
||||
verifyNever(() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
});
|
||||
|
||||
test('gate: skips the native read when the photo has no adjustmentTime', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final asset = LocalAssetStub.image1; // adjustmentTime is null
|
||||
final mockEntity = MockAssetEntity();
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/file.jpg'));
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.group, kBackupGroup);
|
||||
verifyNever(() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
});
|
||||
});
|
||||
|
||||
group('edit pair completion', () {
|
||||
test('handleEditPair: enqueues the edit stacked onto the uploaded base', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final metadata = UploadTaskMetadata(
|
||||
localAssetId: asset.id,
|
||||
isLivePhotos: false,
|
||||
livePhotoVideoId: '',
|
||||
isEditPair: true,
|
||||
);
|
||||
final update = TaskStatusUpdate(
|
||||
UploadTask(url: 'http://test-server.com', filename: 'base.jpg'),
|
||||
TaskStatus.complete,
|
||||
null,
|
||||
'{"id":"base-remote-1"}',
|
||||
);
|
||||
final mockEntity = MockAssetEntity();
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockLocalAssetRepository.getById(asset.id)).thenAnswer((_) async => asset);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/edit.jpg'));
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'edit.jpg');
|
||||
when(() => mockUploadRepository.enqueueBackgroundAll(any())).thenAnswer((_) async => [true]);
|
||||
|
||||
await sut.handleEditPair(update, metadata);
|
||||
|
||||
final enqueued =
|
||||
verify(() => mockUploadRepository.enqueueBackgroundAll(captureAny())).captured.single as List<UploadTask>;
|
||||
expect(enqueued.single.fields['stackParentId'], 'base-remote-1');
|
||||
expect(enqueued.single.group, kBackupEditPairGroup);
|
||||
});
|
||||
|
||||
test('handleEditPair: does nothing for a non edit-pair upload', () async {
|
||||
const metadata = UploadTaskMetadata(localAssetId: 'local-1', isLivePhotos: false, livePhotoVideoId: '');
|
||||
final update = TaskStatusUpdate(
|
||||
UploadTask(url: 'http://test-server.com', filename: 'photo.jpg'),
|
||||
TaskStatus.complete,
|
||||
null,
|
||||
'{"id":"remote-1"}',
|
||||
);
|
||||
|
||||
await sut.handleEditPair(update, metadata);
|
||||
|
||||
verifyNever(() => mockUploadRepository.enqueueBackgroundAll(any()));
|
||||
});
|
||||
|
||||
test('recordPriorRemoteIdOnSuccess: marks the local synced with the uploaded id', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final metadata = UploadTaskMetadata(localAssetId: asset.id, isLivePhotos: false, livePhotoVideoId: '');
|
||||
final update = TaskStatusUpdate(
|
||||
UploadTask(url: 'http://test-server.com', filename: 'photo.jpg'),
|
||||
TaskStatus.complete,
|
||||
null,
|
||||
'{"id":"remote-1"}',
|
||||
);
|
||||
when(() => mockLocalAssetRepository.getById(asset.id)).thenAnswer((_) async => asset);
|
||||
when(
|
||||
() => mockLocalAssetRepository.markSynced(
|
||||
any(),
|
||||
priorRemoteId: any(named: 'priorRemoteId'),
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
await sut.recordPriorRemoteIdOnSuccess(update, metadata);
|
||||
|
||||
verify(
|
||||
() => mockLocalAssetRepository.markSynced(
|
||||
asset.id,
|
||||
priorRemoteId: 'remote-1',
|
||||
syncedChecksum: asset.checksum,
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('recordPriorRemoteIdOnSuccess: skips edit-pair base uploads', () async {
|
||||
const metadata = UploadTaskMetadata(
|
||||
localAssetId: 'local-1',
|
||||
isLivePhotos: false,
|
||||
livePhotoVideoId: '',
|
||||
isEditPair: true,
|
||||
);
|
||||
final update = TaskStatusUpdate(
|
||||
UploadTask(url: 'http://test-server.com', filename: 'base.jpg'),
|
||||
TaskStatus.complete,
|
||||
null,
|
||||
'{"id":"base-remote-1"}',
|
||||
);
|
||||
|
||||
await sut.recordPriorRemoteIdOnSuccess(update, metadata);
|
||||
|
||||
verifyNever(
|
||||
() => mockLocalAssetRepository.markSynced(
|
||||
any(),
|
||||
priorRemoteId: any(named: 'priorRemoteId'),
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('recordPriorRemoteIdOnSuccess: skips live photos', () async {
|
||||
const metadata = UploadTaskMetadata(localAssetId: 'local-1', isLivePhotos: true, livePhotoVideoId: '');
|
||||
final update = TaskStatusUpdate(
|
||||
UploadTask(url: 'http://test-server.com', filename: 'live.mov'),
|
||||
TaskStatus.complete,
|
||||
null,
|
||||
'{"id":"video-remote-1"}',
|
||||
);
|
||||
|
||||
await sut.recordPriorRemoteIdOnSuccess(update, metadata);
|
||||
|
||||
verifyNever(
|
||||
() => mockLocalAssetRepository.markSynced(
|
||||
any(),
|
||||
priorRemoteId: any(named: 'priorRemoteId'),
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('getLivePhotoUploadTask', () {
|
||||
test('should call getOriginalFilename for live photo upload task', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
@@ -172,6 +426,9 @@ void main() {
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAssetMediaRepository,
|
||||
mockNativeSyncApi,
|
||||
mockEditRevertService,
|
||||
mockStackRepository,
|
||||
);
|
||||
addTearDown(() => sutWithV24.dispose());
|
||||
|
||||
@@ -222,6 +479,9 @@ void main() {
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAssetMediaRepository,
|
||||
mockNativeSyncApi,
|
||||
mockEditRevertService,
|
||||
mockStackRepository,
|
||||
);
|
||||
addTearDown(() => sutAndroid.dispose());
|
||||
|
||||
@@ -262,6 +522,9 @@ void main() {
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAssetMediaRepository,
|
||||
mockNativeSyncApi,
|
||||
mockEditRevertService,
|
||||
mockStackRepository,
|
||||
);
|
||||
addTearDown(() => sutWithV24.dispose());
|
||||
|
||||
@@ -302,6 +565,9 @@ void main() {
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAssetMediaRepository,
|
||||
mockNativeSyncApi,
|
||||
mockEditRevertService,
|
||||
mockStackRepository,
|
||||
);
|
||||
addTearDown(() => sutWithV24.dispose());
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
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/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../api.mocks.dart';
|
||||
import '../domain/service.mock.dart';
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../mocks/asset_entity.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
|
||||
void main() {
|
||||
late ForegroundUploadService sut;
|
||||
late MockUploadRepository mockUpload;
|
||||
late MockStorageRepository mockStorage;
|
||||
late MockDriftBackupRepository mockBackup;
|
||||
late MockConnectivityApi mockConnectivity;
|
||||
late MockAssetMediaRepository mockAssetMedia;
|
||||
late MockNativeSyncApi mockNativeApi;
|
||||
late MockDriftLocalAssetRepository mockLocalAsset;
|
||||
late MockEditRevertService mockEditRevert;
|
||||
late MockDriftStackRepository mockStack;
|
||||
late Drift db;
|
||||
late Directory tmp;
|
||||
|
||||
final edited = LocalAsset(
|
||||
id: 'edited-1',
|
||||
name: 'edited-1.jpg',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025, 1, 1, 12),
|
||||
updatedAt: DateTime(2025, 1, 1, 12),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
checksum: 'edited-sha1',
|
||||
// 30s past createdAt → the edit gate fires.
|
||||
adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30),
|
||||
);
|
||||
|
||||
setUpAll(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
registerFallbackValue(edited);
|
||||
registerFallbackValue(File('/tmp/fallback'));
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/path_provider'),
|
||||
(_) async => 'test',
|
||||
);
|
||||
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
await StoreService.init(storeRepository: DriftStoreRepository(db));
|
||||
await SettingsRepository.ensureInitialized(db);
|
||||
await Store.put(StoreKey.serverEndpoint, 'http://test-server.com');
|
||||
await Store.put(StoreKey.deviceId, 'test-device-id');
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
mockUpload = MockUploadRepository();
|
||||
mockStorage = MockStorageRepository();
|
||||
mockBackup = MockDriftBackupRepository();
|
||||
mockConnectivity = MockConnectivityApi();
|
||||
mockAssetMedia = MockAssetMediaRepository();
|
||||
mockNativeApi = MockNativeSyncApi();
|
||||
mockLocalAsset = MockDriftLocalAssetRepository();
|
||||
mockEditRevert = MockEditRevertService();
|
||||
mockStack = MockDriftStackRepository();
|
||||
|
||||
sut = ForegroundUploadService(
|
||||
mockUpload,
|
||||
mockStorage,
|
||||
mockBackup,
|
||||
mockConnectivity,
|
||||
mockAssetMedia,
|
||||
mockNativeApi,
|
||||
mockLocalAsset,
|
||||
mockEditRevert,
|
||||
mockStack,
|
||||
);
|
||||
|
||||
tmp = await Directory.systemTemp.createTemp('fg_upload_test');
|
||||
final assetFile = File('${tmp.path}/edited-1.jpg')..writeAsStringSync('edit-bytes');
|
||||
final baseFile = File('${tmp.path}/edited-1_base.jpg')..writeAsStringSync('base-bytes');
|
||||
|
||||
when(() => mockStorage.clearCache()).thenAnswer((_) async {});
|
||||
when(() => mockConnectivity.getCapabilities()).thenAnswer((_) async => [NetworkCapability.unmetered]);
|
||||
|
||||
final entity = MockAssetEntity();
|
||||
when(() => entity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorage.getAssetEntityForAsset(any())).thenAnswer((_) async => entity);
|
||||
when(() => mockStorage.isAssetAvailableLocally(any())).thenAnswer((_) async => true);
|
||||
when(() => mockStorage.getFileForAsset(any())).thenAnswer((_) async => assetFile);
|
||||
when(() => mockAssetMedia.getOriginalFilename(any())).thenAnswer((_) async => 'edited-1.jpg');
|
||||
|
||||
// Not a revert; prior is alive; the edit gate fires with a real base file.
|
||||
when(() => mockEditRevert.tryHandleRevert(any())).thenAnswer((_) async => false);
|
||||
when(() => mockStack.isRemoteTrashed(any())).thenAnswer((_) async => false);
|
||||
when(() => mockNativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer(
|
||||
(_) async => BaseResource(path: baseFile.path, sha1: 'base-sha1', sizeBytes: 10, mimeType: 'image/jpeg'),
|
||||
);
|
||||
when(
|
||||
() => mockLocalAsset.markSynced(
|
||||
any(),
|
||||
priorRemoteId: any(named: 'priorRemoteId'),
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (tmp.existsSync()) {
|
||||
tmp.deleteSync(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
group('edit pair base failure', () {
|
||||
test('does not upload the edit or mark synced when the base upload fails', () async {
|
||||
// Base upload fails; the edit upload should never run.
|
||||
when(
|
||||
() => mockUpload.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: any(named: 'logContext'),
|
||||
),
|
||||
).thenAnswer((_) async => UploadResult.error(errorMessage: 'boom', statusCode: 500));
|
||||
|
||||
await sut.uploadManual([edited]);
|
||||
|
||||
// Exactly one upload attempt (the base). The edit must not be uploaded,
|
||||
// and the asset must stay a candidate (no markSynced).
|
||||
verify(
|
||||
() => mockUpload.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: 'baseResource[edited-1]',
|
||||
),
|
||||
).called(1);
|
||||
verifyNever(
|
||||
() => mockUpload.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: 'asset[edited-1]',
|
||||
),
|
||||
);
|
||||
verifyNever(
|
||||
() => mockLocalAsset.markSynced(
|
||||
any(),
|
||||
priorRemoteId: any(named: 'priorRemoteId'),
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('uploads the edit with stackParentId and marks synced when the base succeeds', () async {
|
||||
var uploadCount = 0;
|
||||
when(
|
||||
() => mockUpload.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: any(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: any(named: 'logContext'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
uploadCount++;
|
||||
// base first → base-remote, then the edit → edit-remote.
|
||||
return UploadResult.success(remoteAssetId: uploadCount == 1 ? 'base-remote' : 'edit-remote');
|
||||
});
|
||||
|
||||
await sut.uploadManual([edited]);
|
||||
|
||||
// The edit upload carries the base's id as stackParentId.
|
||||
final captured = verify(
|
||||
() => mockUpload.uploadFile(
|
||||
file: any(named: 'file'),
|
||||
originalFileName: any(named: 'originalFileName'),
|
||||
fields: captureAny(named: 'fields'),
|
||||
cancelToken: any(named: 'cancelToken'),
|
||||
onProgress: any(named: 'onProgress'),
|
||||
logContext: 'asset[edited-1]',
|
||||
),
|
||||
).captured.single as Map<String, String>;
|
||||
expect(captured['stackParentId'], 'base-remote');
|
||||
|
||||
verify(
|
||||
() => mockLocalAsset.markSynced(
|
||||
'edited-1',
|
||||
priorRemoteId: 'edit-remote',
|
||||
syncedChecksum: 'edited-sha1',
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,11 +4,14 @@ import 'package:mocktail/mocktail.dart' as mocktail;
|
||||
|
||||
import '../domain/service.mock.dart';
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
|
||||
class UnitMocks {
|
||||
final localAlbum = MockLocalAlbumRepository();
|
||||
final localAsset = MockDriftLocalAssetRepository();
|
||||
final trashedAsset = MockTrashedLocalAssetRepository();
|
||||
final stack = MockDriftStackRepository();
|
||||
final assetApi = MockAssetApiRepository();
|
||||
|
||||
final nativeApi = MockNativeSyncApi();
|
||||
|
||||
@@ -31,6 +34,8 @@ class UnitMocks {
|
||||
mocktail.reset(localAlbum);
|
||||
mocktail.reset(localAsset);
|
||||
mocktail.reset(trashedAsset);
|
||||
mocktail.reset(stack);
|
||||
mocktail.reset(assetApi);
|
||||
mocktail.reset(nativeApi);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/services/edit_pair.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../mocks.dart';
|
||||
|
||||
void main() {
|
||||
final mocks = UnitMocks();
|
||||
|
||||
// createdAt fixed; adjustmentTime is what moves a real edit past the gate.
|
||||
LocalAsset asset({DateTime? adjustmentTime, String? priorRemoteId, String? checksum = 'local-sha1'}) => LocalAsset(
|
||||
id: 'local-1',
|
||||
name: 'photo.jpg',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025, 1, 1, 12),
|
||||
updatedAt: DateTime(2025, 1, 1, 12),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
adjustmentTime: adjustmentTime,
|
||||
priorRemoteId: priorRemoteId,
|
||||
checksum: checksum,
|
||||
);
|
||||
|
||||
BaseResource base(String sha1) => BaseResource(path: '/tmp/none', sha1: sha1, sizeBytes: 1, mimeType: 'image/jpeg');
|
||||
|
||||
void stubBase(BaseResource? result) {
|
||||
when(
|
||||
() => mocks.nativeApi.getBaseResource('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer((_) async => result);
|
||||
}
|
||||
|
||||
Future<EditPairPlan> resolve(LocalAsset asset) =>
|
||||
resolveEditPair(mocks.nativeApi, asset, stackRepository: mocks.stack);
|
||||
|
||||
setUp(() {
|
||||
// Default: the prior remote is alive, so absorb is allowed.
|
||||
when(() => mocks.stack.isRemoteTrashed(any())).thenAnswer((_) async => false);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
mocks.reset();
|
||||
});
|
||||
|
||||
group('resolveEditPair', () {
|
||||
test('reuses the prior remote when the asset was already uploaded as an edit', () async {
|
||||
final plan = await resolve(asset(priorRemoteId: 'remote-edit'));
|
||||
|
||||
expect(plan, isA<AbsorbIntoPrior>().having((p) => p.parentId, 'parentId', 'remote-edit'));
|
||||
verifyNever(() => mocks.nativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
});
|
||||
|
||||
test('uploads the base instead of absorbing when the prior remote was trashed', () async {
|
||||
when(() => mocks.stack.isRemoteTrashed('remote-edit')).thenAnswer((_) async => true);
|
||||
stubBase(base('different-sha1'));
|
||||
|
||||
final plan = await resolve(asset(priorRemoteId: 'remote-edit', adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
|
||||
|
||||
expect(plan, isA<UploadBaseFirst>());
|
||||
});
|
||||
|
||||
test('does not absorb a trashed prior even when the asset reads as not edited', () async {
|
||||
when(() => mocks.stack.isRemoteTrashed('remote-edit')).thenAnswer((_) async => true);
|
||||
|
||||
// Trashed prior + no adjustment → falls through to the gate, which skips.
|
||||
final plan = await resolve(asset(priorRemoteId: 'remote-edit', adjustmentTime: null));
|
||||
|
||||
expect(plan, isA<NoEditPair>());
|
||||
});
|
||||
|
||||
test('absorbs the prior when the trashed check itself fails (cheap-path safety)', () async {
|
||||
when(() => mocks.stack.isRemoteTrashed('remote-edit')).thenThrow(Exception('db error'));
|
||||
|
||||
final plan = await resolve(asset(priorRemoteId: 'remote-edit'));
|
||||
|
||||
expect(plan, isA<AbsorbIntoPrior>().having((p) => p.parentId, 'parentId', 'remote-edit'));
|
||||
});
|
||||
|
||||
test('skips a photo that was never adjusted without touching native', () async {
|
||||
final plan = await resolve(asset(adjustmentTime: null));
|
||||
|
||||
expect(plan, isA<NoEditPair>());
|
||||
verifyNever(() => mocks.nativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
});
|
||||
|
||||
test('skips a capture-time style (adjustment within the 2s window)', () async {
|
||||
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 1)));
|
||||
|
||||
expect(plan, isA<NoEditPair>());
|
||||
verifyNever(() => mocks.nativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
});
|
||||
|
||||
test('skips at exactly the 2s boundary (tolerance is exclusive)', () async {
|
||||
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 2)));
|
||||
|
||||
expect(plan, isA<NoEditPair>());
|
||||
verifyNever(() => mocks.nativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
});
|
||||
|
||||
test('checks the original just past the 2s boundary', () async {
|
||||
stubBase(base('different-sha1'));
|
||||
|
||||
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 3)));
|
||||
|
||||
expect(plan, isA<UploadBaseFirst>());
|
||||
verify(() => mocks.nativeApi.getBaseResource('local-1', allowNetworkAccess: true)).called(1);
|
||||
});
|
||||
|
||||
test('uploads the original first when a real edit moved the timestamp', () async {
|
||||
stubBase(base('different-sha1'));
|
||||
|
||||
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
|
||||
|
||||
expect(plan, isA<UploadBaseFirst>());
|
||||
verify(() => mocks.nativeApi.getBaseResource('local-1', allowNetworkAccess: true)).called(1);
|
||||
});
|
||||
|
||||
test('skips when the original cannot be read (offloaded to iCloud)', () async {
|
||||
stubBase(null);
|
||||
|
||||
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
|
||||
|
||||
expect(plan, isA<NoEditPair>());
|
||||
});
|
||||
|
||||
test('skips when the original bytes match the asset (auto-HDR, nothing to stack)', () async {
|
||||
stubBase(base('local-sha1'));
|
||||
|
||||
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
|
||||
|
||||
expect(plan, isA<NoEditPair>());
|
||||
});
|
||||
|
||||
test('skips when reading the original throws', () async {
|
||||
when(
|
||||
() => mocks.nativeApi.getBaseResource('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenThrow(Exception('boom'));
|
||||
|
||||
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
|
||||
|
||||
expect(plan, isA<NoEditPair>());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../mocks.dart';
|
||||
|
||||
void main() {
|
||||
late EditRevertService sut;
|
||||
final mocks = UnitMocks();
|
||||
|
||||
LocalAsset asset({String? priorRemoteId, String? checksum = 'reverted-sha1'}) => LocalAsset(
|
||||
id: 'local-1',
|
||||
name: 'photo.jpg',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025),
|
||||
updatedAt: DateTime(2025, 2),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
priorRemoteId: priorRemoteId,
|
||||
checksum: checksum,
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
sut = EditRevertService(
|
||||
nativeSyncApi: mocks.nativeApi,
|
||||
stackRepository: mocks.stack,
|
||||
localAssetRepository: mocks.localAsset,
|
||||
assetApiRepository: mocks.assetApi,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
mocks.reset();
|
||||
});
|
||||
|
||||
group('tryHandleRevert', () {
|
||||
test('returns false when the asset was never uploaded as an edit', () async {
|
||||
expect(await sut.tryHandleRevert(asset(priorRemoteId: null)), isFalse);
|
||||
verifyNever(() => mocks.nativeApi.getEditState(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
});
|
||||
|
||||
test('returns false (lets the pair flow run) when there is still a live edit', () async {
|
||||
when(
|
||||
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer((_) async => EditState.edited);
|
||||
|
||||
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
|
||||
verifyNever(() => mocks.stack.findStackIdByRemoteId(any()));
|
||||
});
|
||||
|
||||
test('returns false when the edit state cannot be read (offloaded to iCloud)', () async {
|
||||
when(
|
||||
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer((_) async => EditState.unknown);
|
||||
|
||||
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
|
||||
verifyNever(() => mocks.stack.findStackIdByRemoteId(any()));
|
||||
});
|
||||
|
||||
test('returns false when the prior remote is not in a stack', () async {
|
||||
when(
|
||||
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer((_) async => EditState.notEdited);
|
||||
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => null);
|
||||
|
||||
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
|
||||
verifyNever(() => mocks.assetApi.setStackPrimary(any(), any()));
|
||||
});
|
||||
|
||||
test('returns false when the stack has no base member to flip back to', () async {
|
||||
when(
|
||||
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer((_) async => EditState.notEdited);
|
||||
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => 'stack-1');
|
||||
when(() => mocks.stack.findStackBaseId('stack-1', excludeId: 'remote-edit')).thenAnswer((_) async => null);
|
||||
|
||||
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
|
||||
verifyNever(() => mocks.assetApi.setStackPrimary(any(), any()));
|
||||
});
|
||||
|
||||
test('flips the primary back to the base via prior_remote_id and keeps the edit (no trash)', () async {
|
||||
when(
|
||||
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer((_) async => EditState.notEdited);
|
||||
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => 'stack-1');
|
||||
when(
|
||||
() => mocks.stack.findStackBaseId('stack-1', excludeId: 'remote-edit'),
|
||||
).thenAnswer((_) async => 'remote-base');
|
||||
when(() => mocks.assetApi.setStackPrimary('stack-1', 'remote-base')).thenAnswer((_) async {});
|
||||
when(() => mocks.stack.setPrimary('stack-1', 'remote-base')).thenAnswer((_) async {});
|
||||
when(
|
||||
() => mocks.localAsset.markSynced(
|
||||
'local-1',
|
||||
priorRemoteId: 'remote-base',
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isTrue);
|
||||
|
||||
verify(() => mocks.assetApi.setStackPrimary('stack-1', 'remote-base')).called(1);
|
||||
verify(() => mocks.stack.setPrimary('stack-1', 'remote-base')).called(1);
|
||||
verify(
|
||||
() => mocks.localAsset.markSynced(
|
||||
'local-1',
|
||||
priorRemoteId: 'remote-base',
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
).called(1);
|
||||
// Nothing is trashed or unstacked; every edit stays in the stack.
|
||||
verifyNever(() => mocks.assetApi.delete(any(), any()));
|
||||
verifyNever(() => mocks.assetApi.unStack(any()));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
@@ -18,6 +20,8 @@ void main() {
|
||||
localAssetRepository: mocks.localAsset,
|
||||
nativeSyncApi: mocks.nativeApi,
|
||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||
stackRepository: mocks.stack,
|
||||
assetApiRepository: mocks.assetApi,
|
||||
);
|
||||
|
||||
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
|
||||
@@ -110,6 +114,8 @@ void main() {
|
||||
nativeSyncApi: mocks.nativeApi,
|
||||
batchSize: batchSize,
|
||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||
stackRepository: mocks.stack,
|
||||
assetApiRepository: mocks.assetApi,
|
||||
);
|
||||
|
||||
final album = LocalAlbumFactory.create();
|
||||
@@ -183,5 +189,61 @@ void main() {
|
||||
verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('iOS revert reconcile', () {
|
||||
test('flips the stack primary for a non-styled revert that re-hashed to the base', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
registerFallbackValue(<String>[]);
|
||||
|
||||
final album = LocalAlbumFactory.create();
|
||||
final asset = LocalAssetFactory.create();
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(
|
||||
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: 'h')]);
|
||||
|
||||
const target = StackReconcileTarget(
|
||||
stackId: 'stack-1',
|
||||
newPrimaryId: 'base-1',
|
||||
localAssetId: 'local-1',
|
||||
localAssetChecksum: 'reverted-sha1',
|
||||
);
|
||||
when(() => mocks.stack.findRevertReconcileTargets(any())).thenAnswer((_) async => [target]);
|
||||
when(() => mocks.assetApi.setStackPrimary('stack-1', 'base-1')).thenAnswer((_) async {});
|
||||
when(() => mocks.stack.setPrimary('stack-1', 'base-1')).thenAnswer((_) async {});
|
||||
when(
|
||||
() => mocks.localAsset.markSynced('local-1', priorRemoteId: 'base-1', syncedChecksum: 'reverted-sha1'),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verify(() => mocks.assetApi.setStackPrimary('stack-1', 'base-1')).called(1);
|
||||
verify(() => mocks.stack.setPrimary('stack-1', 'base-1')).called(1);
|
||||
verify(
|
||||
() => mocks.localAsset.markSynced('local-1', priorRemoteId: 'base-1', syncedChecksum: 'reverted-sha1'),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('does not reconcile on a non-iOS platform', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
registerFallbackValue(<String>[]);
|
||||
|
||||
final album = LocalAlbumFactory.create();
|
||||
final asset = LocalAssetFactory.create();
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(() => mocks.trashedAsset.getAssetsToHash(any())).thenAnswer((_) async => []);
|
||||
when(
|
||||
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: 'h')]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verifyNever(() => mocks.stack.findRevertReconcileTargets(any()));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16899,6 +16899,12 @@
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
},
|
||||
"stackParentId": {
|
||||
"description": "Stack this asset onto the parent asset, with the new asset as the stack primary",
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
},
|
||||
"visibility": {
|
||||
"$ref": "#/components/schemas/AssetVisibility"
|
||||
}
|
||||
@@ -19907,12 +19913,6 @@
|
||||
"description": "Whether people are enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"minimumFaces": {
|
||||
"description": "People face threshold",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"sidebarWeb": {
|
||||
"description": "Whether people appear in web sidebar",
|
||||
"type": "boolean"
|
||||
@@ -19974,12 +19974,6 @@
|
||||
"description": "Whether people are enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"minimumFaces": {
|
||||
"description": "People face threshold",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"sidebarWeb": {
|
||||
"description": "Whether people appear in web sidebar",
|
||||
"type": "boolean"
|
||||
@@ -21616,12 +21610,6 @@
|
||||
"description": "Map light style URL",
|
||||
"type": "string"
|
||||
},
|
||||
"minFaces": {
|
||||
"description": "People min faces server default",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
},
|
||||
"oauthButtonText": {
|
||||
"description": "OAuth button text",
|
||||
"type": "string"
|
||||
@@ -21651,7 +21639,6 @@
|
||||
"maintenanceMode",
|
||||
"mapDarkStyleUrl",
|
||||
"mapLightStyleUrl",
|
||||
"minFaces",
|
||||
"oauthButtonText",
|
||||
"publicUsers",
|
||||
"trashDays",
|
||||
|
||||
@@ -55,22 +55,6 @@
|
||||
}
|
||||
],
|
||||
"uiHints": ["SmartAlbum"]
|
||||
},
|
||||
{
|
||||
"name": "asset-webhook",
|
||||
"title": "Send a webhook",
|
||||
"description": "Send the information of newly uploaded assets to an external endpoint",
|
||||
"trigger": "AssetCreate",
|
||||
"steps": [
|
||||
{
|
||||
"method": "immich-plugin-core#webhook",
|
||||
"config": {
|
||||
"url": "",
|
||||
"method": "POST"
|
||||
}
|
||||
}
|
||||
],
|
||||
"uiHints": ["Webhook"]
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
@@ -254,36 +238,6 @@
|
||||
"required": ["albumIds"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "webhook",
|
||||
"title": "Send a webhook",
|
||||
"description": "Send the asset information to an external endpoint",
|
||||
"types": ["AssetV1"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"title": "Webhook URL",
|
||||
"description": "The endpoint that will receive the asset information"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"title": "HTTP method",
|
||||
"enum": ["POST", "PUT", "PATCH"],
|
||||
"default": "POST",
|
||||
"description": "HTTP method used to send the request"
|
||||
},
|
||||
"secret": {
|
||||
"type": "string",
|
||||
"title": "Secret",
|
||||
"description": "Optional value sent as the X-Immich-Webhook-Secret header so the receiver can verify the request"
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
},
|
||||
"uiHints": ["Webhook"]
|
||||
},
|
||||
{
|
||||
"name": "noop1",
|
||||
"title": "DEV: Nested properties",
|
||||
|
||||
Vendored
-3
@@ -22,7 +22,4 @@ declare module 'main' {
|
||||
export function assetTimeline(): I32;
|
||||
export function assetTrash(): I32;
|
||||
export function assetAddToAlbums(): I32;
|
||||
|
||||
// integrations
|
||||
export function webhook(): I32;
|
||||
}
|
||||
|
||||
@@ -102,44 +102,6 @@ export const assetTrash = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
|
||||
};
|
||||
|
||||
type WebhookConfig = {
|
||||
url: string;
|
||||
method?: 'PATCH' | 'POST' | 'PUT';
|
||||
secret?: string;
|
||||
};
|
||||
export const webhook = () => {
|
||||
return wrapper<WorkflowType.AssetV1, WebhookConfig>(({ config, data, trigger, workflow }) => {
|
||||
const { url, method = 'POST', secret } = config;
|
||||
if (!url) {
|
||||
console.warn('Webhook step skipped: no URL configured');
|
||||
return {};
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Immich',
|
||||
};
|
||||
if (secret) {
|
||||
headers['X-Immich-Webhook-Secret'] = secret;
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
trigger,
|
||||
workflowId: workflow.id,
|
||||
asset: data.asset,
|
||||
});
|
||||
|
||||
const response = Http.request({ url, method, headers }, body);
|
||||
if (response.status >= 400) {
|
||||
console.error(`Webhook request to ${url} failed with status ${response.status}`);
|
||||
} else {
|
||||
console.debug(`Webhook request to ${url} returned status ${response.status}`);
|
||||
}
|
||||
|
||||
return {};
|
||||
});
|
||||
};
|
||||
|
||||
export const assetAddToAlbums = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { albumIds: string[]; albumName?: string }>(({ config, data, functions }) => {
|
||||
const assetId = data.asset.id;
|
||||
|
||||
@@ -298,8 +298,6 @@ export type MemoriesResponse = {
|
||||
export type PeopleResponse = {
|
||||
/** Whether people are enabled */
|
||||
enabled: boolean;
|
||||
/** People face threshold */
|
||||
minimumFaces?: number;
|
||||
/** Whether people appear in web sidebar */
|
||||
sidebarWeb: boolean;
|
||||
};
|
||||
@@ -377,8 +375,6 @@ export type MemoriesUpdate = {
|
||||
export type PeopleUpdate = {
|
||||
/** Whether people are enabled */
|
||||
enabled?: boolean;
|
||||
/** People face threshold */
|
||||
minimumFaces?: number;
|
||||
/** Whether people appear in web sidebar */
|
||||
sidebarWeb?: boolean;
|
||||
};
|
||||
@@ -634,6 +630,8 @@ export type AssetMediaCreateDto = {
|
||||
metadata?: AssetMetadataUpsertItemDto[];
|
||||
/** Sidecar file data */
|
||||
sidecarData?: Blob;
|
||||
/** Stack this asset onto the parent asset, with the new asset as the stack primary */
|
||||
stackParentId?: string;
|
||||
visibility?: AssetVisibility;
|
||||
};
|
||||
export type AssetMediaResponseDto = {
|
||||
@@ -1967,8 +1965,6 @@ export type ServerConfigDto = {
|
||||
mapDarkStyleUrl: string;
|
||||
/** Map light style URL */
|
||||
mapLightStyleUrl: string;
|
||||
/** People min faces server default */
|
||||
minFaces: number;
|
||||
/** OAuth button text */
|
||||
oauthButtonText: string;
|
||||
/** Whether public user registration is enabled */
|
||||
|
||||
Generated
+109
-33
@@ -695,8 +695,8 @@ importers:
|
||||
specifier: ^13.15.2
|
||||
version: 13.15.10
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.0.0
|
||||
version: 4.1.7(vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0))
|
||||
specifier: ^3.0.0
|
||||
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0))
|
||||
eslint:
|
||||
specifier: ^10.0.0
|
||||
version: 10.4.0(jiti@2.7.0)
|
||||
@@ -734,8 +734,8 @@ importers:
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.19(tsx@4.22.3)(yaml@2.9.0)
|
||||
testcontainers:
|
||||
specifier: ^12.0.0
|
||||
version: 12.0.1
|
||||
specifier: ^11.0.0
|
||||
version: 11.14.0
|
||||
typescript:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.3
|
||||
@@ -928,7 +928,7 @@ importers:
|
||||
version: 14.6.1(@testing-library/dom@10.4.1)
|
||||
'@trivago/prettier-plugin-sort-imports':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2(prettier-plugin-svelte@4.1.0(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4)))(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
||||
version: 6.0.2(prettier-plugin-svelte@3.5.2(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4)))(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
||||
'@types/chromecast-caf-sender':
|
||||
specifier: ^1.0.11
|
||||
version: 1.0.11
|
||||
@@ -984,8 +984,8 @@ importers:
|
||||
specifier: ^4.1.1
|
||||
version: 4.2.0(prettier@3.8.3)
|
||||
prettier-plugin-svelte:
|
||||
specifier: ^4.0.0
|
||||
version: 4.1.0(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
||||
specifier: ^3.3.3
|
||||
version: 3.5.2(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
||||
rollup-plugin-visualizer:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.1(rolldown@1.0.1)(rollup@4.60.4)
|
||||
@@ -1110,6 +1110,10 @@ packages:
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@angular-devkit/core@19.2.24':
|
||||
resolution: {integrity: sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==}
|
||||
engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
|
||||
@@ -3375,6 +3379,10 @@ packages:
|
||||
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@istanbuljs/schema@0.1.6':
|
||||
resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@jest/schemas@29.6.3':
|
||||
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
@@ -5684,6 +5692,15 @@ packages:
|
||||
peerDependencies:
|
||||
valibot: ^1.4.0
|
||||
|
||||
'@vitest/coverage-v8@3.2.4':
|
||||
resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 3.2.4
|
||||
vitest: 3.2.4
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
||||
'@vitest/coverage-v8@4.1.7':
|
||||
resolution: {integrity: sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==}
|
||||
peerDependencies:
|
||||
@@ -6034,6 +6051,9 @@ packages:
|
||||
ast-metadata-inferer@0.8.1:
|
||||
resolution: {integrity: sha512-ht3Dm6Zr7SXv6t1Ra6gFo0+kLDglHGrEbYihTkcycrbHw7WCcuhBzPlJYHEsIpycaUwzsJHje+vUcxXUX4ztTA==}
|
||||
|
||||
ast-v8-to-istanbul@0.3.12:
|
||||
resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==}
|
||||
|
||||
ast-v8-to-istanbul@1.0.0:
|
||||
resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==}
|
||||
|
||||
@@ -7292,9 +7312,9 @@ packages:
|
||||
resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==}
|
||||
engines: {node: '>= 8.0'}
|
||||
|
||||
dockerode@5.0.0:
|
||||
resolution: {integrity: sha512-C52mvJ+7lcyhWNfrzVfFsbTrBfy/ezE9FGEYLpu17FUeBcCkxERk9nN7uDl/478ynDiQ4U+5DbQC2vENHkVEtQ==}
|
||||
engines: {node: '>= 14.17'}
|
||||
dockerode@4.0.12:
|
||||
resolution: {integrity: sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==}
|
||||
engines: {node: '>= 8.0'}
|
||||
|
||||
docusaurus-lunr-search@3.6.0:
|
||||
resolution: {integrity: sha512-CCEAnj5e67sUZmIb2hOl4xb4nDN07fb0fvRDDmdWlYpUvyS1CSKbw4lsGInLyUFEEEBzxQmT6zaVQdF/8Zretg==}
|
||||
@@ -8704,6 +8724,10 @@ packages:
|
||||
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
istanbul-lib-source-maps@5.0.6:
|
||||
resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -9154,6 +9178,9 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
magicast@0.3.5:
|
||||
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
|
||||
|
||||
magicast@0.5.3:
|
||||
resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==}
|
||||
|
||||
@@ -10678,12 +10705,11 @@ packages:
|
||||
peerDependencies:
|
||||
prettier: ^3.0.0
|
||||
|
||||
prettier-plugin-svelte@4.1.0:
|
||||
resolution: {integrity: sha512-YZkhA2Q9oOerFFG9tq+2f98WYT7Z2JgrybJrAyrB78jpsH9i/DdgplXemehuFPgsldetFNCcR/yCcYlDjPy94Q==}
|
||||
engines: {node: '>=20'}
|
||||
prettier-plugin-svelte@3.5.2:
|
||||
resolution: {integrity: sha512-ItFouLvzSFE3ulNl4DKoWM3BGcbDCNVpIyy/Y3F2gC3aNiGLxtFUdffVqO5Z5hhYG+DFT5KULWaxmeFFpdbvaQ==}
|
||||
peerDependencies:
|
||||
prettier: ^3.0.0
|
||||
svelte: ^5.0.0
|
||||
svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0
|
||||
|
||||
prettier@3.8.3:
|
||||
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
|
||||
@@ -11856,8 +11882,12 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
testcontainers@12.0.1:
|
||||
resolution: {integrity: sha512-EMjjfMNJf3HlL7V3elkxqKUO1r3CtqNBTdmKGwwma/lOtUGfoWvFJ0WQ/KQf1DHEMnRjLWzW4cXbv/Tndsbcbw==}
|
||||
test-exclude@7.0.2:
|
||||
resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
testcontainers@11.14.0:
|
||||
resolution: {integrity: sha512-r9pniwv/iwzyHaI7gwAvAm4Y+IvjJg3vBWdjrUCaDMc2AXIr4jKbq7jJO18Mw2ybs73pZy1Aj7p/4RVBGMRWjg==}
|
||||
|
||||
text-decoder@1.2.7:
|
||||
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
|
||||
@@ -11947,8 +11977,8 @@ packages:
|
||||
resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==}
|
||||
hasBin: true
|
||||
|
||||
tmp@0.2.7:
|
||||
resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==}
|
||||
tmp@0.2.5:
|
||||
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
@@ -12289,6 +12319,11 @@ packages:
|
||||
resolution: {integrity: sha512-6S5mCapmzcxetOD/2UEjL0GF5e4+gB07Dh8qs63xylw5ay4XuyW6iQs70FOJo/puf10LCkvhp4jYMQSDUBYEFg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
uuid@10.0.0:
|
||||
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
uuid@14.0.0:
|
||||
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
|
||||
hasBin: true
|
||||
@@ -12991,6 +13026,11 @@ snapshots:
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@angular-devkit/core@19.2.24(chokidar@4.0.3)':
|
||||
dependencies:
|
||||
ajv: 8.18.0
|
||||
@@ -16042,6 +16082,8 @@ snapshots:
|
||||
dependencies:
|
||||
minipass: 7.1.3
|
||||
|
||||
'@istanbuljs/schema@0.1.6': {}
|
||||
|
||||
'@jest/schemas@29.6.3':
|
||||
dependencies:
|
||||
'@sinclair/typebox': 0.27.10
|
||||
@@ -17783,7 +17825,7 @@ snapshots:
|
||||
|
||||
'@tokenizer/token@0.3.0': {}
|
||||
|
||||
'@trivago/prettier-plugin-sort-imports@6.0.2(prettier-plugin-svelte@4.1.0(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4)))(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
|
||||
'@trivago/prettier-plugin-sort-imports@6.0.2(prettier-plugin-svelte@3.5.2(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4)))(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
|
||||
dependencies:
|
||||
'@babel/generator': 7.29.1
|
||||
'@babel/parser': 7.29.3
|
||||
@@ -17795,7 +17837,7 @@ snapshots:
|
||||
parse-imports-exports: 0.2.4
|
||||
prettier: 3.8.3
|
||||
optionalDependencies:
|
||||
prettier-plugin-svelte: 4.1.0(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
||||
prettier-plugin-svelte: 3.5.2(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
||||
svelte: 5.55.8(@typescript-eslint/types@8.59.4)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -18478,19 +18520,24 @@ snapshots:
|
||||
dependencies:
|
||||
valibot: 1.4.0(typescript@6.0.3)
|
||||
|
||||
'@vitest/coverage-v8@4.1.7(vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0))':
|
||||
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.1.7
|
||||
ast-v8-to-istanbul: 1.0.0
|
||||
ast-v8-to-istanbul: 0.3.12
|
||||
debug: 4.4.3
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-lib-source-maps: 5.0.6
|
||||
istanbul-reports: 3.2.0
|
||||
magicast: 0.5.3
|
||||
obug: 2.1.1
|
||||
std-env: 4.1.0
|
||||
tinyrainbow: 3.1.0
|
||||
magic-string: 0.30.21
|
||||
magicast: 0.3.5
|
||||
std-env: 3.10.0
|
||||
test-exclude: 7.0.2
|
||||
tinyrainbow: 2.0.0
|
||||
vitest: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/coverage-v8@4.1.7(vitest@4.1.7)':
|
||||
dependencies:
|
||||
@@ -18904,6 +18951,12 @@ snapshots:
|
||||
dependencies:
|
||||
'@mdn/browser-compat-data': 5.7.6
|
||||
|
||||
ast-v8-to-istanbul@0.3.12:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
estree-walker: 3.0.3
|
||||
js-tokens: 10.0.0
|
||||
|
||||
ast-v8-to-istanbul@1.0.0:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
@@ -20175,7 +20228,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
dockerode@5.0.0:
|
||||
dockerode@4.0.12:
|
||||
dependencies:
|
||||
'@balena/dockerignore': 1.0.2
|
||||
'@grpc/grpc-js': 1.14.3
|
||||
@@ -20183,6 +20236,7 @@ snapshots:
|
||||
docker-modem: 5.0.7
|
||||
protobufjs: 7.6.0
|
||||
tar-fs: 2.1.4
|
||||
uuid: 10.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -21950,6 +22004,14 @@ snapshots:
|
||||
make-dir: 4.0.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
istanbul-lib-source-maps@5.0.6:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
debug: 4.4.3
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
dependencies:
|
||||
html-escaper: 2.0.2
|
||||
@@ -22367,6 +22429,12 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magicast@0.3.5:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/types': 7.29.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
magicast@0.5.3:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.3
|
||||
@@ -24237,7 +24305,7 @@ snapshots:
|
||||
dependencies:
|
||||
prettier: 3.8.3
|
||||
|
||||
prettier-plugin-svelte@4.1.0(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4)):
|
||||
prettier-plugin-svelte@3.5.2(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4)):
|
||||
dependencies:
|
||||
prettier: 3.8.3
|
||||
svelte: 5.55.8(@typescript-eslint/types@8.59.4)
|
||||
@@ -25780,7 +25848,13 @@ snapshots:
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
|
||||
testcontainers@12.0.1:
|
||||
test-exclude@7.0.2:
|
||||
dependencies:
|
||||
'@istanbuljs/schema': 0.1.6
|
||||
glob: 10.5.0
|
||||
minimatch: 10.2.5
|
||||
|
||||
testcontainers@11.14.0:
|
||||
dependencies:
|
||||
'@balena/dockerignore': 1.0.2
|
||||
'@types/dockerode': 4.0.1
|
||||
@@ -25789,13 +25863,13 @@ snapshots:
|
||||
byline: 5.0.0
|
||||
debug: 4.4.3
|
||||
docker-compose: 1.4.2
|
||||
dockerode: 5.0.0
|
||||
dockerode: 4.0.12
|
||||
get-port: 7.2.0
|
||||
proper-lockfile: 4.1.2
|
||||
properties-reader: 3.0.1
|
||||
ssh-remote-port-forward: 1.0.4
|
||||
tar-fs: 3.1.2
|
||||
tmp: 0.2.7
|
||||
tmp: 0.2.5
|
||||
undici: 7.25.0
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
@@ -25876,7 +25950,7 @@ snapshots:
|
||||
tldts-core: 6.1.86
|
||||
optional: true
|
||||
|
||||
tmp@0.2.7: {}
|
||||
tmp@0.2.5: {}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
@@ -26226,6 +26300,8 @@ snapshots:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
uuid@10.0.0: {}
|
||||
|
||||
uuid@14.0.0: {}
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/immich-app/base-server-dev:202606021219@sha256:63fa91aa011f6f2921dd32fe6d1be8d637e9bd7f3e3dd0c8e446afb31b282af4 AS builder
|
||||
FROM ghcr.io/immich-app/base-server-dev:202605051129@sha256:d07d8fcdb7e9f3ac22a811e87761ebf341ed0bb91956b89097540c2ed3fb9ca3 AS builder
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp \
|
||||
@@ -80,7 +80,7 @@ RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \
|
||||
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||
mise //:plugins
|
||||
|
||||
FROM ghcr.io/immich-app/base-server-prod:202606021219@sha256:6ef9ef5859492149af770a6c884b5e2ddbaeef99f8885ea5f2d9f73625a3d9ec
|
||||
FROM ghcr.io/immich-app/base-server-prod:202605051129@sha256:50f7ffe4ed31e360c90c4905bd5f6658f2a121297544e3fe9368e338b3f76bcd
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:202606021219@sha256:63fa91aa011f6f2921dd32fe6d1be8d637e9bd7f3e3dd0c8e446afb31b282af4 AS dev
|
||||
FROM ghcr.io/immich-app/base-server-dev:202605051129@sha256:d07d8fcdb7e9f3ac22a811e87761ebf341ed0bb91956b89097540c2ed3fb9ca3 AS dev
|
||||
|
||||
|
||||
COPY --from=ghcr.io/jdx/mise:2026.5.18@sha256:5bb3311994fa78cef307ca3077cdb18f9551da0886371fc26ea91ab56220ffc5 /usr/local/bin/mise /usr/local/bin/mise
|
||||
|
||||
+2
-2
@@ -147,7 +147,7 @@
|
||||
"@types/supertest": "^7.0.0",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@types/validator": "^13.15.2",
|
||||
"@vitest/coverage-v8": "^4.0.0",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
@@ -160,7 +160,7 @@
|
||||
"sql-formatter": "^15.0.0",
|
||||
"supertest": "^7.1.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"testcontainers": "^12.0.0",
|
||||
"testcontainers": "^11.0.0",
|
||||
"typescript": "^6.0.0",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"unplugin-swc": "^1.4.5",
|
||||
|
||||
@@ -48,6 +48,10 @@ const AssetMediaCreateSchema = AssetMediaBaseSchema.extend({
|
||||
isFavorite: stringToBool.optional().describe('Mark as favorite'),
|
||||
visibility: AssetVisibilitySchema.optional(),
|
||||
livePhotoVideoId: z.uuidv4().optional().describe('Live photo video ID'),
|
||||
stackParentId: z
|
||||
.uuidv4()
|
||||
.optional()
|
||||
.describe('Stack this asset onto the parent asset, with the new asset as the stack primary'),
|
||||
metadata: JsonParsed.pipe(z.array(AssetMetadataUpsertItemSchema)).optional().describe('Asset metadata items'),
|
||||
[UploadFieldName.SIDECAR_DATA]: z
|
||||
.any()
|
||||
|
||||
@@ -124,7 +124,6 @@ const ServerConfigSchema = z
|
||||
mapDarkStyleUrl: z.string().describe('Map dark style URL'),
|
||||
mapLightStyleUrl: z.string().describe('Map light style URL'),
|
||||
maintenanceMode: z.boolean().describe('Whether maintenance mode is active'),
|
||||
minFaces: z.int().describe('People min faces server default'),
|
||||
})
|
||||
.meta({ id: 'ServerConfigDto' });
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ const PeopleUpdateSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional().describe('Whether people are enabled'),
|
||||
sidebarWeb: z.boolean().optional().describe('Whether people appear in web sidebar'),
|
||||
minimumFaces: z.int().min(1).optional().describe('People face threshold'),
|
||||
})
|
||||
.optional()
|
||||
.meta({ id: 'PeopleUpdate' });
|
||||
@@ -139,7 +138,6 @@ const PeopleResponseSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().describe('Whether people are enabled'),
|
||||
sidebarWeb: z.boolean().describe('Whether people appear in web sidebar'),
|
||||
minimumFaces: z.int().min(1).optional().describe('People face threshold'),
|
||||
})
|
||||
.meta({ id: 'PeopleResponse' });
|
||||
|
||||
|
||||
@@ -42,18 +42,7 @@ group by
|
||||
having
|
||||
(
|
||||
"person"."name" != $3
|
||||
or count("asset_face"."assetId") >= COALESCE(
|
||||
(
|
||||
SELECT
|
||||
value -> 'people' ->> 'minimumFaces'
|
||||
FROM
|
||||
user_metadata
|
||||
WHERE
|
||||
"userId" = $4
|
||||
AND key = 'preferences'
|
||||
),
|
||||
'3'
|
||||
)::int
|
||||
or count("asset_face"."assetId") >= $4
|
||||
)
|
||||
order by
|
||||
"person"."isHidden" asc,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { AssetFace } from 'src/database';
|
||||
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AssetFileType, AssetVisibility, SourceType, UserMetadataKey } from 'src/enum';
|
||||
import { AssetFileType, AssetVisibility, SourceType } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
||||
@@ -13,6 +13,7 @@ import { dummy, removeUndefinedKeys, withFilePath } from 'src/utils/database';
|
||||
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
|
||||
|
||||
export interface PersonSearchOptions {
|
||||
minimumFaceCount: number;
|
||||
withHidden: boolean;
|
||||
closestFaceAssetId?: string;
|
||||
}
|
||||
@@ -167,17 +168,7 @@ export class PersonRepository {
|
||||
.having((eb) =>
|
||||
eb.or([
|
||||
eb('person.name', '!=', ''),
|
||||
eb(
|
||||
(innerEb) => innerEb.fn.count('asset_face.assetId'),
|
||||
'>=',
|
||||
sql<number>`COALESCE(
|
||||
(SELECT value -> 'people' ->> 'minimumFaces'
|
||||
FROM user_metadata
|
||||
WHERE "userId" = ${userId}
|
||||
AND key = ${sql.lit(UserMetadataKey.Preferences)}),
|
||||
'3'
|
||||
)::int `,
|
||||
),
|
||||
eb((innerEb) => innerEb.fn.count('asset_face.assetId'), '>=', options?.minimumFaceCount || 1),
|
||||
]),
|
||||
)
|
||||
.groupBy('person.id')
|
||||
|
||||
@@ -213,8 +213,6 @@ export class PluginRepository {
|
||||
{
|
||||
useWasi: true,
|
||||
runInWorker,
|
||||
// allow plugins (e.g. the webhook workflow step) to make outbound HTTP requests
|
||||
allowedHosts: ['*'],
|
||||
functions: {
|
||||
'extism:host/user': functions ?? {},
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import { columns } from 'src/database';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { DB } from 'src/schema';
|
||||
import { StackTable } from 'src/schema/tables/stack.table';
|
||||
import { asUuid, withDefaultVisibility } from 'src/utils/database';
|
||||
import { asUuid, isStackPrimaryConstraint, withDefaultVisibility } from 'src/utils/database';
|
||||
|
||||
export interface StackSearch {
|
||||
ownerId: string;
|
||||
@@ -124,6 +124,63 @@ export class StackRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async linkAsset(
|
||||
ownerId: string,
|
||||
newAssetId: string,
|
||||
parentId: string,
|
||||
): Promise<{ stackId: string; created: boolean } | null> {
|
||||
try {
|
||||
return await this.db.transaction().execute(async (tx) => {
|
||||
// Lock the parent so two concurrent uploads can't each create a stack for it.
|
||||
const parent = await tx
|
||||
.selectFrom('asset')
|
||||
.select(['id', 'ownerId', 'stackId', 'deletedAt'])
|
||||
.where('id', '=', asUuid(parentId))
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!parent || parent.ownerId !== ownerId || parent.deletedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parent.stackId) {
|
||||
await tx
|
||||
.updateTable('asset')
|
||||
.set({ stackId: parent.stackId, updatedAt: new Date() })
|
||||
.where('id', '=', asUuid(newAssetId))
|
||||
.execute();
|
||||
await tx
|
||||
.updateTable('stack')
|
||||
.set({ primaryAssetId: newAssetId, updatedAt: new Date() })
|
||||
.where('id', '=', parent.stackId)
|
||||
.execute();
|
||||
return { stackId: parent.stackId, created: false };
|
||||
}
|
||||
|
||||
const stack = await tx
|
||||
.insertInto('stack')
|
||||
.values({ ownerId, primaryAssetId: newAssetId })
|
||||
.returning('id')
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await tx
|
||||
.updateTable('asset')
|
||||
.set({ stackId: stack.id, updatedAt: new Date() })
|
||||
.where('id', 'in', [asUuid(newAssetId), parent.id])
|
||||
.execute();
|
||||
|
||||
return { stackId: stack.id, created: true };
|
||||
});
|
||||
} catch (error) {
|
||||
// newAssetId may already be another stack's primary (e.g. a retried upload).
|
||||
// Treat the unique-constraint hit as "couldn't stack" rather than a 500.
|
||||
if (isStackPrimaryConstraint(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.db.deleteFrom('stack').where('id', '=', asUuid(id)).execute();
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
// Delete unauthorized cross-owner asset faces
|
||||
await sql`
|
||||
DELETE FROM asset_face
|
||||
USING person, asset
|
||||
WHERE asset_face."personId" = person.id
|
||||
AND asset_face."assetId" = asset.id
|
||||
AND person."ownerId" != asset."ownerId"
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// Not implemented: the deleted rows were unauthorized cross-owner entries
|
||||
}
|
||||
@@ -417,6 +417,80 @@ describe(AssetMediaService.name, () => {
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should stack a new asset onto the parent and emit the populated stackId', async () => {
|
||||
const file = {
|
||||
uuid: 'random-uuid',
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
size: 42,
|
||||
};
|
||||
const parent = AssetFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([parent.id]));
|
||||
mocks.asset.getById.mockResolvedValueOnce(getForAsset(parent));
|
||||
mocks.asset.create.mockResolvedValue(assetEntity);
|
||||
mocks.stack.linkAsset.mockResolvedValue({ stackId: 'stack-1', created: true });
|
||||
|
||||
await expect(sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file)).resolves.toEqual({
|
||||
id: 'id_1',
|
||||
status: AssetMediaStatus.CREATED,
|
||||
});
|
||||
|
||||
expect(mocks.stack.linkAsset).toHaveBeenCalledWith(authStub.user1.user.id, assetEntity.id, parent.id);
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('AssetCreate', {
|
||||
asset: expect.objectContaining({ stackId: 'stack-1' }),
|
||||
file: expect.objectContaining({ originalPath: file.originalPath }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject stacking onto a trashed asset', async () => {
|
||||
const file = {
|
||||
uuid: 'random-uuid',
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
size: 42,
|
||||
};
|
||||
const parent = AssetFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([parent.id]));
|
||||
mocks.asset.getById.mockResolvedValueOnce({ ...getForAsset(parent), deletedAt: new Date() });
|
||||
|
||||
await expect(
|
||||
sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.asset.create).not.toHaveBeenCalled();
|
||||
expect(mocks.stack.linkAsset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should adopt a duplicate into the stack when stacking', async () => {
|
||||
const file = {
|
||||
uuid: 'random-uuid',
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
size: 0,
|
||||
};
|
||||
const parent = AssetFactory.create();
|
||||
const error = new Error('unique key violation');
|
||||
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([parent.id]));
|
||||
mocks.asset.getById.mockResolvedValueOnce(getForAsset(parent));
|
||||
mocks.asset.create.mockRejectedValue(error);
|
||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('dup-id');
|
||||
mocks.stack.linkAsset.mockResolvedValue({ stackId: 'stack-1', created: false });
|
||||
|
||||
await expect(sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file)).resolves.toEqual({
|
||||
id: 'dup-id',
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
});
|
||||
|
||||
expect(mocks.stack.linkAsset).toHaveBeenCalledWith(authStub.user1.user.id, 'dup-id', parent.id);
|
||||
});
|
||||
|
||||
it('should hide the linked motion asset', async () => {
|
||||
const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build();
|
||||
const asset = AssetFactory.create();
|
||||
|
||||
@@ -140,88 +140,62 @@ export class AssetMediaService extends BaseService {
|
||||
|
||||
this.requireQuota(auth, file.size);
|
||||
|
||||
if (dto.stackParentId) {
|
||||
if (auth.sharedLink) {
|
||||
throw new BadRequestException('Cannot stack an asset uploaded via shared link');
|
||||
}
|
||||
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [dto.stackParentId] });
|
||||
const parent = await this.assetRepository.getById(dto.stackParentId);
|
||||
if (!parent || parent.deletedAt) {
|
||||
throw new BadRequestException('Cannot stack onto a trashed or missing asset');
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.livePhotoVideoId) {
|
||||
await onBeforeLink(
|
||||
{ asset: this.assetRepository, event: this.eventRepository },
|
||||
{ userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
|
||||
);
|
||||
}
|
||||
|
||||
const asset = await this.assetRepository.create({
|
||||
ownerId: auth.user.id,
|
||||
libraryId: null,
|
||||
|
||||
checksum: file.checksum,
|
||||
checksumAlgorithm: ChecksumAlgorithm.sha1File,
|
||||
originalPath: file.originalPath,
|
||||
|
||||
fileCreatedAt: dto.fileCreatedAt,
|
||||
fileModifiedAt: dto.fileModifiedAt,
|
||||
localDateTime: dto.fileCreatedAt,
|
||||
|
||||
type: mimeTypes.assetType(file.originalPath),
|
||||
isFavorite: dto.isFavorite,
|
||||
duration: dto.duration || null,
|
||||
visibility: dto.visibility ?? AssetVisibility.Timeline,
|
||||
livePhotoVideoId: dto.livePhotoVideoId,
|
||||
originalFileName: dto.filename || file.originalName,
|
||||
});
|
||||
|
||||
if (dto.metadata?.length) {
|
||||
await this.assetRepository.upsertMetadata(asset.id, dto.metadata);
|
||||
}
|
||||
|
||||
if (sidecarFile) {
|
||||
await this.assetRepository.upsertFile({
|
||||
assetId: asset.id,
|
||||
path: sidecarFile.originalPath,
|
||||
type: AssetFileType.Sidecar,
|
||||
});
|
||||
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
}
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif({
|
||||
exif: { assetId: asset.id, fileSizeInByte: file.size },
|
||||
lockedPropertiesBehavior: 'override',
|
||||
});
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } });
|
||||
// When stacking, defer the AssetCreate event and emit it below with the
|
||||
// populated stackId, so clients don't briefly see the asset as standalone.
|
||||
const asset = await this.create(auth.user.id, dto, file, sidecarFile, { skipEventEmit: !!dto.stackParentId });
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.addToSharedLink(auth.sharedLink, asset.id);
|
||||
}
|
||||
|
||||
await this.eventRepository.emit('AssetCreate', { asset, file });
|
||||
if (dto.stackParentId) {
|
||||
const linkResult = await this.linkToStackParent(auth.user.id, asset.id, dto.stackParentId);
|
||||
await this.eventRepository.emit('AssetCreate', {
|
||||
asset: linkResult ? { ...asset, stackId: linkResult.stackId } : asset,
|
||||
file,
|
||||
});
|
||||
}
|
||||
|
||||
return { id: asset.id, status: AssetMediaStatus.CREATED };
|
||||
} catch (error: any) {
|
||||
// clean up files
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.FileDelete,
|
||||
data: { files: [file.originalPath, sidecarFile?.originalPath] },
|
||||
});
|
||||
|
||||
// handle duplicates with a success response
|
||||
if (isAssetChecksumConstraint(error)) {
|
||||
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
|
||||
if (!duplicateId) {
|
||||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.addToSharedLink(auth.sharedLink, duplicateId);
|
||||
}
|
||||
|
||||
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
|
||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
||||
}
|
||||
|
||||
this.logger.error(`Error uploading file ${error}`, error?.stack);
|
||||
throw error;
|
||||
return this.handleUploadError(error, auth, file, sidecarFile, dto.stackParentId);
|
||||
}
|
||||
}
|
||||
|
||||
private async linkToStackParent(
|
||||
ownerId: string,
|
||||
newAssetId: string,
|
||||
parentId: string,
|
||||
): Promise<{ stackId: string; created: boolean } | null> {
|
||||
const result = await this.stackRepository.linkAsset(ownerId, newAssetId, parentId);
|
||||
if (!result) {
|
||||
this.logger.warn(`Could not link asset ${newAssetId} to stack parent ${parentId}: parent missing or not owned`);
|
||||
return null;
|
||||
}
|
||||
await this.eventRepository.emit(result.created ? 'StackCreate' : 'StackUpdate', {
|
||||
stackId: result.stackId,
|
||||
userId: ownerId,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise<ImmichFileResponse> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] });
|
||||
|
||||
@@ -347,6 +321,99 @@ export class AssetMediaService extends BaseService {
|
||||
: this.sharedLinkRepository.addAssets(sharedLink.id, [assetId]));
|
||||
}
|
||||
|
||||
private async handleUploadError(
|
||||
error: any,
|
||||
auth: AuthDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
stackParentId?: string,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
// clean up files
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.FileDelete,
|
||||
data: { files: [file.originalPath, sidecarFile?.originalPath] },
|
||||
});
|
||||
|
||||
// handle duplicates with a success response
|
||||
if (isAssetChecksumConstraint(error)) {
|
||||
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
|
||||
if (!duplicateId) {
|
||||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.addToSharedLink(auth.sharedLink, duplicateId);
|
||||
}
|
||||
|
||||
if (stackParentId) {
|
||||
// Adopt the existing duplicate into the stack so a re-uploaded edit still
|
||||
// stacks instead of silently staying separate.
|
||||
await this.linkToStackParent(auth.user.id, duplicateId, stackParentId);
|
||||
}
|
||||
|
||||
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
|
||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
||||
}
|
||||
|
||||
this.logger.error(`Error uploading file ${error}`, error?.stack);
|
||||
throw error;
|
||||
}
|
||||
|
||||
private async create(
|
||||
ownerId: string,
|
||||
dto: AssetMediaCreateDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
options?: { skipEventEmit?: boolean },
|
||||
) {
|
||||
const asset = await this.assetRepository.create({
|
||||
ownerId,
|
||||
libraryId: null,
|
||||
|
||||
checksum: file.checksum,
|
||||
checksumAlgorithm: ChecksumAlgorithm.sha1File,
|
||||
originalPath: file.originalPath,
|
||||
|
||||
fileCreatedAt: dto.fileCreatedAt,
|
||||
fileModifiedAt: dto.fileModifiedAt,
|
||||
localDateTime: dto.fileCreatedAt,
|
||||
|
||||
type: mimeTypes.assetType(file.originalPath),
|
||||
isFavorite: dto.isFavorite,
|
||||
duration: dto.duration || null,
|
||||
visibility: dto.visibility ?? AssetVisibility.Timeline,
|
||||
livePhotoVideoId: dto.livePhotoVideoId,
|
||||
originalFileName: dto.filename || file.originalName,
|
||||
});
|
||||
|
||||
if (dto.metadata?.length) {
|
||||
await this.assetRepository.upsertMetadata(asset.id, dto.metadata);
|
||||
}
|
||||
|
||||
if (sidecarFile) {
|
||||
await this.assetRepository.upsertFile({
|
||||
assetId: asset.id,
|
||||
path: sidecarFile.originalPath,
|
||||
type: AssetFileType.Sidecar,
|
||||
});
|
||||
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
}
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif({
|
||||
exif: { assetId: asset.id, fileSizeInByte: file.size },
|
||||
lockedPropertiesBehavior: 'override',
|
||||
});
|
||||
|
||||
if (!options?.skipEventEmit) {
|
||||
await this.eventRepository.emit('AssetCreate', { asset, file });
|
||||
}
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } });
|
||||
|
||||
return asset;
|
||||
}
|
||||
|
||||
private requireQuota(auth: AuthDto, size: number) {
|
||||
if (auth.user.quotaSizeInBytes !== null && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
|
||||
throw new BadRequestException('Quota has been exceeded!');
|
||||
|
||||
@@ -57,6 +57,7 @@ describe(PersonService.name, () => {
|
||||
],
|
||||
});
|
||||
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
|
||||
minimumFaceCount: 3,
|
||||
withHidden: true,
|
||||
});
|
||||
});
|
||||
@@ -83,6 +84,7 @@ describe(PersonService.name, () => {
|
||||
],
|
||||
});
|
||||
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
|
||||
minimumFaceCount: 3,
|
||||
withHidden: false,
|
||||
});
|
||||
});
|
||||
@@ -452,30 +454,6 @@ describe(PersonService.name, () => {
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queueAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject creating a face on an asset the user does not own', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const asset = AssetFactory.create();
|
||||
const person = PersonFactory.create({ faceAssetId: null });
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
|
||||
await expect(
|
||||
sut.createFace(auth, {
|
||||
assetId: asset.id,
|
||||
personId: person.id,
|
||||
imageHeight: 500,
|
||||
imageWidth: 400,
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 110,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.person.createAssetFace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNewFeaturePhoto', () => {
|
||||
|
||||
@@ -63,7 +63,9 @@ export class PersonService extends BaseService {
|
||||
}
|
||||
closestFaceAssetId = person.faceAssetId;
|
||||
}
|
||||
const { machineLearning } = await this.getConfig({ withCache: false });
|
||||
const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, {
|
||||
minimumFaceCount: machineLearning.facialRecognition.minFaces,
|
||||
withHidden,
|
||||
closestFaceAssetId,
|
||||
});
|
||||
@@ -625,7 +627,7 @@ export class PersonService extends BaseService {
|
||||
// TODO return a asset face response
|
||||
async createFace(auth: AuthDto, dto: AssetFaceCreateDto): Promise<void> {
|
||||
await Promise.all([
|
||||
this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [dto.assetId] }),
|
||||
this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.assetId] }),
|
||||
this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }),
|
||||
]);
|
||||
|
||||
|
||||
@@ -168,7 +168,6 @@ describe(ServerService.name, () => {
|
||||
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
maintenanceMode: false,
|
||||
minFaces: 3,
|
||||
});
|
||||
expect(mocks.systemMetadata.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -128,7 +128,6 @@ export class ServerService extends BaseService {
|
||||
mapDarkStyleUrl: config.map.darkStyle,
|
||||
mapLightStyleUrl: config.map.lightStyle,
|
||||
maintenanceMode: false,
|
||||
minFaces: config.machineLearning.facialRecognition.minFaces,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -539,7 +539,6 @@ export type UserPreferences = {
|
||||
people: {
|
||||
enabled: boolean;
|
||||
sidebarWeb: boolean;
|
||||
minimumFaces: number;
|
||||
};
|
||||
ratings: {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -79,6 +79,12 @@ export const isAssetChecksumConstraint = (error: unknown) =>
|
||||
export const isVideoStreamSessionPkConstraint = (error: unknown) =>
|
||||
(error as PostgresError)?.constraint_name === VIDEO_STREAM_SESSION_PK_CONSTRAINT;
|
||||
|
||||
export const STACK_PRIMARY_CONSTRAINT = 'stack_primaryAssetId_uq';
|
||||
|
||||
export const isStackPrimaryConstraint = (error: unknown) => {
|
||||
return (error as PostgresError)?.constraint_name === STACK_PRIMARY_CONSTRAINT;
|
||||
};
|
||||
|
||||
export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
||||
return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ const getDefaultPreferences = (): UserPreferences => {
|
||||
people: {
|
||||
enabled: true,
|
||||
sidebarWeb: false,
|
||||
minimumFaces: 3,
|
||||
},
|
||||
sharedLinks: {
|
||||
enabled: true,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk';
|
||||
import { Kysely } from 'kysely';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createServer } from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
|
||||
import { AssetVisibility, LogLevel } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
@@ -334,47 +332,4 @@ describe('core plugin', () => {
|
||||
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhook', () => {
|
||||
it('should send the asset information to the configured endpoint', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
|
||||
const received = new Promise<{ headers: NodeJS.Dict<string | string[]>; body: any }>((resolve, reject) => {
|
||||
const server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
server.close();
|
||||
resolve({ headers: req.headers, body: JSON.parse(Buffer.concat(chunks).toString()) });
|
||||
});
|
||||
});
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
createWorkflow({
|
||||
ownerId: user.id,
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
steps: [
|
||||
{
|
||||
method: 'immich-plugin-core#webhook',
|
||||
config: { url: `http://127.0.0.1:${port}/hook`, secret: 'super-secret' },
|
||||
},
|
||||
],
|
||||
})
|
||||
.then((workflow) => ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id }))
|
||||
.catch(reject);
|
||||
});
|
||||
});
|
||||
|
||||
const { headers, body } = await received;
|
||||
expect(headers['x-immich-webhook-secret']).toBe('super-secret');
|
||||
expect(body).toMatchObject({
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
asset: { id: asset.id, ownerId: user.id },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@
|
||||
"happy-dom": "^20.0.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-sort-json": "^4.1.1",
|
||||
"prettier-plugin-svelte": "^4.0.0",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"rollup-plugin-visualizer": "^7.0.0",
|
||||
"svelte": "5.55.8",
|
||||
"svelte-check": "^4.4.6",
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
mdiLink,
|
||||
mdiLockOutline,
|
||||
mdiMagnify,
|
||||
mdiMapMarkerOutline,
|
||||
mdiMapOutline,
|
||||
mdiServer,
|
||||
mdiStateMachine,
|
||||
@@ -94,11 +93,6 @@ export const getPagesProvider = ($t: MessageFormatter) => {
|
||||
onAction: () => goto(Route.people()),
|
||||
$if: () => authManager.authenticated && authManager.preferences.people.enabled,
|
||||
},
|
||||
{
|
||||
title: $t('places'),
|
||||
icon: mdiMapMarkerOutline,
|
||||
onAction: () => goto(Route.places()),
|
||||
},
|
||||
{
|
||||
title: $t('shared_links'),
|
||||
icon: mdiLink,
|
||||
|
||||
@@ -78,7 +78,6 @@
|
||||
let mouseOver = $state(false);
|
||||
let loaded = $state(false);
|
||||
let thumbError = $state(false);
|
||||
let skipFade = $state(false);
|
||||
|
||||
let width = $derived(thumbnailSize || thumbnailWidth || 235);
|
||||
let height = $derived(thumbnailSize || thumbnailHeight || 235);
|
||||
@@ -253,12 +252,7 @@
|
||||
widthStyle="{width}px"
|
||||
heightStyle="{height}px"
|
||||
curve={selected}
|
||||
onComplete={(errored) => {
|
||||
const rect = element?.getBoundingClientRect();
|
||||
skipFade = !rect || rect.bottom < 0 || rect.top > window.innerHeight;
|
||||
loaded = true;
|
||||
thumbError = errored;
|
||||
}}
|
||||
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
|
||||
/>
|
||||
{#if asset.isVideo}
|
||||
<div class="pointer-events-none absolute size-full group-focus-visible:rounded-lg">
|
||||
@@ -303,10 +297,7 @@
|
||||
<Thumbhash
|
||||
base64ThumbHash={asset.thumbhash}
|
||||
data-testid="thumbhash"
|
||||
class={[
|
||||
'absolute top-0 object-cover group-focus-visible:rounded-lg',
|
||||
{ 'rounded-xl': selected, hidden: skipFade },
|
||||
]}
|
||||
class={['absolute top-0 object-cover group-focus-visible:rounded-lg', { 'rounded-xl': selected }]}
|
||||
style="width: {width}px; height: {height}px"
|
||||
draggable="false"
|
||||
fadeOut
|
||||
|
||||
@@ -22,16 +22,11 @@
|
||||
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
const {
|
||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||
} = TUNABLES;
|
||||
|
||||
type Props = {
|
||||
assets: AssetResponseDto[];
|
||||
viewerAssets?: AssetResponseDto[];
|
||||
@@ -39,7 +34,7 @@
|
||||
disableAssetSelect?: boolean;
|
||||
showArchiveIcon?: boolean;
|
||||
viewport: Viewport;
|
||||
onEndReached?: (() => void) | undefined;
|
||||
onIntersected?: (() => void) | undefined;
|
||||
showAssetName?: boolean;
|
||||
onReload?: (() => void) | undefined;
|
||||
pageHeaderOffset?: number;
|
||||
@@ -55,7 +50,7 @@
|
||||
disableAssetSelect = false,
|
||||
showArchiveIcon = false,
|
||||
viewport,
|
||||
onEndReached = undefined,
|
||||
onIntersected = undefined,
|
||||
showAssetName = false,
|
||||
onReload = undefined,
|
||||
slidingWindowOffset = 0,
|
||||
@@ -75,23 +70,24 @@
|
||||
}),
|
||||
);
|
||||
|
||||
const getStyle = (index: number) => {
|
||||
return `top: ${geometry.getTop(index)}px; left: ${geometry.getLeft(index)}px; width: ${geometry.getWidth(index)}px; height: ${geometry.getHeight(index)}px;`;
|
||||
const getStyle = (i: number) => {
|
||||
const geo = geometry;
|
||||
return `top: ${geo.getTop(i)}px; left: ${geo.getLeft(i)}px; width: ${geo.getWidth(i)}px; height: ${geo.getHeight(i)}px;`;
|
||||
};
|
||||
|
||||
const isInOrNearViewport = (index: number) => {
|
||||
const isIntersecting = (i: number) => {
|
||||
const geo = geometry;
|
||||
const window = slidingWindow;
|
||||
const top = geometry.getTop(index);
|
||||
return top + pageHeaderOffset < window.bottom && top + geometry.getHeight(index) > window.top;
|
||||
const top = geo.getTop(i);
|
||||
return top + pageHeaderOffset < window.bottom && top + geo.getHeight(i) > window.top;
|
||||
};
|
||||
|
||||
let shiftKeyIsDown = $state(false);
|
||||
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
|
||||
let scrollTop = $state(0);
|
||||
|
||||
let slidingWindow = $derived.by(() => {
|
||||
const top = (scrollTop || 0) - slidingWindowOffset - INTERSECTION_EXPAND_TOP;
|
||||
const bottom = top + viewport.height + slidingWindowOffset + INTERSECTION_EXPAND_BOTTOM;
|
||||
const top = (scrollTop || 0) - slidingWindowOffset;
|
||||
const bottom = top + viewport.height + slidingWindowOffset;
|
||||
return {
|
||||
top,
|
||||
bottom,
|
||||
@@ -105,15 +101,17 @@
|
||||
|
||||
const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0);
|
||||
|
||||
const debouncedOnEndReached = debounce(() => onEndReached?.(), 750, { maxWait: 100, leading: true });
|
||||
const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
|
||||
|
||||
let lastEndReachedHeight = 0;
|
||||
let lastIntersectedHeight = 0;
|
||||
$effect(() => {
|
||||
// Intersect if there's only one viewport worth of assets left to scroll.
|
||||
if (geometry.containerHeight - slidingWindow.bottom <= viewport.height) {
|
||||
const contentHeight = geometry.containerHeight;
|
||||
if (lastEndReachedHeight !== contentHeight) {
|
||||
debouncedOnEndReached();
|
||||
lastEndReachedHeight = contentHeight;
|
||||
// Notify we got to (near) the end of scroll.
|
||||
const intersectedHeight = geometry.containerHeight;
|
||||
if (lastIntersectedHeight !== intersectedHeight) {
|
||||
debouncedOnIntersected();
|
||||
lastIntersectedHeight = intersectedHeight;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -364,10 +362,10 @@
|
||||
style:height={geometry.containerHeight + 'px'}
|
||||
style:width={geometry.containerWidth + 'px'}
|
||||
>
|
||||
{#each assets as asset, index (asset.id + '-' + index)}
|
||||
{#if isInOrNearViewport(index)}
|
||||
{#each assets as asset, i (asset.id + '-' + i)}
|
||||
{#if isIntersecting(i)}
|
||||
{@const currentAsset = toTimelineAsset(asset)}
|
||||
<div class="absolute" style:overflow="clip" style={getStyle(index)}>
|
||||
<div class="absolute" style:overflow="clip" style={getStyle(i)}>
|
||||
<Thumbnail
|
||||
readonly={disableAssetSelect}
|
||||
onClick={() => {
|
||||
@@ -384,8 +382,8 @@
|
||||
asset={currentAsset}
|
||||
selected={assetInteraction.hasSelectedAsset(currentAsset.id)}
|
||||
selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)}
|
||||
thumbnailWidth={geometry.getWidth(index)}
|
||||
thumbnailHeight={geometry.getHeight(index)}
|
||||
thumbnailWidth={geometry.getWidth(i)}
|
||||
thumbnailHeight={geometry.getHeight(i)}
|
||||
/>
|
||||
{#if showAssetName && !isTimelineAsset(asset)}
|
||||
<div
|
||||
|
||||
@@ -309,7 +309,7 @@
|
||||
<GalleryViewer
|
||||
assets={searchResultAssets}
|
||||
assetInteraction={assetMultiSelectManager}
|
||||
onEndReached={loadNextPage}
|
||||
onIntersected={loadNextPage}
|
||||
showArchiveIcon={true}
|
||||
{viewport}
|
||||
onReload={onSearchQueryUpdate}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
@@ -22,7 +21,6 @@
|
||||
// People
|
||||
let peopleEnabled = $state(authManager.preferences.people?.enabled ?? false);
|
||||
let peopleSidebar = $state(authManager.preferences.people?.sidebarWeb ?? false);
|
||||
let peopleMinFaces = $state(authManager.preferences.people?.minimumFaces ?? serverConfigManager.value.minFaces);
|
||||
|
||||
// Ratings
|
||||
let ratingsEnabled = $state(authManager.preferences.ratings?.enabled ?? false);
|
||||
@@ -45,7 +43,7 @@
|
||||
albums: { defaultAssetOrder },
|
||||
folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar },
|
||||
memories: { enabled: memoriesEnabled, duration: memoriesDuration },
|
||||
people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar, minimumFaces: peopleMinFaces },
|
||||
people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar },
|
||||
ratings: { enabled: ratingsEnabled },
|
||||
sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar },
|
||||
tags: { enabled: tagsEnabled, sidebarWeb: tagsSidebar },
|
||||
@@ -119,9 +117,6 @@
|
||||
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
|
||||
<Switch bind:checked={peopleSidebar} />
|
||||
</Field>
|
||||
<Field label={$t('minFaces')} description={$t('minFaces_description')}>
|
||||
<NumberInput bind:value={peopleMinFaces} />
|
||||
</Field>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
@@ -38,8 +38,6 @@
|
||||
withCoordinates: true,
|
||||
};
|
||||
|
||||
const isOwnAsset = (asset: TimelineAsset) => asset.ownerId === authManager.user.id;
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!point) {
|
||||
return;
|
||||
@@ -56,7 +54,7 @@
|
||||
|
||||
await updateAssets({
|
||||
assetBulkUpdateDto: {
|
||||
ids: assetMultiSelectManager.assets.filter((asset) => isOwnAsset(asset)).map((asset) => asset.id),
|
||||
ids: assetMultiSelectManager.assets.map((asset) => asset.id),
|
||||
latitude: point.lat,
|
||||
longitude: point.lng,
|
||||
},
|
||||
@@ -126,7 +124,7 @@
|
||||
}, 1500);
|
||||
point = { lat: asset.latitude, lng: asset.longitude };
|
||||
void setQueryValue('at', asset.id);
|
||||
} else if (isOwnAsset(asset)) {
|
||||
} else {
|
||||
onClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
|
||||
}
|
||||
};
|
||||
@@ -201,9 +199,6 @@
|
||||
onThumbnailClick={handleThumbnailClick}
|
||||
>
|
||||
{#snippet customThumbnailLayout(asset: TimelineAsset)}
|
||||
{#if !isOwnAsset(asset)}
|
||||
<div class="pointer-events-none absolute inset-0 rounded-sm bg-black/40"></div>
|
||||
{/if}
|
||||
{#if hasGps(asset)}
|
||||
<div class="absolute inset-e-3 bottom-1 rounded-xl bg-success px-4 py-1 text-xs text-black transition-colors">
|
||||
{asset.city || $t('gps')}
|
||||
|
||||
Reference in New Issue
Block a user