mirror of
https://github.com/immich-app/immich.git
synced 2026-06-04 05:45:24 -04:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cd5d9e218 | |||
| 92841f311f | |||
| 9d2e576630 | |||
| 936418a464 | |||
| 84c75d95c7 | |||
| 9287fa08c6 | |||
| 408e1180ca | |||
| 07f19d2caa | |||
| 368cb7a4ad | |||
| 109e0a7ad0 | |||
| 59750dad7d | |||
| 13ecfc8876 | |||
| 65d8b35f8b | |||
| 942d3c648c | |||
| 82db8be5ff | |||
| 03554b24ad | |||
| c5fb67c004 | |||
| 40983b46c8 | |||
| 5dcdbf04ea |
@@ -1 +0,0 @@
|
|||||||
custom: ['https://buy.immich.app', 'https://immich.store']
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
# Contributor Covenant Code of Conduct
|
|
||||||
|
|
||||||
## Our Pledge
|
|
||||||
|
|
||||||
We as members, contributors, and leaders pledge to make participation
|
|
||||||
in our community a harassment-free experience for everyone, regardless
|
|
||||||
of age, body size, visible or invisible disability, ethnicity, sex
|
|
||||||
characteristics, gender identity and expression, level of experience,
|
|
||||||
education, socio-economic status, nationality, personal appearance,
|
|
||||||
race, religion, or sexual identity and orientation.
|
|
||||||
|
|
||||||
We pledge to act and interact in ways that contribute to an open,
|
|
||||||
welcoming, diverse, inclusive, and healthy community.
|
|
||||||
|
|
||||||
## Our Standards
|
|
||||||
|
|
||||||
Examples of behavior that contributes to a positive environment for
|
|
||||||
our community include:
|
|
||||||
|
|
||||||
- Demonstrating empathy and kindness toward other people
|
|
||||||
- Being respectful of differing opinions, viewpoints, and experiences
|
|
||||||
- Giving and gracefully accepting constructive feedback
|
|
||||||
- Accepting responsibility and apologizing to those affected by our
|
|
||||||
mistakes, and learning from the experience
|
|
||||||
- Focusing on what is best not just for us as individuals, but for the
|
|
||||||
overall community
|
|
||||||
|
|
||||||
Examples of unacceptable behavior include:
|
|
||||||
|
|
||||||
- The use of sexualized language or imagery, and sexual attention or
|
|
||||||
advances of any kind
|
|
||||||
- Trolling, insulting or derogatory comments, and personal or
|
|
||||||
political attacks
|
|
||||||
- Public or private harassment
|
|
||||||
- Publishing others' private information, such as a physical or email
|
|
||||||
address, without their explicit permission
|
|
||||||
- Other conduct which could reasonably be considered inappropriate in
|
|
||||||
a professional setting
|
|
||||||
|
|
||||||
## Enforcement Responsibilities
|
|
||||||
|
|
||||||
Community leaders are responsible for clarifying and enforcing our
|
|
||||||
standards of acceptable behavior and will take appropriate and fair
|
|
||||||
corrective action in response to any behavior that they deem
|
|
||||||
inappropriate, threatening, offensive, or harmful.
|
|
||||||
|
|
||||||
Community leaders have the right and responsibility to remove, edit,
|
|
||||||
or reject comments, commits, code, wiki edits, issues, and other
|
|
||||||
contributions that are not aligned to this Code of Conduct, and will
|
|
||||||
communicate reasons for moderation decisions when appropriate.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
This Code of Conduct applies within all community spaces, and also
|
|
||||||
applies when an individual is officially representing the community in
|
|
||||||
public spaces. Examples of representing our community include using an
|
|
||||||
official e-mail address, posting via an official social media account,
|
|
||||||
or acting as an appointed representative at an online or offline
|
|
||||||
event.
|
|
||||||
|
|
||||||
## Enforcement
|
|
||||||
|
|
||||||
Instances of abusive, harassing, or otherwise unacceptable behavior
|
|
||||||
may be reported to the community leaders responsible for enforcement
|
|
||||||
at our Discord channel. All complaints
|
|
||||||
will be reviewed and investigated promptly and fairly.
|
|
||||||
|
|
||||||
All community leaders are obligated to respect the privacy and
|
|
||||||
security of the reporter of any incident.
|
|
||||||
|
|
||||||
## Enforcement Guidelines
|
|
||||||
|
|
||||||
Community leaders will follow these Community Impact Guidelines in
|
|
||||||
determining the consequences for any action they deem in violation of
|
|
||||||
this Code of Conduct:
|
|
||||||
|
|
||||||
### 1. Correction
|
|
||||||
|
|
||||||
**Community Impact**: Use of inappropriate language or other behavior
|
|
||||||
deemed unprofessional or unwelcome in the community.
|
|
||||||
|
|
||||||
**Consequence**: A private, written warning from community leaders,
|
|
||||||
providing clarity around the nature of the violation and an
|
|
||||||
explanation of why the behavior was inappropriate. A public apology
|
|
||||||
may be requested.
|
|
||||||
|
|
||||||
### 2. Warning
|
|
||||||
|
|
||||||
**Community Impact**: A violation through a single incident or series
|
|
||||||
of actions.
|
|
||||||
|
|
||||||
**Consequence**: A warning with consequences for continued
|
|
||||||
behavior. No interaction with the people involved, including
|
|
||||||
unsolicited interaction with those enforcing the Code of Conduct, for
|
|
||||||
a specified period of time. This includes avoiding interactions in
|
|
||||||
community spaces as well as external channels like social
|
|
||||||
media. Violating these terms may lead to a temporary or permanent ban.
|
|
||||||
|
|
||||||
### 3. Temporary Ban
|
|
||||||
|
|
||||||
**Community Impact**: A serious violation of community standards,
|
|
||||||
including sustained inappropriate behavior.
|
|
||||||
|
|
||||||
**Consequence**: A temporary ban from any sort of interaction or
|
|
||||||
public communication with the community for a specified period of
|
|
||||||
time. No public or private interaction with the people involved,
|
|
||||||
including unsolicited interaction with those enforcing the Code of
|
|
||||||
Conduct, is allowed during this period. Violating these terms may lead
|
|
||||||
to a permanent ban.
|
|
||||||
|
|
||||||
### 4. Permanent Ban
|
|
||||||
|
|
||||||
**Community Impact**: Demonstrating a pattern of violation of
|
|
||||||
community standards, including sustained inappropriate behavior,
|
|
||||||
harassment of an individual, or aggression toward or disparagement of
|
|
||||||
classes of individuals.
|
|
||||||
|
|
||||||
**Consequence**: A permanent ban from any sort of public interaction
|
|
||||||
within the community.
|
|
||||||
|
|
||||||
## Attribution
|
|
||||||
|
|
||||||
This Code of Conduct is adapted from the [Contributor
|
|
||||||
Covenant][homepage], version 2.0, available at
|
|
||||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
|
||||||
|
|
||||||
Community Impact Guidelines were inspired by [Mozilla's code of
|
|
||||||
conduct enforcement ladder](https://github.com/mozilla/diversity).
|
|
||||||
|
|
||||||
[homepage]: https://www.contributor-covenant.org
|
|
||||||
|
|
||||||
For answers to common questions about this code of conduct, see the
|
|
||||||
FAQ at https://www.contributor-covenant.org/faq. Translations are
|
|
||||||
available at https://www.contributor-covenant.org/translations.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
|
||||||
|
|
||||||
Please report security issues to `security@immich.app`
|
|
||||||
@@ -141,6 +141,7 @@ describe('/server', () => {
|
|||||||
maintenanceMode: false,
|
maintenanceMode: false,
|
||||||
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||||
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||||
|
minFaces: 3,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -230,6 +230,21 @@ describe('/users', () => {
|
|||||||
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
|
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
|
||||||
expect(after).toMatchObject({ download: { includeEmbeddedVideos: true } });
|
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', () => {
|
describe('GET /users/:id', () => {
|
||||||
|
|||||||
@@ -1592,6 +1592,8 @@
|
|||||||
"merge_people_prompt": "Do you want to merge these people? This action is irreversible.",
|
"merge_people_prompt": "Do you want to merge these people? This action is irreversible.",
|
||||||
"merge_people_successfully": "Merge people successfully",
|
"merge_people_successfully": "Merge people successfully",
|
||||||
"merged_people_count": "Merged {count, plural, one {# person} other {# people}}",
|
"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",
|
"minimize": "Minimize",
|
||||||
"minute": "Minute",
|
"minute": "Minute",
|
||||||
"minutes": "Minutes",
|
"minutes": "Minutes",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
ARG DEVICE=cpu
|
ARG DEVICE=cpu
|
||||||
|
|
||||||
FROM python:3.11-bookworm@sha256:970c99f886b839fc8829289040c1845dadaf2cae46b37acc7710333158ec29b4 AS builder-cpu
|
FROM python:3.11-bookworm@sha256:121d86b6d08752968a7dddbc708849e5f3a839bbff47f32212b46d2a1d842bab AS builder-cpu
|
||||||
|
|
||||||
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS builder-openvino
|
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS builder-openvino
|
||||||
|
|
||||||
FROM builder-cpu AS builder-cuda
|
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 \
|
--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
|
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:9c6f90801e6b68e772b7c0ca74260cbf7af9f320acec894e26fccdaccfbe3b47 AS prod-cpu
|
FROM python:3.11-slim-bookworm@sha256:8dca233de9f3d9bb410665f00a4da6dd06f331083137e0e98ccf227236fcc438 AS prod-cpu
|
||||||
|
|
||||||
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
||||||
MACHINE_LEARNING_MODEL_ARENA=false
|
MACHINE_LEARNING_MODEL_ARENA=false
|
||||||
|
|
||||||
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS prod-openvino
|
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS prod-openvino
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ try:
|
|||||||
str(settings.http_keepalive_timeout_s),
|
str(settings.http_keepalive_timeout_s),
|
||||||
"--graceful-timeout",
|
"--graceful-timeout",
|
||||||
"10",
|
"10",
|
||||||
|
"--no-control-socket",
|
||||||
],
|
],
|
||||||
) as cmd:
|
) as cmd:
|
||||||
cmd.wait()
|
cmd.wait()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from zipfile import BadZipFile
|
|||||||
|
|
||||||
import orjson
|
import orjson
|
||||||
from fastapi import Depends, FastAPI, File, Form, HTTPException
|
from fastapi import Depends, FastAPI, File, Form, HTTPException
|
||||||
from fastapi.responses import ORJSONResponse, PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile
|
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile
|
||||||
from PIL.Image import Image
|
from PIL.Image import Image
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
@@ -32,6 +32,7 @@ from .schemas import (
|
|||||||
ModelIdentity,
|
ModelIdentity,
|
||||||
ModelTask,
|
ModelTask,
|
||||||
ModelType,
|
ModelType,
|
||||||
|
ORJSONResponse,
|
||||||
PipelineRequest,
|
PipelineRequest,
|
||||||
T,
|
T,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -89,7 +89,9 @@ class OpenClipTextualEncoder(BaseCLIPTextualEncoder):
|
|||||||
|
|
||||||
tokenizer: Tokenizer = Tokenizer.from_file(self.tokenizer_file_path.as_posix())
|
tokenizer: Tokenizer = Tokenizer.from_file(self.tokenizer_file_path.as_posix())
|
||||||
|
|
||||||
pad_id: int = tokenizer.token_to_id(pad_token)
|
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")
|
||||||
tokenizer.enable_padding(length=context_length, pad_token=pad_token, pad_id=pad_id)
|
tokenizer.enable_padding(length=context_length, pad_token=pad_token, pad_id=pad_id)
|
||||||
tokenizer.enable_truncation(max_length=context_length)
|
tokenizer.enable_truncation(max_length=context_length)
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ from typing import Any, Literal, Protocol, TypeGuard, TypeVar
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import numpy.typing as npt
|
import numpy.typing as npt
|
||||||
|
import orjson
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from typing_extensions import TypedDict
|
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):
|
class StrEnum(str, Enum):
|
||||||
value: str
|
value: str
|
||||||
|
|
||||||
|
|||||||
Generated
+498
-450
File diff suppressed because it is too large
Load Diff
+9
-129
@@ -207,18 +207,6 @@ 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. */
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
data class PlatformAsset (
|
data class PlatformAsset (
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -484,52 +472,6 @@ data class CloudIdResult (
|
|||||||
return result
|
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() {
|
private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
return when (type) {
|
return when (type) {
|
||||||
@@ -539,40 +481,30 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
130.toByte() -> {
|
130.toByte() -> {
|
||||||
return (readValue(buffer) as Long?)?.let {
|
|
||||||
EditState.ofRaw(it.toInt())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
131.toByte() -> {
|
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
PlatformAsset.fromList(it)
|
PlatformAsset.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
132.toByte() -> {
|
131.toByte() -> {
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
PlatformAlbum.fromList(it)
|
PlatformAlbum.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
133.toByte() -> {
|
132.toByte() -> {
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
SyncDelta.fromList(it)
|
SyncDelta.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
134.toByte() -> {
|
133.toByte() -> {
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
HashResult.fromList(it)
|
HashResult.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
135.toByte() -> {
|
134.toByte() -> {
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
CloudIdResult.fromList(it)
|
CloudIdResult.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
136.toByte() -> {
|
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
|
||||||
BaseResource.fromList(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> super.readValueOfType(type, buffer)
|
else -> super.readValueOfType(type, buffer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -582,32 +514,24 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
|||||||
stream.write(129)
|
stream.write(129)
|
||||||
writeValue(stream, value.raw.toLong())
|
writeValue(stream, value.raw.toLong())
|
||||||
}
|
}
|
||||||
is EditState -> {
|
|
||||||
stream.write(130)
|
|
||||||
writeValue(stream, value.raw.toLong())
|
|
||||||
}
|
|
||||||
is PlatformAsset -> {
|
is PlatformAsset -> {
|
||||||
stream.write(131)
|
stream.write(130)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
is PlatformAlbum -> {
|
is PlatformAlbum -> {
|
||||||
stream.write(132)
|
stream.write(131)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
is SyncDelta -> {
|
is SyncDelta -> {
|
||||||
stream.write(133)
|
stream.write(132)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
is HashResult -> {
|
is HashResult -> {
|
||||||
stream.write(134)
|
stream.write(133)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
is CloudIdResult -> {
|
is CloudIdResult -> {
|
||||||
stream.write(135)
|
stream.write(134)
|
||||||
writeValue(stream, value.toList())
|
|
||||||
}
|
|
||||||
is BaseResource -> {
|
|
||||||
stream.write(136)
|
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
else -> super.writeValue(stream, value)
|
else -> super.writeValue(stream, value)
|
||||||
@@ -631,8 +555,6 @@ interface NativeSyncApi {
|
|||||||
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
|
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
|
||||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
|
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 {
|
companion object {
|
||||||
/** The codec used by NativeSyncApi. */
|
/** The codec used by NativeSyncApi. */
|
||||||
@@ -864,48 +786,6 @@ interface NativeSyncApi {
|
|||||||
channel.setMessageHandler(null)
|
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,14 +476,4 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
|||||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
|
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
|
||||||
return emptyList()
|
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
+9
-117
@@ -183,12 +183,6 @@ enum PlatformAssetPlaybackStyle: Int {
|
|||||||
case videoLooping = 5
|
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.
|
/// Generated class from Pigeon that represents data sent in messages.
|
||||||
struct PlatformAsset: Hashable {
|
struct PlatformAsset: Hashable {
|
||||||
var id: String
|
var id: String
|
||||||
@@ -464,52 +458,6 @@ 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 {
|
private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||||
override func readValue(ofType type: UInt8) -> Any? {
|
override func readValue(ofType type: UInt8) -> Any? {
|
||||||
switch type {
|
switch type {
|
||||||
@@ -520,23 +468,15 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
case 130:
|
case 130:
|
||||||
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
|
|
||||||
if let enumResultAsInt = enumResultAsInt {
|
|
||||||
return EditState(rawValue: enumResultAsInt)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
case 131:
|
|
||||||
return PlatformAsset.fromList(self.readValue() as! [Any?])
|
return PlatformAsset.fromList(self.readValue() as! [Any?])
|
||||||
case 132:
|
case 131:
|
||||||
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
||||||
case 133:
|
case 132:
|
||||||
return SyncDelta.fromList(self.readValue() as! [Any?])
|
return SyncDelta.fromList(self.readValue() as! [Any?])
|
||||||
case 134:
|
case 133:
|
||||||
return HashResult.fromList(self.readValue() as! [Any?])
|
return HashResult.fromList(self.readValue() as! [Any?])
|
||||||
case 135:
|
case 134:
|
||||||
return CloudIdResult.fromList(self.readValue() as! [Any?])
|
return CloudIdResult.fromList(self.readValue() as! [Any?])
|
||||||
case 136:
|
|
||||||
return BaseResource.fromList(self.readValue() as! [Any?])
|
|
||||||
default:
|
default:
|
||||||
return super.readValue(ofType: type)
|
return super.readValue(ofType: type)
|
||||||
}
|
}
|
||||||
@@ -548,26 +488,20 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
|
|||||||
if let value = value as? PlatformAssetPlaybackStyle {
|
if let value = value as? PlatformAssetPlaybackStyle {
|
||||||
super.writeByte(129)
|
super.writeByte(129)
|
||||||
super.writeValue(value.rawValue)
|
super.writeValue(value.rawValue)
|
||||||
} else if let value = value as? EditState {
|
|
||||||
super.writeByte(130)
|
|
||||||
super.writeValue(value.rawValue)
|
|
||||||
} else if let value = value as? PlatformAsset {
|
} else if let value = value as? PlatformAsset {
|
||||||
super.writeByte(131)
|
super.writeByte(130)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
} else if let value = value as? PlatformAlbum {
|
} else if let value = value as? PlatformAlbum {
|
||||||
super.writeByte(132)
|
super.writeByte(131)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
} else if let value = value as? SyncDelta {
|
} else if let value = value as? SyncDelta {
|
||||||
super.writeByte(133)
|
super.writeByte(132)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
} else if let value = value as? HashResult {
|
} else if let value = value as? HashResult {
|
||||||
super.writeByte(134)
|
super.writeByte(133)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
} else if let value = value as? CloudIdResult {
|
} else if let value = value as? CloudIdResult {
|
||||||
super.writeByte(135)
|
super.writeByte(134)
|
||||||
super.writeValue(value.toList())
|
|
||||||
} else if let value = value as? BaseResource {
|
|
||||||
super.writeByte(136)
|
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
} else {
|
} else {
|
||||||
super.writeValue(value)
|
super.writeValue(value)
|
||||||
@@ -605,8 +539,6 @@ protocol NativeSyncApi {
|
|||||||
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||||
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
|
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
|
||||||
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
|
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`.
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
@@ -825,45 +757,5 @@ class NativeSyncApiSetup {
|
|||||||
} else {
|
} else {
|
||||||
getCloudIdForAssetIdsChannel.setMessageHandler(nil)
|
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,6 +1,5 @@
|
|||||||
import Photos
|
import Photos
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import UniformTypeIdentifiers
|
|
||||||
|
|
||||||
struct AssetWrapper: Hashable, Equatable {
|
struct AssetWrapper: Hashable, Equatable {
|
||||||
let asset: PlatformAsset
|
let asset: PlatformAsset
|
||||||
@@ -420,169 +419,4 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
}
|
}
|
||||||
return mappings;
|
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,7 +20,6 @@ const String kSecuredPinCode = "secured_pin_code";
|
|||||||
const String kManualUploadGroup = 'manual_upload_group';
|
const String kManualUploadGroup = 'manual_upload_group';
|
||||||
const String kBackupGroup = 'backup_group';
|
const String kBackupGroup = 'backup_group';
|
||||||
const String kBackupLivePhotoGroup = 'backup_live_photo_group';
|
const String kBackupLivePhotoGroup = 'backup_live_photo_group';
|
||||||
const String kBackupEditPairGroup = 'backup_edit_pair_group';
|
|
||||||
const String kDownloadGroupImage = 'group_image';
|
const String kDownloadGroupImage = 'group_image';
|
||||||
const String kDownloadGroupVideo = 'group_video';
|
const String kDownloadGroupVideo = 'group_video';
|
||||||
const String kDownloadGroupLivePhoto = 'group_livephoto';
|
const String kDownloadGroupLivePhoto = 'group_livephoto';
|
||||||
|
|||||||
@@ -12,13 +12,6 @@ class LocalAsset extends BaseAsset {
|
|||||||
final double? latitude;
|
final double? latitude;
|
||||||
final double? longitude;
|
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({
|
const LocalAsset({
|
||||||
required this.id,
|
required this.id,
|
||||||
String? remoteId,
|
String? remoteId,
|
||||||
@@ -39,8 +32,6 @@ class LocalAsset extends BaseAsset {
|
|||||||
this.latitude,
|
this.latitude,
|
||||||
this.longitude,
|
this.longitude,
|
||||||
required super.isEdited,
|
required super.isEdited,
|
||||||
this.priorRemoteId,
|
|
||||||
this.syncedChecksum,
|
|
||||||
}) : remoteAssetId = remoteId;
|
}) : remoteAssetId = remoteId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -129,8 +120,6 @@ class LocalAsset extends BaseAsset {
|
|||||||
double? latitude,
|
double? latitude,
|
||||||
double? longitude,
|
double? longitude,
|
||||||
bool? isEdited,
|
bool? isEdited,
|
||||||
String? priorRemoteId,
|
|
||||||
String? syncedChecksum,
|
|
||||||
}) {
|
}) {
|
||||||
return LocalAsset(
|
return LocalAsset(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -151,8 +140,6 @@ class LocalAsset extends BaseAsset {
|
|||||||
latitude: latitude ?? this.latitude,
|
latitude: latitude ?? this.latitude,
|
||||||
longitude: longitude ?? this.longitude,
|
longitude: longitude ?? this.longitude,
|
||||||
isEdited: isEdited ?? this.isEdited,
|
isEdited: isEdited ?? this.isEdited,
|
||||||
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
|
|
||||||
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
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,10 +5,8 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
import 'package:immich_mobile/extensions/platform_extensions.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_album.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.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/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.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';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
const String _kHashCancelledCode = "HASH_CANCELLED";
|
const String _kHashCancelledCode = "HASH_CANCELLED";
|
||||||
@@ -19,8 +17,6 @@ class HashService {
|
|||||||
final DriftLocalAssetRepository _localAssetRepository;
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||||
final NativeSyncApi _nativeSyncApi;
|
final NativeSyncApi _nativeSyncApi;
|
||||||
final DriftStackRepository _stackRepository;
|
|
||||||
final AssetApiRepository _assetApiRepository;
|
|
||||||
final bool Function()? _cancelChecker;
|
final bool Function()? _cancelChecker;
|
||||||
final _log = Logger('HashService');
|
final _log = Logger('HashService');
|
||||||
|
|
||||||
@@ -29,8 +25,6 @@ class HashService {
|
|||||||
required this._localAssetRepository,
|
required this._localAssetRepository,
|
||||||
required this._trashedLocalAssetRepository,
|
required this._trashedLocalAssetRepository,
|
||||||
required this._nativeSyncApi,
|
required this._nativeSyncApi,
|
||||||
required this._stackRepository,
|
|
||||||
required this._assetApiRepository,
|
|
||||||
this._cancelChecker,
|
this._cancelChecker,
|
||||||
int? batchSize,
|
int? batchSize,
|
||||||
}) : _batchSize = batchSize ?? kBatchHashFileLimit;
|
}) : _batchSize = batchSize ?? kBatchHashFileLimit;
|
||||||
@@ -46,7 +40,6 @@ class HashService {
|
|||||||
|
|
||||||
// Sorted by backupSelection followed by isCloud
|
// Sorted by backupSelection followed by isCloud
|
||||||
final localAlbums = await _localAlbumRepository.getBackupAlbums();
|
final localAlbums = await _localAlbumRepository.getBackupAlbums();
|
||||||
final hashedIds = <String>{};
|
|
||||||
|
|
||||||
for (final album in localAlbums) {
|
for (final album in localAlbums) {
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
@@ -56,7 +49,7 @@ class HashService {
|
|||||||
|
|
||||||
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
|
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
|
||||||
if (assetsToHash.isNotEmpty) {
|
if (assetsToHash.isNotEmpty) {
|
||||||
await _hashAssets(album, assetsToHash, hashedIds: hashedIds);
|
await _hashAssets(album, assetsToHash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) {
|
if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) {
|
||||||
@@ -64,18 +57,9 @@ class HashService {
|
|||||||
final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds);
|
final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds);
|
||||||
if (trashedToHash.isNotEmpty) {
|
if (trashedToHash.isNotEmpty) {
|
||||||
final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now());
|
final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now());
|
||||||
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true, hashedIds: hashedIds);
|
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
} on PlatformException catch (e) {
|
||||||
if (e.code == _kHashCancelledCode) {
|
if (e.code == _kHashCancelledCode) {
|
||||||
_log.warning("Hashing cancelled by platform");
|
_log.warning("Hashing cancelled by platform");
|
||||||
@@ -92,12 +76,7 @@ class HashService {
|
|||||||
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
|
/// 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
|
/// 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.
|
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
||||||
Future<void> _hashAssets(
|
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash, {bool isTrashed = false}) async {
|
||||||
LocalAlbum album,
|
|
||||||
List<LocalAsset> assetsToHash, {
|
|
||||||
bool isTrashed = false,
|
|
||||||
required Set<String> hashedIds,
|
|
||||||
}) async {
|
|
||||||
final toHash = <String, LocalAsset>{};
|
final toHash = <String, LocalAsset>{};
|
||||||
|
|
||||||
for (final asset in assetsToHash) {
|
for (final asset in assetsToHash) {
|
||||||
@@ -108,21 +87,16 @@ class HashService {
|
|||||||
|
|
||||||
toHash[asset.id] = asset;
|
toHash[asset.id] = asset;
|
||||||
if (toHash.length == _batchSize) {
|
if (toHash.length == _batchSize) {
|
||||||
await _processBatch(album, toHash, isTrashed, hashedIds);
|
await _processBatch(album, toHash, isTrashed);
|
||||||
toHash.clear();
|
toHash.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _processBatch(album, toHash, isTrashed, hashedIds);
|
await _processBatch(album, toHash, isTrashed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Processes a batch of assets.
|
/// Processes a batch of assets.
|
||||||
Future<void> _processBatch(
|
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash, bool isTrashed) async {
|
||||||
LocalAlbum album,
|
|
||||||
Map<String, LocalAsset> toHash,
|
|
||||||
bool isTrashed,
|
|
||||||
Set<String> hashedIds,
|
|
||||||
) async {
|
|
||||||
if (toHash.isEmpty) {
|
if (toHash.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -162,33 +136,5 @@ class HashService {
|
|||||||
} else {
|
} else {
|
||||||
await _localAssetRepository.updateHashes(hashed);
|
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,10 +81,6 @@ class BackgroundSyncManager {
|
|||||||
} on CanceledError {
|
} on CanceledError {
|
||||||
// Ignore cancellation errors
|
// 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 {
|
Future<void> cancelLocal() async {
|
||||||
@@ -190,22 +186,6 @@ 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) {
|
Future<void> syncWebsocketBatchV1(List<dynamic> batchData) {
|
||||||
if (_syncWebsocketTask != null) {
|
if (_syncWebsocketTask != null) {
|
||||||
return _syncWebsocketTask!.future;
|
return _syncWebsocketTask!.future;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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_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_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 {
|
class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||||
const LocalAssetEntity();
|
const LocalAssetEntity();
|
||||||
|
|
||||||
@@ -28,14 +27,6 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
|||||||
|
|
||||||
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
|
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
|
@override
|
||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
@@ -60,7 +51,5 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
|||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
cloudId: iCloudId,
|
cloudId: iCloudId,
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
priorRemoteId: priorRemoteId,
|
|
||||||
syncedChecksum: syncedChecksum,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-155
@@ -26,8 +26,6 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
|
|||||||
i0.Value<double?> latitude,
|
i0.Value<double?> latitude,
|
||||||
i0.Value<double?> longitude,
|
i0.Value<double?> longitude,
|
||||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||||
i0.Value<String?> priorRemoteId,
|
|
||||||
i0.Value<String?> syncedChecksum,
|
|
||||||
});
|
});
|
||||||
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||||
i1.LocalAssetEntityCompanion Function({
|
i1.LocalAssetEntityCompanion Function({
|
||||||
@@ -47,8 +45,6 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
|||||||
i0.Value<double?> latitude,
|
i0.Value<double?> latitude,
|
||||||
i0.Value<double?> longitude,
|
i0.Value<double?> longitude,
|
||||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||||
i0.Value<String?> priorRemoteId,
|
|
||||||
i0.Value<String?> syncedChecksum,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
class $$LocalAssetEntityTableFilterComposer
|
class $$LocalAssetEntityTableFilterComposer
|
||||||
@@ -145,16 +141,6 @@ class $$LocalAssetEntityTableFilterComposer
|
|||||||
column: $table.playbackStyle,
|
column: $table.playbackStyle,
|
||||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
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
|
class $$LocalAssetEntityTableOrderingComposer
|
||||||
@@ -245,16 +231,6 @@ class $$LocalAssetEntityTableOrderingComposer
|
|||||||
column: $table.playbackStyle,
|
column: $table.playbackStyle,
|
||||||
builder: (column) => i0.ColumnOrderings(column),
|
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
|
class $$LocalAssetEntityTableAnnotationComposer
|
||||||
@@ -324,16 +300,6 @@ class $$LocalAssetEntityTableAnnotationComposer
|
|||||||
column: $table.playbackStyle,
|
column: $table.playbackStyle,
|
||||||
builder: (column) => column,
|
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
|
class $$LocalAssetEntityTableTableManager
|
||||||
@@ -393,8 +359,6 @@ class $$LocalAssetEntityTableTableManager
|
|||||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||||
const i0.Value.absent(),
|
const i0.Value.absent(),
|
||||||
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
|
||||||
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
|
||||||
}) => i1.LocalAssetEntityCompanion(
|
}) => i1.LocalAssetEntityCompanion(
|
||||||
name: name,
|
name: name,
|
||||||
type: type,
|
type: type,
|
||||||
@@ -412,8 +376,6 @@ class $$LocalAssetEntityTableTableManager
|
|||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
playbackStyle: playbackStyle,
|
playbackStyle: playbackStyle,
|
||||||
priorRemoteId: priorRemoteId,
|
|
||||||
syncedChecksum: syncedChecksum,
|
|
||||||
),
|
),
|
||||||
createCompanionCallback:
|
createCompanionCallback:
|
||||||
({
|
({
|
||||||
@@ -434,8 +396,6 @@ class $$LocalAssetEntityTableTableManager
|
|||||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||||
const i0.Value.absent(),
|
const i0.Value.absent(),
|
||||||
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
|
||||||
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
|
||||||
}) => i1.LocalAssetEntityCompanion.insert(
|
}) => i1.LocalAssetEntityCompanion.insert(
|
||||||
name: name,
|
name: name,
|
||||||
type: type,
|
type: type,
|
||||||
@@ -453,8 +413,6 @@ class $$LocalAssetEntityTableTableManager
|
|||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
playbackStyle: playbackStyle,
|
playbackStyle: playbackStyle,
|
||||||
priorRemoteId: priorRemoteId,
|
|
||||||
syncedChecksum: syncedChecksum,
|
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||||
@@ -679,28 +637,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
|||||||
).withConverter<i2.AssetPlaybackStyle>(
|
).withConverter<i2.AssetPlaybackStyle>(
|
||||||
i1.$LocalAssetEntityTable.$converterplaybackStyle,
|
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
|
@override
|
||||||
List<i0.GeneratedColumn> get $columns => [
|
List<i0.GeneratedColumn> get $columns => [
|
||||||
name,
|
name,
|
||||||
@@ -719,8 +655,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
|||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
playbackStyle,
|
playbackStyle,
|
||||||
priorRemoteId,
|
|
||||||
syncedChecksum,
|
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
String get aliasedName => _alias ?? actualTableName;
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
@@ -825,24 +759,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
|||||||
longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta),
|
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;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -923,14 +839,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
|||||||
data['${effectivePrefix}playback_style'],
|
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'],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -969,8 +877,6 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
final double? latitude;
|
final double? latitude;
|
||||||
final double? longitude;
|
final double? longitude;
|
||||||
final i2.AssetPlaybackStyle playbackStyle;
|
final i2.AssetPlaybackStyle playbackStyle;
|
||||||
final String? priorRemoteId;
|
|
||||||
final String? syncedChecksum;
|
|
||||||
const LocalAssetEntityData({
|
const LocalAssetEntityData({
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.type,
|
required this.type,
|
||||||
@@ -988,8 +894,6 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
this.latitude,
|
this.latitude,
|
||||||
this.longitude,
|
this.longitude,
|
||||||
required this.playbackStyle,
|
required this.playbackStyle,
|
||||||
this.priorRemoteId,
|
|
||||||
this.syncedChecksum,
|
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||||
@@ -1034,12 +938,6 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(playbackStyle),
|
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;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1069,8 +967,6 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson(
|
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson(
|
||||||
serializer.fromJson<int>(json['playbackStyle']),
|
serializer.fromJson<int>(json['playbackStyle']),
|
||||||
),
|
),
|
||||||
priorRemoteId: serializer.fromJson<String?>(json['priorRemoteId']),
|
|
||||||
syncedChecksum: serializer.fromJson<String?>(json['syncedChecksum']),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@override
|
@override
|
||||||
@@ -1097,8 +993,6 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
'playbackStyle': serializer.toJson<int>(
|
'playbackStyle': serializer.toJson<int>(
|
||||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle),
|
i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle),
|
||||||
),
|
),
|
||||||
'priorRemoteId': serializer.toJson<String?>(priorRemoteId),
|
|
||||||
'syncedChecksum': serializer.toJson<String?>(syncedChecksum),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1119,8 +1013,6 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
i0.Value<double?> latitude = const i0.Value.absent(),
|
i0.Value<double?> latitude = const i0.Value.absent(),
|
||||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||||
i2.AssetPlaybackStyle? playbackStyle,
|
i2.AssetPlaybackStyle? playbackStyle,
|
||||||
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
|
||||||
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
|
||||||
}) => i1.LocalAssetEntityData(
|
}) => i1.LocalAssetEntityData(
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
@@ -1140,12 +1032,6 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
latitude: latitude.present ? latitude.value : this.latitude,
|
latitude: latitude.present ? latitude.value : this.latitude,
|
||||||
longitude: longitude.present ? longitude.value : this.longitude,
|
longitude: longitude.present ? longitude.value : this.longitude,
|
||||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||||
priorRemoteId: priorRemoteId.present
|
|
||||||
? priorRemoteId.value
|
|
||||||
: this.priorRemoteId,
|
|
||||||
syncedChecksum: syncedChecksum.present
|
|
||||||
? syncedChecksum.value
|
|
||||||
: this.syncedChecksum,
|
|
||||||
);
|
);
|
||||||
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
|
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
|
||||||
return LocalAssetEntityData(
|
return LocalAssetEntityData(
|
||||||
@@ -1175,12 +1061,6 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
playbackStyle: data.playbackStyle.present
|
playbackStyle: data.playbackStyle.present
|
||||||
? data.playbackStyle.value
|
? data.playbackStyle.value
|
||||||
: this.playbackStyle,
|
: this.playbackStyle,
|
||||||
priorRemoteId: data.priorRemoteId.present
|
|
||||||
? data.priorRemoteId.value
|
|
||||||
: this.priorRemoteId,
|
|
||||||
syncedChecksum: data.syncedChecksum.present
|
|
||||||
? data.syncedChecksum.value
|
|
||||||
: this.syncedChecksum,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1202,9 +1082,7 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
..write('adjustmentTime: $adjustmentTime, ')
|
..write('adjustmentTime: $adjustmentTime, ')
|
||||||
..write('latitude: $latitude, ')
|
..write('latitude: $latitude, ')
|
||||||
..write('longitude: $longitude, ')
|
..write('longitude: $longitude, ')
|
||||||
..write('playbackStyle: $playbackStyle, ')
|
..write('playbackStyle: $playbackStyle')
|
||||||
..write('priorRemoteId: $priorRemoteId, ')
|
|
||||||
..write('syncedChecksum: $syncedChecksum')
|
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
@@ -1227,8 +1105,6 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
playbackStyle,
|
playbackStyle,
|
||||||
priorRemoteId,
|
|
||||||
syncedChecksum,
|
|
||||||
);
|
);
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
@@ -1249,9 +1125,7 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
other.adjustmentTime == this.adjustmentTime &&
|
other.adjustmentTime == this.adjustmentTime &&
|
||||||
other.latitude == this.latitude &&
|
other.latitude == this.latitude &&
|
||||||
other.longitude == this.longitude &&
|
other.longitude == this.longitude &&
|
||||||
other.playbackStyle == this.playbackStyle &&
|
other.playbackStyle == this.playbackStyle);
|
||||||
other.priorRemoteId == this.priorRemoteId &&
|
|
||||||
other.syncedChecksum == this.syncedChecksum);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalAssetEntityCompanion
|
class LocalAssetEntityCompanion
|
||||||
@@ -1272,8 +1146,6 @@ class LocalAssetEntityCompanion
|
|||||||
final i0.Value<double?> latitude;
|
final i0.Value<double?> latitude;
|
||||||
final i0.Value<double?> longitude;
|
final i0.Value<double?> longitude;
|
||||||
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
|
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
|
||||||
final i0.Value<String?> priorRemoteId;
|
|
||||||
final i0.Value<String?> syncedChecksum;
|
|
||||||
const LocalAssetEntityCompanion({
|
const LocalAssetEntityCompanion({
|
||||||
this.name = const i0.Value.absent(),
|
this.name = const i0.Value.absent(),
|
||||||
this.type = const i0.Value.absent(),
|
this.type = const i0.Value.absent(),
|
||||||
@@ -1291,8 +1163,6 @@ class LocalAssetEntityCompanion
|
|||||||
this.latitude = const i0.Value.absent(),
|
this.latitude = const i0.Value.absent(),
|
||||||
this.longitude = const i0.Value.absent(),
|
this.longitude = const i0.Value.absent(),
|
||||||
this.playbackStyle = const i0.Value.absent(),
|
this.playbackStyle = const i0.Value.absent(),
|
||||||
this.priorRemoteId = const i0.Value.absent(),
|
|
||||||
this.syncedChecksum = const i0.Value.absent(),
|
|
||||||
});
|
});
|
||||||
LocalAssetEntityCompanion.insert({
|
LocalAssetEntityCompanion.insert({
|
||||||
required String name,
|
required String name,
|
||||||
@@ -1311,8 +1181,6 @@ class LocalAssetEntityCompanion
|
|||||||
this.latitude = const i0.Value.absent(),
|
this.latitude = const i0.Value.absent(),
|
||||||
this.longitude = const i0.Value.absent(),
|
this.longitude = const i0.Value.absent(),
|
||||||
this.playbackStyle = 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),
|
}) : name = i0.Value(name),
|
||||||
type = i0.Value(type),
|
type = i0.Value(type),
|
||||||
id = i0.Value(id);
|
id = i0.Value(id);
|
||||||
@@ -1333,8 +1201,6 @@ class LocalAssetEntityCompanion
|
|||||||
i0.Expression<double>? latitude,
|
i0.Expression<double>? latitude,
|
||||||
i0.Expression<double>? longitude,
|
i0.Expression<double>? longitude,
|
||||||
i0.Expression<int>? playbackStyle,
|
i0.Expression<int>? playbackStyle,
|
||||||
i0.Expression<String>? priorRemoteId,
|
|
||||||
i0.Expression<String>? syncedChecksum,
|
|
||||||
}) {
|
}) {
|
||||||
return i0.RawValuesInsertable({
|
return i0.RawValuesInsertable({
|
||||||
if (name != null) 'name': name,
|
if (name != null) 'name': name,
|
||||||
@@ -1353,8 +1219,6 @@ class LocalAssetEntityCompanion
|
|||||||
if (latitude != null) 'latitude': latitude,
|
if (latitude != null) 'latitude': latitude,
|
||||||
if (longitude != null) 'longitude': longitude,
|
if (longitude != null) 'longitude': longitude,
|
||||||
if (playbackStyle != null) 'playback_style': playbackStyle,
|
if (playbackStyle != null) 'playback_style': playbackStyle,
|
||||||
if (priorRemoteId != null) 'prior_remote_id': priorRemoteId,
|
|
||||||
if (syncedChecksum != null) 'synced_checksum': syncedChecksum,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1375,8 +1239,6 @@ class LocalAssetEntityCompanion
|
|||||||
i0.Value<double?>? latitude,
|
i0.Value<double?>? latitude,
|
||||||
i0.Value<double?>? longitude,
|
i0.Value<double?>? longitude,
|
||||||
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
|
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
|
||||||
i0.Value<String?>? priorRemoteId,
|
|
||||||
i0.Value<String?>? syncedChecksum,
|
|
||||||
}) {
|
}) {
|
||||||
return i1.LocalAssetEntityCompanion(
|
return i1.LocalAssetEntityCompanion(
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
@@ -1395,8 +1257,6 @@ class LocalAssetEntityCompanion
|
|||||||
latitude: latitude ?? this.latitude,
|
latitude: latitude ?? this.latitude,
|
||||||
longitude: longitude ?? this.longitude,
|
longitude: longitude ?? this.longitude,
|
||||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||||
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
|
|
||||||
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1457,12 +1317,6 @@ 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;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1484,9 +1338,7 @@ class LocalAssetEntityCompanion
|
|||||||
..write('adjustmentTime: $adjustmentTime, ')
|
..write('adjustmentTime: $adjustmentTime, ')
|
||||||
..write('latitude: $latitude, ')
|
..write('latitude: $latitude, ')
|
||||||
..write('longitude: $longitude, ')
|
..write('longitude: $longitude, ')
|
||||||
..write('playbackStyle: $playbackStyle, ')
|
..write('playbackStyle: $playbackStyle')
|
||||||
..write('priorRemoteId: $priorRemoteId, ')
|
|
||||||
..write('syncedChecksum: $syncedChecksum')
|
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
@@ -1496,7 +1348,3 @@ i0.Index get idxLocalAssetCloudId => i0.Index(
|
|||||||
'idx_local_asset_cloud_id',
|
'idx_local_asset_cloud_id',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_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,13 +7,7 @@ import 'local_album_asset.entity.dart';
|
|||||||
mergedAsset:
|
mergedAsset:
|
||||||
SELECT
|
SELECT
|
||||||
rae.id as remote_id,
|
rae.id as remote_id,
|
||||||
-- local_id links a remote to its on-device copy, normally by checksum. A reverted iOS
|
(SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1) as local_id,
|
||||||
-- 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.name,
|
||||||
rae."type",
|
rae."type",
|
||||||
rae.created_at as created_at,
|
rae.created_at as created_at,
|
||||||
@@ -89,13 +83,6 @@ AND NOT EXISTS (
|
|||||||
INNER JOIN local_album_entity la on laa.album_id = la.id
|
INNER JOIN local_album_entity la on laa.album_id = la.id
|
||||||
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
|
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
|
ORDER BY created_at DESC
|
||||||
LIMIT $limit;
|
LIMIT $limit;
|
||||||
|
|
||||||
@@ -149,10 +136,6 @@ FROM
|
|||||||
INNER JOIN local_album_entity la on laa.album_id = la.id
|
INNER JOIN local_album_entity la on laa.album_id = la.id
|
||||||
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
|
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
|
GROUP BY bucket_date
|
||||||
ORDER BY bucket_date DESC;
|
ORDER BY bucket_date DESC;
|
||||||
|
|||||||
+2
-2
@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
|||||||
);
|
);
|
||||||
$arrayStartIndex += generatedlimit.amountOfVariables;
|
$arrayStartIndex += generatedlimit.amountOfVariables;
|
||||||
return customSelect(
|
return customSelect(
|
||||||
'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}',
|
'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}',
|
||||||
variables: [
|
variables: [
|
||||||
for (var $ in userIds) i0.Variable<String>($),
|
for (var $ in userIds) i0.Variable<String>($),
|
||||||
...generatedlimit.introducedVariables,
|
...generatedlimit.introducedVariables,
|
||||||
@@ -81,7 +81,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
|||||||
final expandeduserIds = $expandVar($arrayStartIndex, userIds.length);
|
final expandeduserIds = $expandVar($arrayStartIndex, userIds.length);
|
||||||
$arrayStartIndex += userIds.length;
|
$arrayStartIndex += userIds.length;
|
||||||
return customSelect(
|
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) 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',
|
'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',
|
||||||
variables: [
|
variables: [
|
||||||
i0.Variable<int>(groupBy),
|
i0.Variable<int>(groupBy),
|
||||||
for (var $ in userIds) i0.Variable<String>($),
|
for (var $ in userIds) i0.Variable<String>($),
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
|||||||
INNER JOIN main.local_album_entity la on laa.album_id = la.id
|
INNER JOIN main.local_album_entity la on laa.album_id = la.id
|
||||||
WHERE laa.asset_id = lae.id
|
WHERE laa.asset_id = lae.id
|
||||||
AND la.backup_selection = ?3
|
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
|
final row = await _db
|
||||||
@@ -105,10 +104,6 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
|||||||
_db.remoteAssetEntity.checksum.equalsExp(lae.checksum) & _db.remoteAssetEntity.ownerId.equals(userId),
|
_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()),
|
lae.id.isNotInQuery(_getExcludedSubquery()),
|
||||||
)
|
)
|
||||||
..orderBy([(localAsset) => OrderingTerm.desc(localAsset.createdAt)]);
|
..orderBy([(localAsset) => OrderingTerm.desc(localAsset.createdAt)]);
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ class Drift extends $Drift {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 28;
|
int get schemaVersion => 27;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -279,11 +279,6 @@ class Drift extends $Drift {
|
|||||||
from26To27: (m, v27) async {
|
from26To27: (m, v27) async {
|
||||||
await customStatement('ALTER TABLE metadata RENAME TO settings');
|
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,7 +112,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
|||||||
i7.idxLocalAlbumAssetAlbumAsset,
|
i7.idxLocalAlbumAssetAlbumAsset,
|
||||||
i4.idxLocalAssetChecksum,
|
i4.idxLocalAssetChecksum,
|
||||||
i4.idxLocalAssetCloudId,
|
i4.idxLocalAssetCloudId,
|
||||||
i4.idxLocalAssetPriorRemoteId,
|
|
||||||
i3.idxStackPrimaryAssetId,
|
i3.idxStackPrimaryAssetId,
|
||||||
i2.uQRemoteAssetsOwnerChecksum,
|
i2.uQRemoteAssetsOwnerChecksum,
|
||||||
i2.uQRemoteAssetsOwnerLibraryChecksum,
|
i2.uQRemoteAssetsOwnerLibraryChecksum,
|
||||||
|
|||||||
@@ -14083,612 +14083,6 @@ 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({
|
i0.MigrationStepWithVersion migrationSteps({
|
||||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||||
@@ -14716,7 +14110,6 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||||||
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
|
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, Schema26 schema) from25To26,
|
||||||
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
|
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
|
||||||
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
|
|
||||||
}) {
|
}) {
|
||||||
return (currentVersion, database) async {
|
return (currentVersion, database) async {
|
||||||
switch (currentVersion) {
|
switch (currentVersion) {
|
||||||
@@ -14850,11 +14243,6 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||||||
final migrator = i1.Migrator(database, schema);
|
final migrator = i1.Migrator(database, schema);
|
||||||
await from26To27(migrator, schema);
|
await from26To27(migrator, schema);
|
||||||
return 27;
|
return 27;
|
||||||
case 27:
|
|
||||||
final schema = Schema28(database: database);
|
|
||||||
final migrator = i1.Migrator(database, schema);
|
|
||||||
await from27To28(migrator, schema);
|
|
||||||
return 28;
|
|
||||||
default:
|
default:
|
||||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||||
}
|
}
|
||||||
@@ -14888,7 +14276,6 @@ i1.OnUpgrade stepByStep({
|
|||||||
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
|
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, Schema26 schema) from25To26,
|
||||||
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
|
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
|
||||||
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
|
|
||||||
}) => i0.VersionedSchema.stepByStepHelper(
|
}) => i0.VersionedSchema.stepByStepHelper(
|
||||||
step: migrationSteps(
|
step: migrationSteps(
|
||||||
from1To2: from1To2,
|
from1To2: from1To2,
|
||||||
@@ -14917,6 +14304,5 @@ i1.OnUpgrade stepByStep({
|
|||||||
from24To25: from24To25,
|
from24To25: from24To25,
|
||||||
from25To26: from25To26,
|
from25To26: from25To26,
|
||||||
from26To27: from26To27,
|
from26To27: from26To27,
|
||||||
from27To28: from27To28,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -64,12 +64,6 @@ 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) {
|
Future<void> delete(List<String> ids) {
|
||||||
if (ids.isEmpty) {
|
if (ids.isEmpty) {
|
||||||
return Future.value();
|
return Future.value();
|
||||||
|
|||||||
@@ -1,24 +1,8 @@
|
|||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:drift/drift.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/domain/models/stack.model.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.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 {
|
class DriftStackRepository extends DriftDatabaseRepository {
|
||||||
final Drift _db;
|
final Drift _db;
|
||||||
const DriftStackRepository(this._db) : super(_db);
|
const DriftStackRepository(this._db) : super(_db);
|
||||||
@@ -30,111 +14,6 @@ class DriftStackRepository extends DriftDatabaseRepository {
|
|||||||
return stack.toDto();
|
return stack.toDto();
|
||||||
}).get();
|
}).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 {
|
extension on StackEntityData {
|
||||||
|
|||||||
+9
-109
@@ -88,8 +88,6 @@ int _deepHash(Object? value) {
|
|||||||
|
|
||||||
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||||
|
|
||||||
enum EditState { notEdited, edited, unknown }
|
|
||||||
|
|
||||||
class PlatformAsset {
|
class PlatformAsset {
|
||||||
PlatformAsset({
|
PlatformAsset({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -397,55 +395,6 @@ class CloudIdResult {
|
|||||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
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 {
|
class _PigeonCodec extends StandardMessageCodec {
|
||||||
const _PigeonCodec();
|
const _PigeonCodec();
|
||||||
@override
|
@override
|
||||||
@@ -456,26 +405,20 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
} else if (value is PlatformAssetPlaybackStyle) {
|
} else if (value is PlatformAssetPlaybackStyle) {
|
||||||
buffer.putUint8(129);
|
buffer.putUint8(129);
|
||||||
writeValue(buffer, value.index);
|
writeValue(buffer, value.index);
|
||||||
} else if (value is EditState) {
|
|
||||||
buffer.putUint8(130);
|
|
||||||
writeValue(buffer, value.index);
|
|
||||||
} else if (value is PlatformAsset) {
|
} else if (value is PlatformAsset) {
|
||||||
buffer.putUint8(131);
|
buffer.putUint8(130);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is PlatformAlbum) {
|
} else if (value is PlatformAlbum) {
|
||||||
buffer.putUint8(132);
|
buffer.putUint8(131);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is SyncDelta) {
|
} else if (value is SyncDelta) {
|
||||||
buffer.putUint8(133);
|
buffer.putUint8(132);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is HashResult) {
|
} else if (value is HashResult) {
|
||||||
buffer.putUint8(134);
|
buffer.putUint8(133);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is CloudIdResult) {
|
} else if (value is CloudIdResult) {
|
||||||
buffer.putUint8(135);
|
buffer.putUint8(134);
|
||||||
writeValue(buffer, value.encode());
|
|
||||||
} else if (value is BaseResource) {
|
|
||||||
buffer.putUint8(136);
|
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else {
|
} else {
|
||||||
super.writeValue(buffer, value);
|
super.writeValue(buffer, value);
|
||||||
@@ -489,20 +432,15 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
final value = readValue(buffer) as int?;
|
final value = readValue(buffer) as int?;
|
||||||
return value == null ? null : PlatformAssetPlaybackStyle.values[value];
|
return value == null ? null : PlatformAssetPlaybackStyle.values[value];
|
||||||
case 130:
|
case 130:
|
||||||
final value = readValue(buffer) as int?;
|
|
||||||
return value == null ? null : EditState.values[value];
|
|
||||||
case 131:
|
|
||||||
return PlatformAsset.decode(readValue(buffer)!);
|
return PlatformAsset.decode(readValue(buffer)!);
|
||||||
case 132:
|
case 131:
|
||||||
return PlatformAlbum.decode(readValue(buffer)!);
|
return PlatformAlbum.decode(readValue(buffer)!);
|
||||||
case 133:
|
case 132:
|
||||||
return SyncDelta.decode(readValue(buffer)!);
|
return SyncDelta.decode(readValue(buffer)!);
|
||||||
case 134:
|
case 133:
|
||||||
return HashResult.decode(readValue(buffer)!);
|
return HashResult.decode(readValue(buffer)!);
|
||||||
case 135:
|
case 134:
|
||||||
return CloudIdResult.decode(readValue(buffer)!);
|
return CloudIdResult.decode(readValue(buffer)!);
|
||||||
case 136:
|
|
||||||
return BaseResource.decode(readValue(buffer)!);
|
|
||||||
default:
|
default:
|
||||||
return super.readValueOfType(type, buffer);
|
return super.readValueOfType(type, buffer);
|
||||||
}
|
}
|
||||||
@@ -753,42 +691,4 @@ class NativeSyncApi {
|
|||||||
);
|
);
|
||||||
return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>();
|
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,16 +63,19 @@ class SheetTile extends ConsumerWidget {
|
|||||||
subtitleWidget = null;
|
subtitleWidget = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListTile(
|
return Material(
|
||||||
dense: true,
|
type: MaterialType.transparency,
|
||||||
visualDensity: VisualDensity.compact,
|
child: ListTile(
|
||||||
title: GestureDetector(onLongPress: () => copyTitle(context, ref), child: titleWidget),
|
dense: true,
|
||||||
titleAlignment: ListTileTitleAlignment.center,
|
visualDensity: VisualDensity.compact,
|
||||||
leading: leading,
|
title: GestureDetector(onLongPress: () => copyTitle(context, ref), child: titleWidget),
|
||||||
trailing: trailing,
|
titleAlignment: ListTileTitleAlignment.center,
|
||||||
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
|
leading: leading,
|
||||||
subtitle: subtitleWidget,
|
trailing: trailing,
|
||||||
onTap: onTap,
|
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
|
||||||
|
subtitle: subtitleWidget,
|
||||||
|
onTap: onTap,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
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/hash.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/local_sync.service.dart';
|
import 'package:immich_mobile/domain/services/local_sync.service.dart';
|
||||||
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
||||||
@@ -12,8 +11,6 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/infrastructure/cancel.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/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.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/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||||
|
|
||||||
@@ -48,22 +45,11 @@ 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(
|
final hashServiceProvider = Provider(
|
||||||
(ref) => HashService(
|
(ref) => HashService(
|
||||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||||
localAssetRepository: ref.watch(localAssetRepository),
|
localAssetRepository: ref.watch(localAssetRepository),
|
||||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||||
stackRepository: ref.watch(driftStackProvider),
|
|
||||||
assetApiRepository: ref.watch(assetApiRepositoryProvider),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -53,18 +53,9 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
|||||||
);
|
);
|
||||||
final List<dynamic> _batchedAssetUploadReady = [];
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_batchDebouncer.dispose();
|
_batchDebouncer.dispose();
|
||||||
_stackUpdateDebouncer.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +105,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
|||||||
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
|
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
|
||||||
socket.on('on_config_update', _handleOnConfigUpdate);
|
socket.on('on_config_update', _handleOnConfigUpdate);
|
||||||
socket.on('on_new_release', _handleReleaseUpdates);
|
socket.on('on_new_release', _handleReleaseUpdates);
|
||||||
socket.on('on_asset_stack_update', _handleAssetStackUpdate);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dPrint(() => "[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
|
dPrint(() => "[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
|
||||||
}
|
}
|
||||||
@@ -198,14 +188,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
|||||||
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data));
|
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() {
|
void _processBatchedAssetUploadReadyV1() {
|
||||||
if (_batchedAssetUploadReady.isEmpty) {
|
if (_batchedAssetUploadReady.isEmpty) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -67,10 +67,6 @@ class AssetApiRepository extends ApiRepository {
|
|||||||
return _stacksApi.deleteStacks(BulkIdsDto(ids: ids));
|
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}) {
|
Future<Response> downloadAsset(String id, {required bool edited}) {
|
||||||
return _api.downloadAssetWithHttpInfo(id, edited: edited);
|
return _api.downloadAssetWithHttpInfo(id, edited: edited);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ class UploadRepository {
|
|||||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||||
);
|
);
|
||||||
FileDownloader().registerCallbacks(
|
|
||||||
group: kBackupEditPairGroup,
|
|
||||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
|
||||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
|
||||||
);
|
|
||||||
FileDownloader().registerCallbacks(
|
FileDownloader().registerCallbacks(
|
||||||
group: kManualUploadGroup,
|
group: kManualUploadGroup,
|
||||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||||
|
|||||||
@@ -9,24 +9,17 @@ 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/asset_metadata.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.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/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.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/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/settings.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/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/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/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/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||||
import 'package:immich_mobile/services/api.service.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:immich_mobile/utils/debug_print.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
@@ -38,9 +31,6 @@ final backgroundUploadServiceProvider = Provider((ref) {
|
|||||||
ref.watch(localAssetRepository),
|
ref.watch(localAssetRepository),
|
||||||
ref.watch(backupRepositoryProvider),
|
ref.watch(backupRepositoryProvider),
|
||||||
ref.watch(assetMediaRepositoryProvider),
|
ref.watch(assetMediaRepositoryProvider),
|
||||||
ref.watch(nativeSyncApiProvider),
|
|
||||||
ref.watch(editRevertServiceProvider),
|
|
||||||
ref.watch(driftStackProvider),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
ref.onDispose(service.dispose);
|
ref.onDispose(service.dispose);
|
||||||
@@ -53,35 +43,13 @@ class UploadTaskMetadata {
|
|||||||
final bool isLivePhotos;
|
final bool isLivePhotos;
|
||||||
final String livePhotoVideoId;
|
final String livePhotoVideoId;
|
||||||
|
|
||||||
// Marks the base upload of an edit pair. On completion the chained edit
|
const UploadTaskMetadata({required this.localAssetId, required this.isLivePhotos, required this.livePhotoVideoId});
|
||||||
// upload is enqueued with stackParentId = this base's remote id.
|
|
||||||
final bool isEditPair;
|
|
||||||
|
|
||||||
// Path of the native temp file backing this task (the edit base), so it can
|
UploadTaskMetadata copyWith({String? localAssetId, bool? isLivePhotos, String? livePhotoVideoId}) {
|
||||||
// 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(
|
return UploadTaskMetadata(
|
||||||
localAssetId: localAssetId ?? this.localAssetId,
|
localAssetId: localAssetId ?? this.localAssetId,
|
||||||
isLivePhotos: isLivePhotos ?? this.isLivePhotos,
|
isLivePhotos: isLivePhotos ?? this.isLivePhotos,
|
||||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||||
isEditPair: isEditPair ?? this.isEditPair,
|
|
||||||
basePath: basePath ?? this.basePath,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,8 +58,6 @@ class UploadTaskMetadata {
|
|||||||
'localAssetId': localAssetId,
|
'localAssetId': localAssetId,
|
||||||
'isLivePhotos': isLivePhotos,
|
'isLivePhotos': isLivePhotos,
|
||||||
'livePhotoVideoId': livePhotoVideoId,
|
'livePhotoVideoId': livePhotoVideoId,
|
||||||
'isEditPair': isEditPair,
|
|
||||||
'basePath': basePath,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,8 +66,6 @@ class UploadTaskMetadata {
|
|||||||
localAssetId: map['localAssetId'] as String,
|
localAssetId: map['localAssetId'] as String,
|
||||||
isLivePhotos: map['isLivePhotos'] as bool,
|
isLivePhotos: map['isLivePhotos'] as bool,
|
||||||
livePhotoVideoId: map['livePhotoVideoId'] as String,
|
livePhotoVideoId: map['livePhotoVideoId'] as String,
|
||||||
isEditPair: (map['isEditPair'] as bool?) ?? false,
|
|
||||||
basePath: (map['basePath'] as String?) ?? '',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +76,7 @@ class UploadTaskMetadata {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
String toString() =>
|
||||||
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId, isEditPair: $isEditPair, basePath: $basePath)';
|
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(covariant UploadTaskMetadata other) {
|
bool operator ==(covariant UploadTaskMetadata other) {
|
||||||
@@ -122,18 +86,11 @@ class UploadTaskMetadata {
|
|||||||
|
|
||||||
return other.localAssetId == localAssetId &&
|
return other.localAssetId == localAssetId &&
|
||||||
other.isLivePhotos == isLivePhotos &&
|
other.isLivePhotos == isLivePhotos &&
|
||||||
other.livePhotoVideoId == livePhotoVideoId &&
|
other.livePhotoVideoId == livePhotoVideoId;
|
||||||
other.isEditPair == isEditPair &&
|
|
||||||
other.basePath == basePath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode => localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode;
|
||||||
localAssetId.hashCode ^
|
|
||||||
isLivePhotos.hashCode ^
|
|
||||||
livePhotoVideoId.hashCode ^
|
|
||||||
isEditPair.hashCode ^
|
|
||||||
basePath.hashCode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Service for handling background uploads using iOS URLSession (background_downloader)
|
/// Service for handling background uploads using iOS URLSession (background_downloader)
|
||||||
@@ -147,9 +104,6 @@ class BackgroundUploadService {
|
|||||||
this._localAssetRepository,
|
this._localAssetRepository,
|
||||||
this._backupRepository,
|
this._backupRepository,
|
||||||
this._assetMediaRepository,
|
this._assetMediaRepository,
|
||||||
this._nativeSyncApi,
|
|
||||||
this._editRevertService,
|
|
||||||
this._stackRepository,
|
|
||||||
) {
|
) {
|
||||||
_uploadRepository.onUploadStatus = _onUploadCallback;
|
_uploadRepository.onUploadStatus = _onUploadCallback;
|
||||||
_uploadRepository.onTaskProgress = _onTaskProgressCallback;
|
_uploadRepository.onTaskProgress = _onTaskProgressCallback;
|
||||||
@@ -160,9 +114,6 @@ class BackgroundUploadService {
|
|||||||
final DriftLocalAssetRepository _localAssetRepository;
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
final DriftBackupRepository _backupRepository;
|
final DriftBackupRepository _backupRepository;
|
||||||
final AssetMediaRepository _assetMediaRepository;
|
final AssetMediaRepository _assetMediaRepository;
|
||||||
final NativeSyncApi _nativeSyncApi;
|
|
||||||
final EditRevertService _editRevertService;
|
|
||||||
final DriftStackRepository _stackRepository;
|
|
||||||
final Logger _logger = Logger('BackgroundUploadService');
|
final Logger _logger = Logger('BackgroundUploadService');
|
||||||
|
|
||||||
final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast();
|
final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast();
|
||||||
@@ -242,13 +193,10 @@ class BackgroundUploadService {
|
|||||||
|
|
||||||
await _storageRepository.clearCache();
|
await _storageRepository.clearCache();
|
||||||
await _uploadRepository.reset(kBackupGroup);
|
await _uploadRepository.reset(kBackupGroup);
|
||||||
await _uploadRepository.reset(kBackupEditPairGroup);
|
|
||||||
await _uploadRepository.deleteDatabaseRecords(kBackupGroup);
|
await _uploadRepository.deleteDatabaseRecords(kBackupGroup);
|
||||||
await _uploadRepository.deleteDatabaseRecords(kBackupEditPairGroup);
|
|
||||||
|
|
||||||
final activeTasks = await _uploadRepository.getActiveTasks(kBackupGroup);
|
final activeTasks = await _uploadRepository.getActiveTasks(kBackupGroup);
|
||||||
final activeEditTasks = await _uploadRepository.getActiveTasks(kBackupEditPairGroup);
|
return activeTasks.length;
|
||||||
return activeTasks.length + activeEditTasks.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resume background backup processing
|
/// Resume background backup processing
|
||||||
@@ -257,25 +205,11 @@ class BackgroundUploadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleTaskStatusUpdate(TaskStatusUpdate update) async {
|
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) {
|
switch (update.status) {
|
||||||
case TaskStatus.complete:
|
case TaskStatus.complete:
|
||||||
unawaited(_handleLivePhoto(update, metadata));
|
unawaited(_handleLivePhoto(update));
|
||||||
unawaited(handleEditPair(update, metadata));
|
|
||||||
unawaited(recordPriorRemoteIdOnSuccess(update, metadata));
|
|
||||||
|
|
||||||
// Edit-pair bases live in the native temp dir and are deleted by
|
if (CurrentPlatform.isIOS) {
|
||||||
// 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 {
|
try {
|
||||||
final path = await update.task.filePath();
|
final path = await update.task.filePath();
|
||||||
await File(path).delete();
|
await File(path).delete();
|
||||||
@@ -286,20 +220,19 @@ class BackgroundUploadService {
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TaskStatus.failed:
|
|
||||||
case TaskStatus.canceled:
|
|
||||||
case TaskStatus.notFound:
|
|
||||||
unawaited(_cleanupTempResourceOnFailure(metadata));
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleLivePhoto(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
|
Future<void> _handleLivePhoto(TaskStatusUpdate update) async {
|
||||||
try {
|
try {
|
||||||
if (metadata == null || !metadata.isLivePhotos) {
|
if (update.task.metaData.isEmpty || update.task.metaData == '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final metadata = UploadTaskMetadata.fromJson(update.task.metaData);
|
||||||
|
if (!metadata.isLivePhotos) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,143 +258,6 @@ 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
|
@visibleForTesting
|
||||||
Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
|
Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
|
||||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||||
@@ -470,24 +266,6 @@ class BackgroundUploadService {
|
|||||||
return null;
|
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;
|
File? file;
|
||||||
|
|
||||||
/// iOS LivePhoto has two files: a photo and a video.
|
/// iOS LivePhoto has two files: a photo and a video.
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
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,26 +6,18 @@ 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/asset_metadata.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.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/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_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/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/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/infrastructure/repositories/storage.repository.dart';
|
||||||
import 'package:immich_mobile/platform/connectivity_api.g.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/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/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/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/upload.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:logging/logging.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||||
@@ -47,10 +39,6 @@ final foregroundUploadServiceProvider = Provider((ref) {
|
|||||||
ref.watch(backupRepositoryProvider),
|
ref.watch(backupRepositoryProvider),
|
||||||
ref.watch(connectivityApiProvider),
|
ref.watch(connectivityApiProvider),
|
||||||
ref.watch(assetMediaRepositoryProvider),
|
ref.watch(assetMediaRepositoryProvider),
|
||||||
ref.watch(nativeSyncApiProvider),
|
|
||||||
ref.watch(localAssetRepository),
|
|
||||||
ref.watch(editRevertServiceProvider),
|
|
||||||
ref.watch(driftStackProvider),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,10 +54,6 @@ class ForegroundUploadService {
|
|||||||
this._backupRepository,
|
this._backupRepository,
|
||||||
this._connectivityApi,
|
this._connectivityApi,
|
||||||
this._assetMediaRepository,
|
this._assetMediaRepository,
|
||||||
this._nativeSyncApi,
|
|
||||||
this._localAssetRepository,
|
|
||||||
this._editRevertService,
|
|
||||||
this._stackRepository,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final UploadRepository _uploadRepository;
|
final UploadRepository _uploadRepository;
|
||||||
@@ -77,10 +61,6 @@ class ForegroundUploadService {
|
|||||||
final DriftBackupRepository _backupRepository;
|
final DriftBackupRepository _backupRepository;
|
||||||
final ConnectivityApi _connectivityApi;
|
final ConnectivityApi _connectivityApi;
|
||||||
final AssetMediaRepository _assetMediaRepository;
|
final AssetMediaRepository _assetMediaRepository;
|
||||||
final NativeSyncApi _nativeSyncApi;
|
|
||||||
final DriftLocalAssetRepository _localAssetRepository;
|
|
||||||
final EditRevertService _editRevertService;
|
|
||||||
final DriftStackRepository _stackRepository;
|
|
||||||
final Logger _logger = Logger('ForegroundUploadService');
|
final Logger _logger = Logger('ForegroundUploadService');
|
||||||
|
|
||||||
bool shouldAbortUpload = false;
|
bool shouldAbortUpload = false;
|
||||||
@@ -270,16 +250,6 @@ class ForegroundUploadService {
|
|||||||
return;
|
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);
|
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
|
||||||
|
|
||||||
if (!isAvailableLocally && CurrentPlatform.isIOS) {
|
if (!isAvailableLocally && CurrentPlatform.isIOS) {
|
||||||
@@ -385,27 +355,20 @@ class ForegroundUploadService {
|
|||||||
fields['livePhotoVideoId'] = livePhotoVideoId;
|
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.
|
// 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.
|
||||||
final metadata = _cloudMetadata(asset, includeAdjustment: true);
|
if (CurrentPlatform.isIOS && asset.cloudId != null) {
|
||||||
if (metadata != null) {
|
fields['metadata'] = jsonEncode([
|
||||||
fields['metadata'] = metadata;
|
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 onProgress = callbacks.onProgress;
|
final onProgress = callbacks.onProgress;
|
||||||
@@ -421,14 +384,6 @@ class ForegroundUploadService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.isSuccess && result.remoteAssetId != null) {
|
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!);
|
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
|
||||||
} else if (result.isCancelled) {
|
} else if (result.isCancelled) {
|
||||||
_logger.warning(() => "Backup was cancelled by the user");
|
_logger.warning(() => "Backup was cancelled by the user");
|
||||||
@@ -460,71 +415,6 @@ 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(
|
Future<UploadResult> _uploadSingleFile(
|
||||||
File file, {
|
File file, {
|
||||||
required String deviceAssetId,
|
required String deviceAssetId,
|
||||||
@@ -571,16 +461,3 @@ class ForegroundUploadService {
|
|||||||
return true;
|
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
+3
-13
@@ -1586,11 +1586,8 @@ class AssetsApi {
|
|||||||
/// * [MultipartFile] sidecarData:
|
/// * [MultipartFile] sidecarData:
|
||||||
/// Sidecar file data
|
/// Sidecar file data
|
||||||
///
|
///
|
||||||
/// * [String] stackParentId:
|
|
||||||
/// Stack this asset onto the parent asset, with the new asset as the stack primary
|
|
||||||
///
|
|
||||||
/// * [AssetVisibility] visibility:
|
/// * [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, String? stackParentId, 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, AssetVisibility? visibility, Future<void>? abortTrigger, }) async {
|
||||||
// ignore: prefer_const_declarations
|
// ignore: prefer_const_declarations
|
||||||
final apiPath = r'/assets';
|
final apiPath = r'/assets';
|
||||||
|
|
||||||
@@ -1654,10 +1651,6 @@ class AssetsApi {
|
|||||||
mp.fields[r'sidecarData'] = sidecarData.field;
|
mp.fields[r'sidecarData'] = sidecarData.field;
|
||||||
mp.files.add(sidecarData);
|
mp.files.add(sidecarData);
|
||||||
}
|
}
|
||||||
if (stackParentId != null) {
|
|
||||||
hasFields = true;
|
|
||||||
mp.fields[r'stackParentId'] = parameterToString(stackParentId);
|
|
||||||
}
|
|
||||||
if (visibility != null) {
|
if (visibility != null) {
|
||||||
hasFields = true;
|
hasFields = true;
|
||||||
mp.fields[r'visibility'] = parameterToString(visibility);
|
mp.fields[r'visibility'] = parameterToString(visibility);
|
||||||
@@ -1718,12 +1711,9 @@ class AssetsApi {
|
|||||||
/// * [MultipartFile] sidecarData:
|
/// * [MultipartFile] sidecarData:
|
||||||
/// Sidecar file data
|
/// Sidecar file data
|
||||||
///
|
///
|
||||||
/// * [String] stackParentId:
|
|
||||||
/// Stack this asset onto the parent asset, with the new asset as the stack primary
|
|
||||||
///
|
|
||||||
/// * [AssetVisibility] visibility:
|
/// * [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, String? stackParentId, AssetVisibility? visibility, Future<void>? abortTrigger, }) async {
|
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, stackParentId: stackParentId, visibility: visibility, abortTrigger: abortTrigger,);
|
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,);
|
||||||
if (response.statusCode >= HttpStatus.badRequest) {
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-1
@@ -14,32 +14,52 @@ class PeopleResponse {
|
|||||||
/// Returns a new [PeopleResponse] instance.
|
/// Returns a new [PeopleResponse] instance.
|
||||||
PeopleResponse({
|
PeopleResponse({
|
||||||
required this.enabled,
|
required this.enabled,
|
||||||
|
this.minimumFaces,
|
||||||
required this.sidebarWeb,
|
required this.sidebarWeb,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Whether people are enabled
|
/// Whether people are enabled
|
||||||
bool 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
|
/// Whether people appear in web sidebar
|
||||||
bool sidebarWeb;
|
bool sidebarWeb;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is PeopleResponse &&
|
bool operator ==(Object other) => identical(this, other) || other is PeopleResponse &&
|
||||||
other.enabled == enabled &&
|
other.enabled == enabled &&
|
||||||
|
other.minimumFaces == minimumFaces &&
|
||||||
other.sidebarWeb == sidebarWeb;
|
other.sidebarWeb == sidebarWeb;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(enabled.hashCode) +
|
(enabled.hashCode) +
|
||||||
|
(minimumFaces == null ? 0 : minimumFaces!.hashCode) +
|
||||||
(sidebarWeb.hashCode);
|
(sidebarWeb.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'PeopleResponse[enabled=$enabled, sidebarWeb=$sidebarWeb]';
|
String toString() => 'PeopleResponse[enabled=$enabled, minimumFaces=$minimumFaces, sidebarWeb=$sidebarWeb]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
json[r'enabled'] = this.enabled;
|
json[r'enabled'] = this.enabled;
|
||||||
|
if (this.minimumFaces != null) {
|
||||||
|
json[r'minimumFaces'] = this.minimumFaces;
|
||||||
|
} else {
|
||||||
|
// json[r'minimumFaces'] = null;
|
||||||
|
}
|
||||||
json[r'sidebarWeb'] = this.sidebarWeb;
|
json[r'sidebarWeb'] = this.sidebarWeb;
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
@@ -54,6 +74,7 @@ class PeopleResponse {
|
|||||||
|
|
||||||
return PeopleResponse(
|
return PeopleResponse(
|
||||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||||
|
minimumFaces: mapValueOfType<int>(json, r'minimumFaces'),
|
||||||
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb')!,
|
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb')!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-1
@@ -14,6 +14,7 @@ class PeopleUpdate {
|
|||||||
/// Returns a new [PeopleUpdate] instance.
|
/// Returns a new [PeopleUpdate] instance.
|
||||||
PeopleUpdate({
|
PeopleUpdate({
|
||||||
this.enabled,
|
this.enabled,
|
||||||
|
this.minimumFaces,
|
||||||
this.sidebarWeb,
|
this.sidebarWeb,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,6 +27,18 @@ class PeopleUpdate {
|
|||||||
///
|
///
|
||||||
bool? 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
|
/// Whether people appear in web sidebar
|
||||||
///
|
///
|
||||||
/// Please note: This property should have been non-nullable! Since the specification file
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
@@ -38,16 +51,18 @@ class PeopleUpdate {
|
|||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is PeopleUpdate &&
|
bool operator ==(Object other) => identical(this, other) || other is PeopleUpdate &&
|
||||||
other.enabled == enabled &&
|
other.enabled == enabled &&
|
||||||
|
other.minimumFaces == minimumFaces &&
|
||||||
other.sidebarWeb == sidebarWeb;
|
other.sidebarWeb == sidebarWeb;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(enabled == null ? 0 : enabled!.hashCode) +
|
(enabled == null ? 0 : enabled!.hashCode) +
|
||||||
|
(minimumFaces == null ? 0 : minimumFaces!.hashCode) +
|
||||||
(sidebarWeb == null ? 0 : sidebarWeb!.hashCode);
|
(sidebarWeb == null ? 0 : sidebarWeb!.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'PeopleUpdate[enabled=$enabled, sidebarWeb=$sidebarWeb]';
|
String toString() => 'PeopleUpdate[enabled=$enabled, minimumFaces=$minimumFaces, sidebarWeb=$sidebarWeb]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@@ -56,6 +71,11 @@ class PeopleUpdate {
|
|||||||
} else {
|
} else {
|
||||||
// json[r'enabled'] = null;
|
// json[r'enabled'] = null;
|
||||||
}
|
}
|
||||||
|
if (this.minimumFaces != null) {
|
||||||
|
json[r'minimumFaces'] = this.minimumFaces;
|
||||||
|
} else {
|
||||||
|
// json[r'minimumFaces'] = null;
|
||||||
|
}
|
||||||
if (this.sidebarWeb != null) {
|
if (this.sidebarWeb != null) {
|
||||||
json[r'sidebarWeb'] = this.sidebarWeb;
|
json[r'sidebarWeb'] = this.sidebarWeb;
|
||||||
} else {
|
} else {
|
||||||
@@ -74,6 +94,7 @@ class PeopleUpdate {
|
|||||||
|
|
||||||
return PeopleUpdate(
|
return PeopleUpdate(
|
||||||
enabled: mapValueOfType<bool>(json, r'enabled'),
|
enabled: mapValueOfType<bool>(json, r'enabled'),
|
||||||
|
minimumFaces: mapValueOfType<int>(json, r'minimumFaces'),
|
||||||
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb'),
|
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -20,6 +20,7 @@ class ServerConfigDto {
|
|||||||
required this.maintenanceMode,
|
required this.maintenanceMode,
|
||||||
required this.mapDarkStyleUrl,
|
required this.mapDarkStyleUrl,
|
||||||
required this.mapLightStyleUrl,
|
required this.mapLightStyleUrl,
|
||||||
|
required this.minFaces,
|
||||||
required this.oauthButtonText,
|
required this.oauthButtonText,
|
||||||
required this.publicUsers,
|
required this.publicUsers,
|
||||||
required this.trashDays,
|
required this.trashDays,
|
||||||
@@ -47,6 +48,12 @@ class ServerConfigDto {
|
|||||||
/// Map light style URL
|
/// Map light style URL
|
||||||
String mapLightStyleUrl;
|
String mapLightStyleUrl;
|
||||||
|
|
||||||
|
/// People min faces server default
|
||||||
|
///
|
||||||
|
/// Minimum value: -9007199254740991
|
||||||
|
/// Maximum value: 9007199254740991
|
||||||
|
int minFaces;
|
||||||
|
|
||||||
/// OAuth button text
|
/// OAuth button text
|
||||||
String oauthButtonText;
|
String oauthButtonText;
|
||||||
|
|
||||||
@@ -74,6 +81,7 @@ class ServerConfigDto {
|
|||||||
other.maintenanceMode == maintenanceMode &&
|
other.maintenanceMode == maintenanceMode &&
|
||||||
other.mapDarkStyleUrl == mapDarkStyleUrl &&
|
other.mapDarkStyleUrl == mapDarkStyleUrl &&
|
||||||
other.mapLightStyleUrl == mapLightStyleUrl &&
|
other.mapLightStyleUrl == mapLightStyleUrl &&
|
||||||
|
other.minFaces == minFaces &&
|
||||||
other.oauthButtonText == oauthButtonText &&
|
other.oauthButtonText == oauthButtonText &&
|
||||||
other.publicUsers == publicUsers &&
|
other.publicUsers == publicUsers &&
|
||||||
other.trashDays == trashDays &&
|
other.trashDays == trashDays &&
|
||||||
@@ -89,13 +97,14 @@ class ServerConfigDto {
|
|||||||
(maintenanceMode.hashCode) +
|
(maintenanceMode.hashCode) +
|
||||||
(mapDarkStyleUrl.hashCode) +
|
(mapDarkStyleUrl.hashCode) +
|
||||||
(mapLightStyleUrl.hashCode) +
|
(mapLightStyleUrl.hashCode) +
|
||||||
|
(minFaces.hashCode) +
|
||||||
(oauthButtonText.hashCode) +
|
(oauthButtonText.hashCode) +
|
||||||
(publicUsers.hashCode) +
|
(publicUsers.hashCode) +
|
||||||
(trashDays.hashCode) +
|
(trashDays.hashCode) +
|
||||||
(userDeleteDelay.hashCode);
|
(userDeleteDelay.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
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]';
|
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]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@@ -106,6 +115,7 @@ class ServerConfigDto {
|
|||||||
json[r'maintenanceMode'] = this.maintenanceMode;
|
json[r'maintenanceMode'] = this.maintenanceMode;
|
||||||
json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl;
|
json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl;
|
||||||
json[r'mapLightStyleUrl'] = this.mapLightStyleUrl;
|
json[r'mapLightStyleUrl'] = this.mapLightStyleUrl;
|
||||||
|
json[r'minFaces'] = this.minFaces;
|
||||||
json[r'oauthButtonText'] = this.oauthButtonText;
|
json[r'oauthButtonText'] = this.oauthButtonText;
|
||||||
json[r'publicUsers'] = this.publicUsers;
|
json[r'publicUsers'] = this.publicUsers;
|
||||||
json[r'trashDays'] = this.trashDays;
|
json[r'trashDays'] = this.trashDays;
|
||||||
@@ -129,6 +139,7 @@ class ServerConfigDto {
|
|||||||
maintenanceMode: mapValueOfType<bool>(json, r'maintenanceMode')!,
|
maintenanceMode: mapValueOfType<bool>(json, r'maintenanceMode')!,
|
||||||
mapDarkStyleUrl: mapValueOfType<String>(json, r'mapDarkStyleUrl')!,
|
mapDarkStyleUrl: mapValueOfType<String>(json, r'mapDarkStyleUrl')!,
|
||||||
mapLightStyleUrl: mapValueOfType<String>(json, r'mapLightStyleUrl')!,
|
mapLightStyleUrl: mapValueOfType<String>(json, r'mapLightStyleUrl')!,
|
||||||
|
minFaces: mapValueOfType<int>(json, r'minFaces')!,
|
||||||
oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
|
oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
|
||||||
publicUsers: mapValueOfType<bool>(json, r'publicUsers')!,
|
publicUsers: mapValueOfType<bool>(json, r'publicUsers')!,
|
||||||
trashDays: mapValueOfType<int>(json, r'trashDays')!,
|
trashDays: mapValueOfType<int>(json, r'trashDays')!,
|
||||||
@@ -187,6 +198,7 @@ class ServerConfigDto {
|
|||||||
'maintenanceMode',
|
'maintenanceMode',
|
||||||
'mapDarkStyleUrl',
|
'mapDarkStyleUrl',
|
||||||
'mapLightStyleUrl',
|
'mapLightStyleUrl',
|
||||||
|
'minFaces',
|
||||||
'oauthButtonText',
|
'oauthButtonText',
|
||||||
'publicUsers',
|
'publicUsers',
|
||||||
'trashDays',
|
'trashDays',
|
||||||
|
|||||||
@@ -103,21 +103,6 @@ class CloudIdResult {
|
|||||||
const CloudIdResult({required this.assetId, this.error, this.cloudId});
|
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()
|
@HostApi()
|
||||||
abstract class NativeSyncApi {
|
abstract class NativeSyncApi {
|
||||||
bool shouldFullSync();
|
bool shouldFullSync();
|
||||||
@@ -155,12 +140,4 @@ abstract class NativeSyncApi {
|
|||||||
|
|
||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
|
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,9 +1,6 @@
|
|||||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
class MockSyncApi extends Mock implements SyncApi {}
|
class MockSyncApi extends Mock implements SyncApi {}
|
||||||
|
|
||||||
class MockServerApi extends Mock implements ServerApi {}
|
class MockServerApi extends Mock implements ServerApi {}
|
||||||
|
|
||||||
class MockConnectivityApi extends Mock implements ConnectivityApi {}
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
|
||||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
@@ -12,5 +11,3 @@ class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
|
|||||||
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
||||||
|
|
||||||
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
||||||
|
|
||||||
class MockEditRevertService extends Mock implements EditRevertService {}
|
|
||||||
|
|||||||
-4
@@ -31,7 +31,6 @@ import 'schema_v24.dart' as v24;
|
|||||||
import 'schema_v25.dart' as v25;
|
import 'schema_v25.dart' as v25;
|
||||||
import 'schema_v26.dart' as v26;
|
import 'schema_v26.dart' as v26;
|
||||||
import 'schema_v27.dart' as v27;
|
import 'schema_v27.dart' as v27;
|
||||||
import 'schema_v28.dart' as v28;
|
|
||||||
|
|
||||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||||
@override
|
@override
|
||||||
@@ -91,8 +90,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
|||||||
return v26.DatabaseAtV26(db);
|
return v26.DatabaseAtV26(db);
|
||||||
case 27:
|
case 27:
|
||||||
return v27.DatabaseAtV27(db);
|
return v27.DatabaseAtV27(db);
|
||||||
case 28:
|
|
||||||
return v28.DatabaseAtV28(db);
|
|
||||||
default:
|
default:
|
||||||
throw MissingSchemaException(version, versions);
|
throw MissingSchemaException(version, versions);
|
||||||
}
|
}
|
||||||
@@ -126,6 +123,5 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
|||||||
25,
|
25,
|
||||||
26,
|
26,
|
||||||
27,
|
27,
|
||||||
28,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
-9471
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@ import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/settings.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_album.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.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/storage.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||||
@@ -37,8 +36,6 @@ class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
|
|||||||
|
|
||||||
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
|
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
|
||||||
|
|
||||||
class MockDriftStackRepository extends Mock implements DriftStackRepository {}
|
|
||||||
|
|
||||||
class MockStorageRepository extends Mock implements StorageRepository {}
|
class MockStorageRepository extends Mock implements StorageRepository {}
|
||||||
|
|
||||||
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
|
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
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,7 +14,6 @@ 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_album_user.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.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/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/entities/user.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/utils/option.dart';
|
import 'package:immich_mobile/utils/option.dart';
|
||||||
@@ -87,7 +86,6 @@ class MediumRepositoryContext {
|
|||||||
String? stackId,
|
String? stackId,
|
||||||
String? thumbHash,
|
String? thumbHash,
|
||||||
String? libraryId,
|
String? libraryId,
|
||||||
DateTime? uploadedAt,
|
|
||||||
}) async {
|
}) async {
|
||||||
id ??= TestUtils.uuid();
|
id ??= TestUtils.uuid();
|
||||||
createdAt ??= TestUtils.date();
|
createdAt ??= TestUtils.date();
|
||||||
@@ -114,19 +112,6 @@ class MediumRepositoryContext {
|
|||||||
localDateTime: .new(createdAt.toLocal()),
|
localDateTime: .new(createdAt.toLocal()),
|
||||||
thumbHash: .new(TestUtils.uuid(thumbHash)),
|
thumbHash: .new(TestUtils.uuid(thumbHash)),
|
||||||
libraryId: .new(TestUtils.uuid(libraryId)),
|
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),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -260,8 +245,6 @@ class MediumRepositoryContext {
|
|||||||
int? durationMs,
|
int? durationMs,
|
||||||
int? orientation,
|
int? orientation,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
String? priorRemoteId,
|
|
||||||
String? syncedChecksum,
|
|
||||||
}) async {
|
}) async {
|
||||||
id ??= TestUtils.uuid();
|
id ??= TestUtils.uuid();
|
||||||
return db
|
return db
|
||||||
@@ -283,8 +266,6 @@ class MediumRepositoryContext {
|
|||||||
adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()),
|
adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()),
|
||||||
latitude: .new(latitude ?? TestUtils.randDouble(-90, 90)),
|
latitude: .new(latitude ?? TestUtils.randDouble(-90, 90)),
|
||||||
longitude: .new(longitude ?? TestUtils.randDouble(-180, 180)),
|
longitude: .new(longitude ?? TestUtils.randDouble(-180, 180)),
|
||||||
priorRemoteId: .new(priorRemoteId),
|
|
||||||
syncedChecksum: .new(syncedChecksum),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:background_downloader/background_downloader.dart';
|
|
||||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
@@ -15,11 +13,9 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.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/settings.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/store.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:immich_mobile/services/background_upload.service.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
import '../domain/service.mock.dart';
|
|
||||||
import '../fixtures/asset.stub.dart';
|
import '../fixtures/asset.stub.dart';
|
||||||
import '../infrastructure/repository.mock.dart';
|
import '../infrastructure/repository.mock.dart';
|
||||||
import '../mocks/asset_entity.mock.dart';
|
import '../mocks/asset_entity.mock.dart';
|
||||||
@@ -32,15 +28,10 @@ void main() {
|
|||||||
late MockDriftLocalAssetRepository mockLocalAssetRepository;
|
late MockDriftLocalAssetRepository mockLocalAssetRepository;
|
||||||
late MockDriftBackupRepository mockBackupRepository;
|
late MockDriftBackupRepository mockBackupRepository;
|
||||||
late MockAssetMediaRepository mockAssetMediaRepository;
|
late MockAssetMediaRepository mockAssetMediaRepository;
|
||||||
late MockNativeSyncApi mockNativeSyncApi;
|
|
||||||
late MockEditRevertService mockEditRevertService;
|
|
||||||
late MockDriftStackRepository mockStackRepository;
|
|
||||||
late Drift db;
|
late Drift db;
|
||||||
|
|
||||||
setUpAll(() async {
|
setUpAll(() async {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
registerFallbackValue(LocalAssetStub.image1);
|
|
||||||
registerFallbackValue(<UploadTask>[]);
|
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||||
const MethodChannel('plugins.flutter.io/path_provider'),
|
const MethodChannel('plugins.flutter.io/path_provider'),
|
||||||
(MethodCall methodCall) async => 'test',
|
(MethodCall methodCall) async => 'test',
|
||||||
@@ -59,9 +50,6 @@ void main() {
|
|||||||
mockLocalAssetRepository = MockDriftLocalAssetRepository();
|
mockLocalAssetRepository = MockDriftLocalAssetRepository();
|
||||||
mockBackupRepository = MockDriftBackupRepository();
|
mockBackupRepository = MockDriftBackupRepository();
|
||||||
mockAssetMediaRepository = MockAssetMediaRepository();
|
mockAssetMediaRepository = MockAssetMediaRepository();
|
||||||
mockNativeSyncApi = MockNativeSyncApi();
|
|
||||||
mockEditRevertService = MockEditRevertService();
|
|
||||||
mockStackRepository = MockDriftStackRepository();
|
|
||||||
|
|
||||||
sut = BackgroundUploadService(
|
sut = BackgroundUploadService(
|
||||||
mockUploadRepository,
|
mockUploadRepository,
|
||||||
@@ -69,22 +57,8 @@ void main() {
|
|||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
mockBackupRepository,
|
mockBackupRepository,
|
||||||
mockAssetMediaRepository,
|
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.onUploadStatus = (_) {};
|
||||||
mockUploadRepository.onTaskProgress = (_) {};
|
mockUploadRepository.onTaskProgress = (_) {};
|
||||||
});
|
});
|
||||||
@@ -148,234 +122,6 @@ 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', () {
|
group('getLivePhotoUploadTask', () {
|
||||||
test('should call getOriginalFilename for live photo upload task', () async {
|
test('should call getOriginalFilename for live photo upload task', () async {
|
||||||
final asset = LocalAssetStub.image1;
|
final asset = LocalAssetStub.image1;
|
||||||
@@ -426,9 +172,6 @@ void main() {
|
|||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
mockBackupRepository,
|
mockBackupRepository,
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
mockNativeSyncApi,
|
|
||||||
mockEditRevertService,
|
|
||||||
mockStackRepository,
|
|
||||||
);
|
);
|
||||||
addTearDown(() => sutWithV24.dispose());
|
addTearDown(() => sutWithV24.dispose());
|
||||||
|
|
||||||
@@ -479,9 +222,6 @@ void main() {
|
|||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
mockBackupRepository,
|
mockBackupRepository,
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
mockNativeSyncApi,
|
|
||||||
mockEditRevertService,
|
|
||||||
mockStackRepository,
|
|
||||||
);
|
);
|
||||||
addTearDown(() => sutAndroid.dispose());
|
addTearDown(() => sutAndroid.dispose());
|
||||||
|
|
||||||
@@ -522,9 +262,6 @@ void main() {
|
|||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
mockBackupRepository,
|
mockBackupRepository,
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
mockNativeSyncApi,
|
|
||||||
mockEditRevertService,
|
|
||||||
mockStackRepository,
|
|
||||||
);
|
);
|
||||||
addTearDown(() => sutWithV24.dispose());
|
addTearDown(() => sutWithV24.dispose());
|
||||||
|
|
||||||
@@ -565,9 +302,6 @@ void main() {
|
|||||||
mockLocalAssetRepository,
|
mockLocalAssetRepository,
|
||||||
mockBackupRepository,
|
mockBackupRepository,
|
||||||
mockAssetMediaRepository,
|
mockAssetMediaRepository,
|
||||||
mockNativeSyncApi,
|
|
||||||
mockEditRevertService,
|
|
||||||
mockStackRepository,
|
|
||||||
);
|
);
|
||||||
addTearDown(() => sutWithV24.dispose());
|
addTearDown(() => sutWithV24.dispose());
|
||||||
|
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
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,14 +4,11 @@ import 'package:mocktail/mocktail.dart' as mocktail;
|
|||||||
|
|
||||||
import '../domain/service.mock.dart';
|
import '../domain/service.mock.dart';
|
||||||
import '../infrastructure/repository.mock.dart';
|
import '../infrastructure/repository.mock.dart';
|
||||||
import '../repository.mocks.dart';
|
|
||||||
|
|
||||||
class UnitMocks {
|
class UnitMocks {
|
||||||
final localAlbum = MockLocalAlbumRepository();
|
final localAlbum = MockLocalAlbumRepository();
|
||||||
final localAsset = MockDriftLocalAssetRepository();
|
final localAsset = MockDriftLocalAssetRepository();
|
||||||
final trashedAsset = MockTrashedLocalAssetRepository();
|
final trashedAsset = MockTrashedLocalAssetRepository();
|
||||||
final stack = MockDriftStackRepository();
|
|
||||||
final assetApi = MockAssetApiRepository();
|
|
||||||
|
|
||||||
final nativeApi = MockNativeSyncApi();
|
final nativeApi = MockNativeSyncApi();
|
||||||
|
|
||||||
@@ -34,8 +31,6 @@ class UnitMocks {
|
|||||||
mocktail.reset(localAlbum);
|
mocktail.reset(localAlbum);
|
||||||
mocktail.reset(localAsset);
|
mocktail.reset(localAsset);
|
||||||
mocktail.reset(trashedAsset);
|
mocktail.reset(trashedAsset);
|
||||||
mocktail.reset(stack);
|
|
||||||
mocktail.reset(assetApi);
|
|
||||||
mocktail.reset(nativeApi);
|
mocktail.reset(nativeApi);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
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>());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
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,8 +1,6 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/local_album.model.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/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:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
@@ -20,8 +18,6 @@ void main() {
|
|||||||
localAssetRepository: mocks.localAsset,
|
localAssetRepository: mocks.localAsset,
|
||||||
nativeSyncApi: mocks.nativeApi,
|
nativeSyncApi: mocks.nativeApi,
|
||||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||||
stackRepository: mocks.stack,
|
|
||||||
assetApiRepository: mocks.assetApi,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
|
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
|
||||||
@@ -114,8 +110,6 @@ void main() {
|
|||||||
nativeSyncApi: mocks.nativeApi,
|
nativeSyncApi: mocks.nativeApi,
|
||||||
batchSize: batchSize,
|
batchSize: batchSize,
|
||||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||||
stackRepository: mocks.stack,
|
|
||||||
assetApiRepository: mocks.assetApi,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final album = LocalAlbumFactory.create();
|
final album = LocalAlbumFactory.create();
|
||||||
@@ -189,61 +183,5 @@ void main() {
|
|||||||
verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
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,12 +16899,6 @@
|
|||||||
"format": "binary",
|
"format": "binary",
|
||||||
"type": "string"
|
"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": {
|
"visibility": {
|
||||||
"$ref": "#/components/schemas/AssetVisibility"
|
"$ref": "#/components/schemas/AssetVisibility"
|
||||||
}
|
}
|
||||||
@@ -19913,6 +19907,12 @@
|
|||||||
"description": "Whether people are enabled",
|
"description": "Whether people are enabled",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"minimumFaces": {
|
||||||
|
"description": "People face threshold",
|
||||||
|
"maximum": 9007199254740991,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"sidebarWeb": {
|
"sidebarWeb": {
|
||||||
"description": "Whether people appear in web sidebar",
|
"description": "Whether people appear in web sidebar",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -19974,6 +19974,12 @@
|
|||||||
"description": "Whether people are enabled",
|
"description": "Whether people are enabled",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"minimumFaces": {
|
||||||
|
"description": "People face threshold",
|
||||||
|
"maximum": 9007199254740991,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"sidebarWeb": {
|
"sidebarWeb": {
|
||||||
"description": "Whether people appear in web sidebar",
|
"description": "Whether people appear in web sidebar",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -21610,6 +21616,12 @@
|
|||||||
"description": "Map light style URL",
|
"description": "Map light style URL",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"minFaces": {
|
||||||
|
"description": "People min faces server default",
|
||||||
|
"maximum": 9007199254740991,
|
||||||
|
"minimum": -9007199254740991,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"oauthButtonText": {
|
"oauthButtonText": {
|
||||||
"description": "OAuth button text",
|
"description": "OAuth button text",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -21639,6 +21651,7 @@
|
|||||||
"maintenanceMode",
|
"maintenanceMode",
|
||||||
"mapDarkStyleUrl",
|
"mapDarkStyleUrl",
|
||||||
"mapLightStyleUrl",
|
"mapLightStyleUrl",
|
||||||
|
"minFaces",
|
||||||
"oauthButtonText",
|
"oauthButtonText",
|
||||||
"publicUsers",
|
"publicUsers",
|
||||||
"trashDays",
|
"trashDays",
|
||||||
|
|||||||
@@ -298,6 +298,8 @@ export type MemoriesResponse = {
|
|||||||
export type PeopleResponse = {
|
export type PeopleResponse = {
|
||||||
/** Whether people are enabled */
|
/** Whether people are enabled */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
/** People face threshold */
|
||||||
|
minimumFaces?: number;
|
||||||
/** Whether people appear in web sidebar */
|
/** Whether people appear in web sidebar */
|
||||||
sidebarWeb: boolean;
|
sidebarWeb: boolean;
|
||||||
};
|
};
|
||||||
@@ -375,6 +377,8 @@ export type MemoriesUpdate = {
|
|||||||
export type PeopleUpdate = {
|
export type PeopleUpdate = {
|
||||||
/** Whether people are enabled */
|
/** Whether people are enabled */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
/** People face threshold */
|
||||||
|
minimumFaces?: number;
|
||||||
/** Whether people appear in web sidebar */
|
/** Whether people appear in web sidebar */
|
||||||
sidebarWeb?: boolean;
|
sidebarWeb?: boolean;
|
||||||
};
|
};
|
||||||
@@ -630,8 +634,6 @@ export type AssetMediaCreateDto = {
|
|||||||
metadata?: AssetMetadataUpsertItemDto[];
|
metadata?: AssetMetadataUpsertItemDto[];
|
||||||
/** Sidecar file data */
|
/** Sidecar file data */
|
||||||
sidecarData?: Blob;
|
sidecarData?: Blob;
|
||||||
/** Stack this asset onto the parent asset, with the new asset as the stack primary */
|
|
||||||
stackParentId?: string;
|
|
||||||
visibility?: AssetVisibility;
|
visibility?: AssetVisibility;
|
||||||
};
|
};
|
||||||
export type AssetMediaResponseDto = {
|
export type AssetMediaResponseDto = {
|
||||||
@@ -1965,6 +1967,8 @@ export type ServerConfigDto = {
|
|||||||
mapDarkStyleUrl: string;
|
mapDarkStyleUrl: string;
|
||||||
/** Map light style URL */
|
/** Map light style URL */
|
||||||
mapLightStyleUrl: string;
|
mapLightStyleUrl: string;
|
||||||
|
/** People min faces server default */
|
||||||
|
minFaces: number;
|
||||||
/** OAuth button text */
|
/** OAuth button text */
|
||||||
oauthButtonText: string;
|
oauthButtonText: string;
|
||||||
/** Whether public user registration is enabled */
|
/** Whether public user registration is enabled */
|
||||||
|
|||||||
Generated
+33
-109
@@ -695,8 +695,8 @@ importers:
|
|||||||
specifier: ^13.15.2
|
specifier: ^13.15.2
|
||||||
version: 13.15.10
|
version: 13.15.10
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^3.0.0
|
specifier: ^4.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))
|
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))
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^10.0.0
|
specifier: ^10.0.0
|
||||||
version: 10.4.0(jiti@2.7.0)
|
version: 10.4.0(jiti@2.7.0)
|
||||||
@@ -734,8 +734,8 @@ importers:
|
|||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.4.19(tsx@4.22.3)(yaml@2.9.0)
|
version: 3.4.19(tsx@4.22.3)(yaml@2.9.0)
|
||||||
testcontainers:
|
testcontainers:
|
||||||
specifier: ^11.0.0
|
specifier: ^12.0.0
|
||||||
version: 11.14.0
|
version: 12.0.1
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
@@ -928,7 +928,7 @@ importers:
|
|||||||
version: 14.6.1(@testing-library/dom@10.4.1)
|
version: 14.6.1(@testing-library/dom@10.4.1)
|
||||||
'@trivago/prettier-plugin-sort-imports':
|
'@trivago/prettier-plugin-sort-imports':
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
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))
|
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))
|
||||||
'@types/chromecast-caf-sender':
|
'@types/chromecast-caf-sender':
|
||||||
specifier: ^1.0.11
|
specifier: ^1.0.11
|
||||||
version: 1.0.11
|
version: 1.0.11
|
||||||
@@ -984,8 +984,8 @@ importers:
|
|||||||
specifier: ^4.1.1
|
specifier: ^4.1.1
|
||||||
version: 4.2.0(prettier@3.8.3)
|
version: 4.2.0(prettier@3.8.3)
|
||||||
prettier-plugin-svelte:
|
prettier-plugin-svelte:
|
||||||
specifier: ^3.3.3
|
specifier: ^4.0.0
|
||||||
version: 3.5.2(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
version: 4.1.0(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
||||||
rollup-plugin-visualizer:
|
rollup-plugin-visualizer:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.0.1(rolldown@1.0.1)(rollup@4.60.4)
|
version: 7.0.1(rolldown@1.0.1)(rollup@4.60.4)
|
||||||
@@ -1110,10 +1110,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
'@ampproject/remapping@2.3.0':
|
|
||||||
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
|
||||||
engines: {node: '>=6.0.0'}
|
|
||||||
|
|
||||||
'@angular-devkit/core@19.2.24':
|
'@angular-devkit/core@19.2.24':
|
||||||
resolution: {integrity: sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==}
|
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'}
|
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'}
|
||||||
@@ -3379,10 +3375,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
|
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
|
||||||
engines: {node: '>=18.0.0'}
|
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':
|
'@jest/schemas@29.6.3':
|
||||||
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
|
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
|
||||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||||
@@ -5692,15 +5684,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
valibot: ^1.4.0
|
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':
|
'@vitest/coverage-v8@4.1.7':
|
||||||
resolution: {integrity: sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==}
|
resolution: {integrity: sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -6051,9 +6034,6 @@ packages:
|
|||||||
ast-metadata-inferer@0.8.1:
|
ast-metadata-inferer@0.8.1:
|
||||||
resolution: {integrity: sha512-ht3Dm6Zr7SXv6t1Ra6gFo0+kLDglHGrEbYihTkcycrbHw7WCcuhBzPlJYHEsIpycaUwzsJHje+vUcxXUX4ztTA==}
|
resolution: {integrity: sha512-ht3Dm6Zr7SXv6t1Ra6gFo0+kLDglHGrEbYihTkcycrbHw7WCcuhBzPlJYHEsIpycaUwzsJHje+vUcxXUX4ztTA==}
|
||||||
|
|
||||||
ast-v8-to-istanbul@0.3.12:
|
|
||||||
resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==}
|
|
||||||
|
|
||||||
ast-v8-to-istanbul@1.0.0:
|
ast-v8-to-istanbul@1.0.0:
|
||||||
resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==}
|
resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==}
|
||||||
|
|
||||||
@@ -7312,9 +7292,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==}
|
resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==}
|
||||||
engines: {node: '>= 8.0'}
|
engines: {node: '>= 8.0'}
|
||||||
|
|
||||||
dockerode@4.0.12:
|
dockerode@5.0.0:
|
||||||
resolution: {integrity: sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==}
|
resolution: {integrity: sha512-C52mvJ+7lcyhWNfrzVfFsbTrBfy/ezE9FGEYLpu17FUeBcCkxERk9nN7uDl/478ynDiQ4U+5DbQC2vENHkVEtQ==}
|
||||||
engines: {node: '>= 8.0'}
|
engines: {node: '>= 14.17'}
|
||||||
|
|
||||||
docusaurus-lunr-search@3.6.0:
|
docusaurus-lunr-search@3.6.0:
|
||||||
resolution: {integrity: sha512-CCEAnj5e67sUZmIb2hOl4xb4nDN07fb0fvRDDmdWlYpUvyS1CSKbw4lsGInLyUFEEEBzxQmT6zaVQdF/8Zretg==}
|
resolution: {integrity: sha512-CCEAnj5e67sUZmIb2hOl4xb4nDN07fb0fvRDDmdWlYpUvyS1CSKbw4lsGInLyUFEEEBzxQmT6zaVQdF/8Zretg==}
|
||||||
@@ -8724,10 +8704,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
|
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
|
||||||
engines: {node: '>=10'}
|
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:
|
istanbul-reports@3.2.0:
|
||||||
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
|
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -9178,9 +9154,6 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
magicast@0.3.5:
|
|
||||||
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
|
|
||||||
|
|
||||||
magicast@0.5.3:
|
magicast@0.5.3:
|
||||||
resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==}
|
resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==}
|
||||||
|
|
||||||
@@ -10705,11 +10678,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
prettier: ^3.0.0
|
prettier: ^3.0.0
|
||||||
|
|
||||||
prettier-plugin-svelte@3.5.2:
|
prettier-plugin-svelte@4.1.0:
|
||||||
resolution: {integrity: sha512-ItFouLvzSFE3ulNl4DKoWM3BGcbDCNVpIyy/Y3F2gC3aNiGLxtFUdffVqO5Z5hhYG+DFT5KULWaxmeFFpdbvaQ==}
|
resolution: {integrity: sha512-YZkhA2Q9oOerFFG9tq+2f98WYT7Z2JgrybJrAyrB78jpsH9i/DdgplXemehuFPgsldetFNCcR/yCcYlDjPy94Q==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
prettier: ^3.0.0
|
prettier: ^3.0.0
|
||||||
svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0
|
svelte: ^5.0.0
|
||||||
|
|
||||||
prettier@3.8.3:
|
prettier@3.8.3:
|
||||||
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
|
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
|
||||||
@@ -11882,12 +11856,8 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
test-exclude@7.0.2:
|
testcontainers@12.0.1:
|
||||||
resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==}
|
resolution: {integrity: sha512-EMjjfMNJf3HlL7V3elkxqKUO1r3CtqNBTdmKGwwma/lOtUGfoWvFJ0WQ/KQf1DHEMnRjLWzW4cXbv/Tndsbcbw==}
|
||||||
engines: {node: '>=18'}
|
|
||||||
|
|
||||||
testcontainers@11.14.0:
|
|
||||||
resolution: {integrity: sha512-r9pniwv/iwzyHaI7gwAvAm4Y+IvjJg3vBWdjrUCaDMc2AXIr4jKbq7jJO18Mw2ybs73pZy1Aj7p/4RVBGMRWjg==}
|
|
||||||
|
|
||||||
text-decoder@1.2.7:
|
text-decoder@1.2.7:
|
||||||
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
|
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
|
||||||
@@ -11977,8 +11947,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==}
|
resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
tmp@0.2.5:
|
tmp@0.2.7:
|
||||||
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
|
resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==}
|
||||||
engines: {node: '>=14.14'}
|
engines: {node: '>=14.14'}
|
||||||
|
|
||||||
to-regex-range@5.0.1:
|
to-regex-range@5.0.1:
|
||||||
@@ -12319,11 +12289,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-6S5mCapmzcxetOD/2UEjL0GF5e4+gB07Dh8qs63xylw5ay4XuyW6iQs70FOJo/puf10LCkvhp4jYMQSDUBYEFg==}
|
resolution: {integrity: sha512-6S5mCapmzcxetOD/2UEjL0GF5e4+gB07Dh8qs63xylw5ay4XuyW6iQs70FOJo/puf10LCkvhp4jYMQSDUBYEFg==}
|
||||||
engines: {node: '>=10.0.0'}
|
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:
|
uuid@14.0.0:
|
||||||
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
|
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -13026,11 +12991,6 @@ snapshots:
|
|||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@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)':
|
'@angular-devkit/core@19.2.24(chokidar@4.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
ajv: 8.18.0
|
ajv: 8.18.0
|
||||||
@@ -16082,8 +16042,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
minipass: 7.1.3
|
minipass: 7.1.3
|
||||||
|
|
||||||
'@istanbuljs/schema@0.1.6': {}
|
|
||||||
|
|
||||||
'@jest/schemas@29.6.3':
|
'@jest/schemas@29.6.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sinclair/typebox': 0.27.10
|
'@sinclair/typebox': 0.27.10
|
||||||
@@ -17825,7 +17783,7 @@ snapshots:
|
|||||||
|
|
||||||
'@tokenizer/token@0.3.0': {}
|
'@tokenizer/token@0.3.0': {}
|
||||||
|
|
||||||
'@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))':
|
'@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))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/generator': 7.29.1
|
'@babel/generator': 7.29.1
|
||||||
'@babel/parser': 7.29.3
|
'@babel/parser': 7.29.3
|
||||||
@@ -17837,7 +17795,7 @@ snapshots:
|
|||||||
parse-imports-exports: 0.2.4
|
parse-imports-exports: 0.2.4
|
||||||
prettier: 3.8.3
|
prettier: 3.8.3
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
prettier-plugin-svelte: 3.5.2(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
prettier-plugin-svelte: 4.1.0(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
||||||
svelte: 5.55.8(@typescript-eslint/types@8.59.4)
|
svelte: 5.55.8(@typescript-eslint/types@8.59.4)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -18520,24 +18478,19 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
valibot: 1.4.0(typescript@6.0.3)
|
valibot: 1.4.0(typescript@6.0.3)
|
||||||
|
|
||||||
'@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))':
|
'@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))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ampproject/remapping': 2.3.0
|
|
||||||
'@bcoe/v8-coverage': 1.0.2
|
'@bcoe/v8-coverage': 1.0.2
|
||||||
ast-v8-to-istanbul: 0.3.12
|
'@vitest/utils': 4.1.7
|
||||||
debug: 4.4.3
|
ast-v8-to-istanbul: 1.0.0
|
||||||
istanbul-lib-coverage: 3.2.2
|
istanbul-lib-coverage: 3.2.2
|
||||||
istanbul-lib-report: 3.0.1
|
istanbul-lib-report: 3.0.1
|
||||||
istanbul-lib-source-maps: 5.0.6
|
|
||||||
istanbul-reports: 3.2.0
|
istanbul-reports: 3.2.0
|
||||||
magic-string: 0.30.21
|
magicast: 0.5.3
|
||||||
magicast: 0.3.5
|
obug: 2.1.1
|
||||||
std-env: 3.10.0
|
std-env: 4.1.0
|
||||||
test-exclude: 7.0.2
|
tinyrainbow: 3.1.0
|
||||||
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)
|
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)':
|
'@vitest/coverage-v8@4.1.7(vitest@4.1.7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -18951,12 +18904,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@mdn/browser-compat-data': 5.7.6
|
'@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:
|
ast-v8-to-istanbul@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
@@ -20228,7 +20175,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
dockerode@4.0.12:
|
dockerode@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@balena/dockerignore': 1.0.2
|
'@balena/dockerignore': 1.0.2
|
||||||
'@grpc/grpc-js': 1.14.3
|
'@grpc/grpc-js': 1.14.3
|
||||||
@@ -20236,7 +20183,6 @@ snapshots:
|
|||||||
docker-modem: 5.0.7
|
docker-modem: 5.0.7
|
||||||
protobufjs: 7.6.0
|
protobufjs: 7.6.0
|
||||||
tar-fs: 2.1.4
|
tar-fs: 2.1.4
|
||||||
uuid: 10.0.0
|
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -22004,14 +21950,6 @@ snapshots:
|
|||||||
make-dir: 4.0.0
|
make-dir: 4.0.0
|
||||||
supports-color: 7.2.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:
|
istanbul-reports@3.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
html-escaper: 2.0.2
|
html-escaper: 2.0.2
|
||||||
@@ -22429,12 +22367,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@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:
|
magicast@0.5.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/parser': 7.29.3
|
'@babel/parser': 7.29.3
|
||||||
@@ -24305,7 +24237,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
prettier: 3.8.3
|
prettier: 3.8.3
|
||||||
|
|
||||||
prettier-plugin-svelte@3.5.2(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4)):
|
prettier-plugin-svelte@4.1.0(prettier@3.8.3)(svelte@5.55.8(@typescript-eslint/types@8.59.4)):
|
||||||
dependencies:
|
dependencies:
|
||||||
prettier: 3.8.3
|
prettier: 3.8.3
|
||||||
svelte: 5.55.8(@typescript-eslint/types@8.59.4)
|
svelte: 5.55.8(@typescript-eslint/types@8.59.4)
|
||||||
@@ -25848,13 +25780,7 @@ snapshots:
|
|||||||
commander: 2.20.3
|
commander: 2.20.3
|
||||||
source-map-support: 0.5.21
|
source-map-support: 0.5.21
|
||||||
|
|
||||||
test-exclude@7.0.2:
|
testcontainers@12.0.1:
|
||||||
dependencies:
|
|
||||||
'@istanbuljs/schema': 0.1.6
|
|
||||||
glob: 10.5.0
|
|
||||||
minimatch: 10.2.5
|
|
||||||
|
|
||||||
testcontainers@11.14.0:
|
|
||||||
dependencies:
|
dependencies:
|
||||||
'@balena/dockerignore': 1.0.2
|
'@balena/dockerignore': 1.0.2
|
||||||
'@types/dockerode': 4.0.1
|
'@types/dockerode': 4.0.1
|
||||||
@@ -25863,13 +25789,13 @@ snapshots:
|
|||||||
byline: 5.0.0
|
byline: 5.0.0
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
docker-compose: 1.4.2
|
docker-compose: 1.4.2
|
||||||
dockerode: 4.0.12
|
dockerode: 5.0.0
|
||||||
get-port: 7.2.0
|
get-port: 7.2.0
|
||||||
proper-lockfile: 4.1.2
|
proper-lockfile: 4.1.2
|
||||||
properties-reader: 3.0.1
|
properties-reader: 3.0.1
|
||||||
ssh-remote-port-forward: 1.0.4
|
ssh-remote-port-forward: 1.0.4
|
||||||
tar-fs: 3.1.2
|
tar-fs: 3.1.2
|
||||||
tmp: 0.2.5
|
tmp: 0.2.7
|
||||||
undici: 7.25.0
|
undici: 7.25.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- bare-abort-controller
|
- bare-abort-controller
|
||||||
@@ -25950,7 +25876,7 @@ snapshots:
|
|||||||
tldts-core: 6.1.86
|
tldts-core: 6.1.86
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
tmp@0.2.5: {}
|
tmp@0.2.7: {}
|
||||||
|
|
||||||
to-regex-range@5.0.1:
|
to-regex-range@5.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -26300,8 +26226,6 @@ snapshots:
|
|||||||
- encoding
|
- encoding
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
uuid@10.0.0: {}
|
|
||||||
|
|
||||||
uuid@14.0.0: {}
|
uuid@14.0.0: {}
|
||||||
|
|
||||||
uuid@8.3.2: {}
|
uuid@8.3.2: {}
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM ghcr.io/immich-app/base-server-dev:202605051129@sha256:d07d8fcdb7e9f3ac22a811e87761ebf341ed0bb91956b89097540c2ed3fb9ca3 AS builder
|
FROM ghcr.io/immich-app/base-server-dev:202606021219@sha256:63fa91aa011f6f2921dd32fe6d1be8d637e9bd7f3e3dd0c8e446afb31b282af4 AS builder
|
||||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||||
CI=1 \
|
CI=1 \
|
||||||
COREPACK_HOME=/tmp \
|
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 \
|
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||||
mise //:plugins
|
mise //:plugins
|
||||||
|
|
||||||
FROM ghcr.io/immich-app/base-server-prod:202605051129@sha256:50f7ffe4ed31e360c90c4905bd5f6658f2a121297544e3fe9368e338b3f76bcd
|
FROM ghcr.io/immich-app/base-server-prod:202606021219@sha256:6ef9ef5859492149af770a6c884b5e2ddbaeef99f8885ea5f2d9f73625a3d9ec
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# dev build
|
# dev build
|
||||||
FROM ghcr.io/immich-app/base-server-dev:202605051129@sha256:d07d8fcdb7e9f3ac22a811e87761ebf341ed0bb91956b89097540c2ed3fb9ca3 AS dev
|
FROM ghcr.io/immich-app/base-server-dev:202606021219@sha256:63fa91aa011f6f2921dd32fe6d1be8d637e9bd7f3e3dd0c8e446afb31b282af4 AS dev
|
||||||
|
|
||||||
|
|
||||||
COPY --from=ghcr.io/jdx/mise:2026.5.18@sha256:5bb3311994fa78cef307ca3077cdb18f9551da0886371fc26ea91ab56220ffc5 /usr/local/bin/mise /usr/local/bin/mise
|
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/supertest": "^7.0.0",
|
||||||
"@types/ua-parser-js": "^0.7.36",
|
"@types/ua-parser-js": "^0.7.36",
|
||||||
"@types/validator": "^13.15.2",
|
"@types/validator": "^13.15.2",
|
||||||
"@vitest/coverage-v8": "^3.0.0",
|
"@vitest/coverage-v8": "^4.0.0",
|
||||||
"eslint": "^10.0.0",
|
"eslint": "^10.0.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
"sql-formatter": "^15.0.0",
|
"sql-formatter": "^15.0.0",
|
||||||
"supertest": "^7.1.0",
|
"supertest": "^7.1.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"testcontainers": "^11.0.0",
|
"testcontainers": "^12.0.0",
|
||||||
"typescript": "^6.0.0",
|
"typescript": "^6.0.0",
|
||||||
"typescript-eslint": "^8.28.0",
|
"typescript-eslint": "^8.28.0",
|
||||||
"unplugin-swc": "^1.4.5",
|
"unplugin-swc": "^1.4.5",
|
||||||
|
|||||||
@@ -48,10 +48,6 @@ const AssetMediaCreateSchema = AssetMediaBaseSchema.extend({
|
|||||||
isFavorite: stringToBool.optional().describe('Mark as favorite'),
|
isFavorite: stringToBool.optional().describe('Mark as favorite'),
|
||||||
visibility: AssetVisibilitySchema.optional(),
|
visibility: AssetVisibilitySchema.optional(),
|
||||||
livePhotoVideoId: z.uuidv4().optional().describe('Live photo video ID'),
|
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'),
|
metadata: JsonParsed.pipe(z.array(AssetMetadataUpsertItemSchema)).optional().describe('Asset metadata items'),
|
||||||
[UploadFieldName.SIDECAR_DATA]: z
|
[UploadFieldName.SIDECAR_DATA]: z
|
||||||
.any()
|
.any()
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ const ServerConfigSchema = z
|
|||||||
mapDarkStyleUrl: z.string().describe('Map dark style URL'),
|
mapDarkStyleUrl: z.string().describe('Map dark style URL'),
|
||||||
mapLightStyleUrl: z.string().describe('Map light style URL'),
|
mapLightStyleUrl: z.string().describe('Map light style URL'),
|
||||||
maintenanceMode: z.boolean().describe('Whether maintenance mode is active'),
|
maintenanceMode: z.boolean().describe('Whether maintenance mode is active'),
|
||||||
|
minFaces: z.int().describe('People min faces server default'),
|
||||||
})
|
})
|
||||||
.meta({ id: 'ServerConfigDto' });
|
.meta({ id: 'ServerConfigDto' });
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const PeopleUpdateSchema = z
|
|||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional().describe('Whether people are enabled'),
|
enabled: z.boolean().optional().describe('Whether people are enabled'),
|
||||||
sidebarWeb: z.boolean().optional().describe('Whether people appear in web sidebar'),
|
sidebarWeb: z.boolean().optional().describe('Whether people appear in web sidebar'),
|
||||||
|
minimumFaces: z.int().min(1).optional().describe('People face threshold'),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.meta({ id: 'PeopleUpdate' });
|
.meta({ id: 'PeopleUpdate' });
|
||||||
@@ -138,6 +139,7 @@ const PeopleResponseSchema = z
|
|||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().describe('Whether people are enabled'),
|
enabled: z.boolean().describe('Whether people are enabled'),
|
||||||
sidebarWeb: z.boolean().describe('Whether people appear in web sidebar'),
|
sidebarWeb: z.boolean().describe('Whether people appear in web sidebar'),
|
||||||
|
minimumFaces: z.int().min(1).optional().describe('People face threshold'),
|
||||||
})
|
})
|
||||||
.meta({ id: 'PeopleResponse' });
|
.meta({ id: 'PeopleResponse' });
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,18 @@ group by
|
|||||||
having
|
having
|
||||||
(
|
(
|
||||||
"person"."name" != $3
|
"person"."name" != $3
|
||||||
or count("asset_face"."assetId") >= $4
|
or count("asset_face"."assetId") >= COALESCE(
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
value -> 'people' ->> 'minimumFaces'
|
||||||
|
FROM
|
||||||
|
user_metadata
|
||||||
|
WHERE
|
||||||
|
"userId" = $4
|
||||||
|
AND key = 'preferences'
|
||||||
|
),
|
||||||
|
'3'
|
||||||
|
)::int
|
||||||
)
|
)
|
||||||
order by
|
order by
|
||||||
"person"."isHidden" asc,
|
"person"."isHidden" asc,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
|||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { AssetFace } from 'src/database';
|
import { AssetFace } from 'src/database';
|
||||||
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { AssetFileType, AssetVisibility, SourceType } from 'src/enum';
|
import { AssetFileType, AssetVisibility, SourceType, UserMetadataKey } from 'src/enum';
|
||||||
import { DB } from 'src/schema';
|
import { DB } from 'src/schema';
|
||||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||||
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
||||||
@@ -13,7 +13,6 @@ import { dummy, removeUndefinedKeys, withFilePath } from 'src/utils/database';
|
|||||||
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
|
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
|
||||||
|
|
||||||
export interface PersonSearchOptions {
|
export interface PersonSearchOptions {
|
||||||
minimumFaceCount: number;
|
|
||||||
withHidden: boolean;
|
withHidden: boolean;
|
||||||
closestFaceAssetId?: string;
|
closestFaceAssetId?: string;
|
||||||
}
|
}
|
||||||
@@ -168,7 +167,17 @@ export class PersonRepository {
|
|||||||
.having((eb) =>
|
.having((eb) =>
|
||||||
eb.or([
|
eb.or([
|
||||||
eb('person.name', '!=', ''),
|
eb('person.name', '!=', ''),
|
||||||
eb((innerEb) => innerEb.fn.count('asset_face.assetId'), '>=', options?.minimumFaceCount || 1),
|
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 `,
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
.groupBy('person.id')
|
.groupBy('person.id')
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { columns } from 'src/database';
|
|||||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { DB } from 'src/schema';
|
import { DB } from 'src/schema';
|
||||||
import { StackTable } from 'src/schema/tables/stack.table';
|
import { StackTable } from 'src/schema/tables/stack.table';
|
||||||
import { asUuid, isStackPrimaryConstraint, withDefaultVisibility } from 'src/utils/database';
|
import { asUuid, withDefaultVisibility } from 'src/utils/database';
|
||||||
|
|
||||||
export interface StackSearch {
|
export interface StackSearch {
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
@@ -124,63 +124,6 @@ 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] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
async delete(id: string): Promise<void> {
|
async delete(id: string): Promise<void> {
|
||||||
await this.db.deleteFrom('stack').where('id', '=', asUuid(id)).execute();
|
await this.db.deleteFrom('stack').where('id', '=', asUuid(id)).execute();
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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,80 +417,6 @@ describe(AssetMediaService.name, () => {
|
|||||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
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 () => {
|
it('should hide the linked motion asset', async () => {
|
||||||
const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build();
|
const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build();
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
|
|||||||
@@ -140,60 +140,86 @@ export class AssetMediaService extends BaseService {
|
|||||||
|
|
||||||
this.requireQuota(auth, file.size);
|
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) {
|
if (dto.livePhotoVideoId) {
|
||||||
await onBeforeLink(
|
await onBeforeLink(
|
||||||
{ asset: this.assetRepository, event: this.eventRepository },
|
{ asset: this.assetRepository, event: this.eventRepository },
|
||||||
{ userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
|
{ userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 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.assetRepository.create({
|
||||||
const asset = await this.create(auth.user.id, dto, file, sidecarFile, { skipEventEmit: !!dto.stackParentId });
|
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' } });
|
||||||
|
|
||||||
if (auth.sharedLink) {
|
if (auth.sharedLink) {
|
||||||
await this.addToSharedLink(auth.sharedLink, asset.id);
|
await this.addToSharedLink(auth.sharedLink, asset.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.stackParentId) {
|
await this.eventRepository.emit('AssetCreate', { asset, file });
|
||||||
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 };
|
return { id: asset.id, status: AssetMediaStatus.CREATED };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return this.handleUploadError(error, auth, file, sidecarFile, dto.stackParentId);
|
// clean up files
|
||||||
}
|
await this.jobRepository.queue({
|
||||||
}
|
name: JobName.FileDelete,
|
||||||
|
data: { files: [file.originalPath, sidecarFile?.originalPath] },
|
||||||
|
});
|
||||||
|
|
||||||
private async linkToStackParent(
|
// handle duplicates with a success response
|
||||||
ownerId: string,
|
if (isAssetChecksumConstraint(error)) {
|
||||||
newAssetId: string,
|
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
|
||||||
parentId: string,
|
if (!duplicateId) {
|
||||||
): Promise<{ stackId: string; created: boolean } | null> {
|
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||||
const result = await this.stackRepository.linkAsset(ownerId, newAssetId, parentId);
|
throw new InternalServerErrorException();
|
||||||
if (!result) {
|
}
|
||||||
this.logger.warn(`Could not link asset ${newAssetId} to stack parent ${parentId}: parent missing or not owned`);
|
|
||||||
return null;
|
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;
|
||||||
}
|
}
|
||||||
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> {
|
async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise<ImmichFileResponse> {
|
||||||
@@ -321,99 +347,6 @@ export class AssetMediaService extends BaseService {
|
|||||||
: this.sharedLinkRepository.addAssets(sharedLink.id, [assetId]));
|
: 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) {
|
private requireQuota(auth: AuthDto, size: number) {
|
||||||
if (auth.user.quotaSizeInBytes !== null && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
|
if (auth.user.quotaSizeInBytes !== null && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
|
||||||
throw new BadRequestException('Quota has been exceeded!');
|
throw new BadRequestException('Quota has been exceeded!');
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ describe(PersonService.name, () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
|
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
|
||||||
minimumFaceCount: 3,
|
|
||||||
withHidden: true,
|
withHidden: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -84,7 +83,6 @@ describe(PersonService.name, () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
|
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
|
||||||
minimumFaceCount: 3,
|
|
||||||
withHidden: false,
|
withHidden: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -454,6 +452,30 @@ describe(PersonService.name, () => {
|
|||||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||||
expect(mocks.job.queueAll).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', () => {
|
describe('createNewFeaturePhoto', () => {
|
||||||
|
|||||||
@@ -63,9 +63,7 @@ export class PersonService extends BaseService {
|
|||||||
}
|
}
|
||||||
closestFaceAssetId = person.faceAssetId;
|
closestFaceAssetId = person.faceAssetId;
|
||||||
}
|
}
|
||||||
const { machineLearning } = await this.getConfig({ withCache: false });
|
|
||||||
const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, {
|
const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, {
|
||||||
minimumFaceCount: machineLearning.facialRecognition.minFaces,
|
|
||||||
withHidden,
|
withHidden,
|
||||||
closestFaceAssetId,
|
closestFaceAssetId,
|
||||||
});
|
});
|
||||||
@@ -627,7 +625,7 @@ export class PersonService extends BaseService {
|
|||||||
// TODO return a asset face response
|
// TODO return a asset face response
|
||||||
async createFace(auth: AuthDto, dto: AssetFaceCreateDto): Promise<void> {
|
async createFace(auth: AuthDto, dto: AssetFaceCreateDto): Promise<void> {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.assetId] }),
|
this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [dto.assetId] }),
|
||||||
this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }),
|
this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ describe(ServerService.name, () => {
|
|||||||
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||||
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||||
maintenanceMode: false,
|
maintenanceMode: false,
|
||||||
|
minFaces: 3,
|
||||||
});
|
});
|
||||||
expect(mocks.systemMetadata.get).toHaveBeenCalled();
|
expect(mocks.systemMetadata.get).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export class ServerService extends BaseService {
|
|||||||
mapDarkStyleUrl: config.map.darkStyle,
|
mapDarkStyleUrl: config.map.darkStyle,
|
||||||
mapLightStyleUrl: config.map.lightStyle,
|
mapLightStyleUrl: config.map.lightStyle,
|
||||||
maintenanceMode: false,
|
maintenanceMode: false,
|
||||||
|
minFaces: config.machineLearning.facialRecognition.minFaces,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -539,6 +539,7 @@ export type UserPreferences = {
|
|||||||
people: {
|
people: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
sidebarWeb: boolean;
|
sidebarWeb: boolean;
|
||||||
|
minimumFaces: number;
|
||||||
};
|
};
|
||||||
ratings: {
|
ratings: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@@ -79,12 +79,6 @@ export const isAssetChecksumConstraint = (error: unknown) =>
|
|||||||
export const isVideoStreamSessionPkConstraint = (error: unknown) =>
|
export const isVideoStreamSessionPkConstraint = (error: unknown) =>
|
||||||
(error as PostgresError)?.constraint_name === VIDEO_STREAM_SESSION_PK_CONSTRAINT;
|
(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>) {
|
export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
||||||
return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]);
|
return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const getDefaultPreferences = (): UserPreferences => {
|
|||||||
people: {
|
people: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sidebarWeb: false,
|
sidebarWeb: false,
|
||||||
|
minimumFaces: 3,
|
||||||
},
|
},
|
||||||
sharedLinks: {
|
sharedLinks: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
+1
-1
@@ -103,7 +103,7 @@
|
|||||||
"happy-dom": "^20.0.0",
|
"happy-dom": "^20.0.0",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-sort-json": "^4.1.1",
|
"prettier-plugin-sort-json": "^4.1.1",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
"prettier-plugin-svelte": "^4.0.0",
|
||||||
"rollup-plugin-visualizer": "^7.0.0",
|
"rollup-plugin-visualizer": "^7.0.0",
|
||||||
"svelte": "5.55.8",
|
"svelte": "5.55.8",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.4.6",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
mdiLink,
|
mdiLink,
|
||||||
mdiLockOutline,
|
mdiLockOutline,
|
||||||
mdiMagnify,
|
mdiMagnify,
|
||||||
|
mdiMapMarkerOutline,
|
||||||
mdiMapOutline,
|
mdiMapOutline,
|
||||||
mdiServer,
|
mdiServer,
|
||||||
mdiStateMachine,
|
mdiStateMachine,
|
||||||
@@ -93,6 +94,11 @@ export const getPagesProvider = ($t: MessageFormatter) => {
|
|||||||
onAction: () => goto(Route.people()),
|
onAction: () => goto(Route.people()),
|
||||||
$if: () => authManager.authenticated && authManager.preferences.people.enabled,
|
$if: () => authManager.authenticated && authManager.preferences.people.enabled,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: $t('places'),
|
||||||
|
icon: mdiMapMarkerOutline,
|
||||||
|
onAction: () => goto(Route.places()),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: $t('shared_links'),
|
title: $t('shared_links'),
|
||||||
icon: mdiLink,
|
icon: mdiLink,
|
||||||
|
|||||||
@@ -286,7 +286,11 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</AdaptiveImage>
|
</AdaptiveImage>
|
||||||
|
|
||||||
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef}
|
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef && asset.width && asset.height}
|
||||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
<FaceEditor
|
||||||
|
imageSize={{ width: asset.width, height: asset.height }}
|
||||||
|
containerSize={{ width: containerWidth, height: containerHeight }}
|
||||||
|
assetId={asset.id}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -308,9 +308,31 @@
|
|||||||
let containerHeight = $state(0);
|
let containerHeight = $state(0);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (assetViewerManager.isFaceEditMode) {
|
if (!assetViewerManager.isFaceEditMode || !videoPlayer) {
|
||||||
videoPlayer?.pause();
|
return;
|
||||||
}
|
}
|
||||||
|
videoPlayer.pause();
|
||||||
|
|
||||||
|
const { videoWidth, videoHeight } = videoPlayer;
|
||||||
|
if (videoWidth === 0 || videoHeight === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = videoWidth;
|
||||||
|
canvas.height = videoHeight;
|
||||||
|
canvas.getContext('2d')?.drawImage(videoPlayer, 0, 0);
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
const onImageLoad = () => (assetViewerManager.imgRef = img);
|
||||||
|
img.addEventListener('load', onImageLoad);
|
||||||
|
img.src = canvas.toDataURL('image/png');
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
img.removeEventListener('load', onImageLoad);
|
||||||
|
img.src = '';
|
||||||
|
assetViewerManager.imgRef = undefined;
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// The time is only refreshed on HLS fragment decode by default,
|
// The time is only refreshed on HLS fragment decode by default,
|
||||||
@@ -454,8 +476,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if assetViewerManager.isFaceEditMode && videoPlayer}
|
{#if assetViewerManager.isFaceEditMode}
|
||||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
<FaceEditor
|
||||||
|
imageSize={{ width: asset.width ?? 0, height: asset.height ?? 0 }}
|
||||||
|
containerSize={{ width: containerWidth, height: containerHeight }}
|
||||||
|
{assetId}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import FaceCreateTagModal from '$lib/modals/CreateFaceModal.svelte';
|
import FaceCreateTagModal from '$lib/modals/CreateFaceModal.svelte';
|
||||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
import { computeContentMetrics, mapContentRectToNatural, type Size } from '$lib/utils/container-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||||
@@ -14,13 +14,12 @@
|
|||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
htmlElement: HTMLImageElement | HTMLVideoElement;
|
imageSize: Size;
|
||||||
containerWidth: number;
|
containerSize: Size;
|
||||||
containerHeight: number;
|
|
||||||
assetId: string;
|
assetId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
|
let { imageSize, containerSize, assetId }: Props = $props();
|
||||||
|
|
||||||
let canvasEl: HTMLCanvasElement | undefined = $state();
|
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||||
let canvas: Canvas | undefined = $state();
|
let canvas: Canvas | undefined = $state();
|
||||||
@@ -54,7 +53,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setupCanvas = () => {
|
const setupCanvas = () => {
|
||||||
if (!canvasEl || !htmlElement) {
|
if (!canvasEl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,17 +85,7 @@
|
|||||||
searchInputEl?.focus();
|
searchInputEl?.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageContentMetrics = $derived.by(() => {
|
const imageContentMetrics = $derived(computeContentMetrics(imageSize, containerSize));
|
||||||
const natural = getNaturalSize(htmlElement);
|
|
||||||
const container = { width: containerWidth, height: containerHeight };
|
|
||||||
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container);
|
|
||||||
return {
|
|
||||||
contentWidth,
|
|
||||||
contentHeight,
|
|
||||||
offsetX: (containerWidth - contentWidth) / 2,
|
|
||||||
offsetY: (containerHeight - contentHeight) / 2,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
|
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
|
||||||
const { offsetX, offsetY } = imageContentMetrics;
|
const { offsetX, offsetY } = imageContentMetrics;
|
||||||
@@ -116,8 +105,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
canvas.setDimensions({
|
canvas.setDimensions({
|
||||||
width: containerWidth,
|
width: containerSize.width,
|
||||||
height: containerHeight,
|
height: containerSize.height,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!faceRect) {
|
if (!faceRect) {
|
||||||
@@ -175,11 +164,11 @@
|
|||||||
};
|
};
|
||||||
const selectorWidth = faceSelectorEl.offsetWidth;
|
const selectorWidth = faceSelectorEl.offsetWidth;
|
||||||
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
|
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
|
||||||
const listHeight = Math.min(MAX_LIST_HEIGHT, containerHeight - gap * 2 - chromeHeight);
|
const listHeight = Math.min(MAX_LIST_HEIGHT, containerSize.height - gap * 2 - chromeHeight);
|
||||||
const selectorHeight = listHeight + chromeHeight;
|
const selectorHeight = listHeight + chromeHeight;
|
||||||
|
|
||||||
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
|
const clampTop = (top: number) => clamp(top, gap, containerSize.height - selectorHeight - gap);
|
||||||
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
|
const clampLeft = (left: number) => clamp(left, gap, containerSize.width - selectorWidth - gap);
|
||||||
|
|
||||||
const overlapArea = (position: { top: number; left: number }) => {
|
const overlapArea = (position: { top: number; left: number }) => {
|
||||||
const selectorRight = position.left + selectorWidth;
|
const selectorRight = position.left + selectorWidth;
|
||||||
@@ -238,61 +227,42 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getFaceCroppedCoordinates = () => {
|
const getFaceCroppedCoordinates = () => {
|
||||||
if (!faceRect || !htmlElement) {
|
if (!faceRect || imageContentMetrics.contentWidth === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { left, top, width, height } = faceRect.getBoundingRect();
|
const imageRect = mapContentRectToNatural(faceRect.getBoundingRect(), imageContentMetrics, imageSize);
|
||||||
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
|
|
||||||
const natural = getNaturalSize(htmlElement);
|
|
||||||
|
|
||||||
const scaleX = natural.width / contentWidth;
|
|
||||||
const scaleY = natural.height / contentHeight;
|
|
||||||
const imageX = (left - offsetX) * scaleX;
|
|
||||||
const imageY = (top - offsetY) * scaleY;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
imageWidth: natural.width,
|
imageWidth: imageSize.width,
|
||||||
imageHeight: natural.height,
|
imageHeight: imageSize.height,
|
||||||
x: Math.floor(imageX),
|
x: Math.floor(imageRect.left),
|
||||||
y: Math.floor(imageY),
|
y: Math.floor(imageRect.top),
|
||||||
width: Math.floor(width * scaleX),
|
width: Math.floor(imageRect.width),
|
||||||
height: Math.floor(height * scaleY),
|
height: Math.floor(imageRect.height),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type FaceCoordinates = NonNullable<ReturnType<typeof getFaceCroppedCoordinates>>;
|
type FaceCoordinates = NonNullable<ReturnType<typeof getFaceCroppedCoordinates>>;
|
||||||
|
|
||||||
const getFacePreviewUrl = (data: FaceCoordinates) => {
|
const getFacePreviewUrl = (data: FaceCoordinates) => {
|
||||||
if (!htmlElement) {
|
const imgRef = assetViewerManager.imgRef;
|
||||||
|
if (!imgRef || imageContentMetrics.contentWidth === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const natural = getNaturalSize(htmlElement);
|
const scaleX = imgRef.naturalWidth / imageSize.width;
|
||||||
if (natural.width <= 0 || natural.height <= 0) {
|
const scaleY = imgRef.naturalHeight / imageSize.height;
|
||||||
return;
|
const x = clamp(Math.floor(data.x * scaleX), 0, imgRef.naturalWidth - 1);
|
||||||
}
|
const y = clamp(Math.floor(data.y * scaleY), 0, imgRef.naturalHeight - 1);
|
||||||
|
const width = clamp(Math.floor(data.width * scaleX), 1, imgRef.naturalWidth - x);
|
||||||
const x = clamp(data.x, 0, natural.width - 1);
|
const height = clamp(Math.floor(data.height * scaleY), 1, imgRef.naturalHeight - y);
|
||||||
const y = clamp(data.y, 0, natural.height - 1);
|
|
||||||
const width = clamp(data.width, 1, natural.width - x);
|
|
||||||
const height = clamp(data.height, 1, natural.height - y);
|
|
||||||
|
|
||||||
if (width <= 0 || height <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
|
|
||||||
const context = canvas.getContext('2d');
|
|
||||||
if (!context) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
context.drawImage(htmlElement, x, y, width, height, 0, 0, width, height);
|
canvas.getContext('2d')?.drawImage(imgRef, x, y, width, height, 0, 0, width, height);
|
||||||
return canvas.toDataURL('image/png');
|
return canvas.toDataURL('image/png');
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
let mouseOver = $state(false);
|
let mouseOver = $state(false);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let thumbError = $state(false);
|
let thumbError = $state(false);
|
||||||
|
let skipFade = $state(false);
|
||||||
|
|
||||||
let width = $derived(thumbnailSize || thumbnailWidth || 235);
|
let width = $derived(thumbnailSize || thumbnailWidth || 235);
|
||||||
let height = $derived(thumbnailSize || thumbnailHeight || 235);
|
let height = $derived(thumbnailSize || thumbnailHeight || 235);
|
||||||
@@ -252,7 +253,12 @@
|
|||||||
widthStyle="{width}px"
|
widthStyle="{width}px"
|
||||||
heightStyle="{height}px"
|
heightStyle="{height}px"
|
||||||
curve={selected}
|
curve={selected}
|
||||||
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
|
onComplete={(errored) => {
|
||||||
|
const rect = element?.getBoundingClientRect();
|
||||||
|
skipFade = !rect || rect.bottom < 0 || rect.top > window.innerHeight;
|
||||||
|
loaded = true;
|
||||||
|
thumbError = errored;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{#if asset.isVideo}
|
{#if asset.isVideo}
|
||||||
<div class="pointer-events-none absolute size-full group-focus-visible:rounded-lg">
|
<div class="pointer-events-none absolute size-full group-focus-visible:rounded-lg">
|
||||||
@@ -297,7 +303,10 @@
|
|||||||
<Thumbhash
|
<Thumbhash
|
||||||
base64ThumbHash={asset.thumbhash}
|
base64ThumbHash={asset.thumbhash}
|
||||||
data-testid="thumbhash"
|
data-testid="thumbhash"
|
||||||
class={['absolute top-0 object-cover group-focus-visible:rounded-lg', { 'rounded-xl': selected }]}
|
class={[
|
||||||
|
'absolute top-0 object-cover group-focus-visible:rounded-lg',
|
||||||
|
{ 'rounded-xl': selected, hidden: skipFade },
|
||||||
|
]}
|
||||||
style="width: {width}px; height: {height}px"
|
style="width: {width}px; height: {height}px"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
fadeOut
|
fadeOut
|
||||||
|
|||||||
@@ -22,11 +22,16 @@
|
|||||||
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
|
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
|
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
|
import { TUNABLES } from '$lib/utils/tunables';
|
||||||
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
|
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { modalManager } from '@immich/ui';
|
import { modalManager } from '@immich/ui';
|
||||||
import { debounce } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
const {
|
||||||
|
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||||
|
} = TUNABLES;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
assets: AssetResponseDto[];
|
assets: AssetResponseDto[];
|
||||||
viewerAssets?: AssetResponseDto[];
|
viewerAssets?: AssetResponseDto[];
|
||||||
@@ -34,7 +39,7 @@
|
|||||||
disableAssetSelect?: boolean;
|
disableAssetSelect?: boolean;
|
||||||
showArchiveIcon?: boolean;
|
showArchiveIcon?: boolean;
|
||||||
viewport: Viewport;
|
viewport: Viewport;
|
||||||
onIntersected?: (() => void) | undefined;
|
onEndReached?: (() => void) | undefined;
|
||||||
showAssetName?: boolean;
|
showAssetName?: boolean;
|
||||||
onReload?: (() => void) | undefined;
|
onReload?: (() => void) | undefined;
|
||||||
pageHeaderOffset?: number;
|
pageHeaderOffset?: number;
|
||||||
@@ -50,7 +55,7 @@
|
|||||||
disableAssetSelect = false,
|
disableAssetSelect = false,
|
||||||
showArchiveIcon = false,
|
showArchiveIcon = false,
|
||||||
viewport,
|
viewport,
|
||||||
onIntersected = undefined,
|
onEndReached = undefined,
|
||||||
showAssetName = false,
|
showAssetName = false,
|
||||||
onReload = undefined,
|
onReload = undefined,
|
||||||
slidingWindowOffset = 0,
|
slidingWindowOffset = 0,
|
||||||
@@ -70,24 +75,23 @@
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const getStyle = (i: number) => {
|
const getStyle = (index: number) => {
|
||||||
const geo = geometry;
|
return `top: ${geometry.getTop(index)}px; left: ${geometry.getLeft(index)}px; width: ${geometry.getWidth(index)}px; height: ${geometry.getHeight(index)}px;`;
|
||||||
return `top: ${geo.getTop(i)}px; left: ${geo.getLeft(i)}px; width: ${geo.getWidth(i)}px; height: ${geo.getHeight(i)}px;`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isIntersecting = (i: number) => {
|
const isInOrNearViewport = (index: number) => {
|
||||||
const geo = geometry;
|
|
||||||
const window = slidingWindow;
|
const window = slidingWindow;
|
||||||
const top = geo.getTop(i);
|
const top = geometry.getTop(index);
|
||||||
return top + pageHeaderOffset < window.bottom && top + geo.getHeight(i) > window.top;
|
return top + pageHeaderOffset < window.bottom && top + geometry.getHeight(index) > window.top;
|
||||||
};
|
};
|
||||||
|
|
||||||
let shiftKeyIsDown = $state(false);
|
let shiftKeyIsDown = $state(false);
|
||||||
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
|
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
|
||||||
let scrollTop = $state(0);
|
let scrollTop = $state(0);
|
||||||
|
|
||||||
let slidingWindow = $derived.by(() => {
|
let slidingWindow = $derived.by(() => {
|
||||||
const top = (scrollTop || 0) - slidingWindowOffset;
|
const top = (scrollTop || 0) - slidingWindowOffset - INTERSECTION_EXPAND_TOP;
|
||||||
const bottom = top + viewport.height + slidingWindowOffset;
|
const bottom = top + viewport.height + slidingWindowOffset + INTERSECTION_EXPAND_BOTTOM;
|
||||||
return {
|
return {
|
||||||
top,
|
top,
|
||||||
bottom,
|
bottom,
|
||||||
@@ -101,17 +105,15 @@
|
|||||||
|
|
||||||
const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0);
|
const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0);
|
||||||
|
|
||||||
const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
|
const debouncedOnEndReached = debounce(() => onEndReached?.(), 750, { maxWait: 100, leading: true });
|
||||||
|
|
||||||
let lastIntersectedHeight = 0;
|
let lastEndReachedHeight = 0;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Intersect if there's only one viewport worth of assets left to scroll.
|
|
||||||
if (geometry.containerHeight - slidingWindow.bottom <= viewport.height) {
|
if (geometry.containerHeight - slidingWindow.bottom <= viewport.height) {
|
||||||
// Notify we got to (near) the end of scroll.
|
const contentHeight = geometry.containerHeight;
|
||||||
const intersectedHeight = geometry.containerHeight;
|
if (lastEndReachedHeight !== contentHeight) {
|
||||||
if (lastIntersectedHeight !== intersectedHeight) {
|
debouncedOnEndReached();
|
||||||
debouncedOnIntersected();
|
lastEndReachedHeight = contentHeight;
|
||||||
lastIntersectedHeight = intersectedHeight;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -362,10 +364,10 @@
|
|||||||
style:height={geometry.containerHeight + 'px'}
|
style:height={geometry.containerHeight + 'px'}
|
||||||
style:width={geometry.containerWidth + 'px'}
|
style:width={geometry.containerWidth + 'px'}
|
||||||
>
|
>
|
||||||
{#each assets as asset, i (asset.id + '-' + i)}
|
{#each assets as asset, index (asset.id + '-' + index)}
|
||||||
{#if isIntersecting(i)}
|
{#if isInOrNearViewport(index)}
|
||||||
{@const currentAsset = toTimelineAsset(asset)}
|
{@const currentAsset = toTimelineAsset(asset)}
|
||||||
<div class="absolute" style:overflow="clip" style={getStyle(i)}>
|
<div class="absolute" style:overflow="clip" style={getStyle(index)}>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
readonly={disableAssetSelect}
|
readonly={disableAssetSelect}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -382,8 +384,8 @@
|
|||||||
asset={currentAsset}
|
asset={currentAsset}
|
||||||
selected={assetInteraction.hasSelectedAsset(currentAsset.id)}
|
selected={assetInteraction.hasSelectedAsset(currentAsset.id)}
|
||||||
selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)}
|
selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)}
|
||||||
thumbnailWidth={geometry.getWidth(i)}
|
thumbnailWidth={geometry.getWidth(index)}
|
||||||
thumbnailHeight={geometry.getHeight(i)}
|
thumbnailHeight={geometry.getHeight(index)}
|
||||||
/>
|
/>
|
||||||
{#if showAssetName && !isTimelineAsset(asset)}
|
{#if showAssetName && !isTimelineAsset(asset)}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
getContentMetrics,
|
computeContentMetrics,
|
||||||
getNaturalSize,
|
getNaturalSize,
|
||||||
|
mapContentRectToNatural,
|
||||||
mapNormalizedRectToContent,
|
mapNormalizedRectToContent,
|
||||||
mapNormalizedToContent,
|
mapNormalizedToContent,
|
||||||
scaleToCover,
|
scaleToCover,
|
||||||
scaleToFit,
|
scaleToFit,
|
||||||
} from '$lib/utils/container-utils';
|
} from '$lib/utils/container-utils';
|
||||||
|
|
||||||
const mockImage = (props: {
|
const mockImage = (props: { naturalWidth: number; naturalHeight: number }): HTMLImageElement =>
|
||||||
naturalWidth: number;
|
props as unknown as HTMLImageElement;
|
||||||
naturalHeight: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}): HTMLImageElement => props as unknown as HTMLImageElement;
|
|
||||||
|
|
||||||
const mockVideo = (props: {
|
const mockVideo = (props: {
|
||||||
videoWidth: number;
|
videoWidth: number;
|
||||||
@@ -49,48 +46,85 @@ describe('scaleToFit', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getContentMetrics', () => {
|
describe('computeContentMetrics', () => {
|
||||||
it('should compute zero offsets when aspect ratios match', () => {
|
it('should return zero metrics for zero-width content', () => {
|
||||||
const img = mockImage({ naturalWidth: 1600, naturalHeight: 900, width: 800, height: 450 });
|
expect(computeContentMetrics({ width: 0, height: 1080 }, { width: 800, height: 600 })).toEqual({
|
||||||
expect(getContentMetrics(img)).toEqual({
|
contentWidth: 0,
|
||||||
|
contentHeight: 0,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return zero metrics for zero-height content', () => {
|
||||||
|
expect(computeContentMetrics({ width: 1920, height: 0 }, { width: 800, height: 600 })).toEqual({
|
||||||
|
contentWidth: 0,
|
||||||
|
contentHeight: 0,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should center wide content vertically', () => {
|
||||||
|
expect(computeContentMetrics({ width: 2000, height: 1000 }, { width: 800, height: 600 })).toEqual({
|
||||||
|
contentWidth: 800,
|
||||||
|
contentHeight: 400,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should center tall content horizontally', () => {
|
||||||
|
expect(computeContentMetrics({ width: 1000, height: 2000 }, { width: 800, height: 600 })).toEqual({
|
||||||
|
contentWidth: 300,
|
||||||
|
contentHeight: 600,
|
||||||
|
offsetX: 250,
|
||||||
|
offsetY: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce zero offsets when aspect ratios match', () => {
|
||||||
|
expect(computeContentMetrics({ width: 1600, height: 900 }, { width: 800, height: 450 })).toEqual({
|
||||||
contentWidth: 800,
|
contentWidth: 800,
|
||||||
contentHeight: 450,
|
contentHeight: 450,
|
||||||
offsetX: 0,
|
offsetX: 0,
|
||||||
offsetY: 0,
|
offsetY: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should compute horizontal letterbox offsets for tall image', () => {
|
describe('mapContentRectToNatural', () => {
|
||||||
const img = mockImage({ naturalWidth: 1000, naturalHeight: 2000, width: 800, height: 600 });
|
it('should map a full-content rect back to natural size', () => {
|
||||||
const metrics = getContentMetrics(img);
|
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
|
||||||
expect(metrics.contentWidth).toBe(300);
|
const rect = mapContentRectToNatural({ left: 0, top: 100, width: 800, height: 400 }, metrics, {
|
||||||
expect(metrics.contentHeight).toBe(600);
|
width: 2000,
|
||||||
expect(metrics.offsetX).toBe(250);
|
height: 1000,
|
||||||
expect(metrics.offsetY).toBe(0);
|
});
|
||||||
|
expect(rect).toEqual({ left: 0, top: 0, width: 2000, height: 1000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should compute vertical letterbox offsets for wide image', () => {
|
it('should map a centered sub-rect to natural coordinates', () => {
|
||||||
const img = mockImage({ naturalWidth: 2000, naturalHeight: 1000, width: 800, height: 600 });
|
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
|
||||||
const metrics = getContentMetrics(img);
|
const rect = mapContentRectToNatural({ left: 200, top: 200, width: 400, height: 200 }, metrics, {
|
||||||
expect(metrics.contentWidth).toBe(800);
|
width: 2000,
|
||||||
expect(metrics.contentHeight).toBe(400);
|
height: 1000,
|
||||||
expect(metrics.offsetX).toBe(0);
|
});
|
||||||
expect(metrics.offsetY).toBe(100);
|
expect(rect).toEqual({ left: 500, top: 250, width: 1000, height: 500 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use clientWidth/clientHeight for video elements', () => {
|
it('should handle letterboxed content with horizontal offset', () => {
|
||||||
const video = mockVideo({ videoWidth: 1920, videoHeight: 1080, clientWidth: 800, clientHeight: 600 });
|
const metrics = { contentWidth: 300, contentHeight: 600, offsetX: 250, offsetY: 0 };
|
||||||
const metrics = getContentMetrics(video);
|
const rect = mapContentRectToNatural({ left: 250, top: 0, width: 300, height: 600 }, metrics, {
|
||||||
expect(metrics.contentWidth).toBe(800);
|
width: 1000,
|
||||||
expect(metrics.contentHeight).toBe(450);
|
height: 2000,
|
||||||
expect(metrics.offsetX).toBe(0);
|
});
|
||||||
expect(metrics.offsetY).toBe(75);
|
expect(rect).toEqual({ left: 0, top: 0, width: 1000, height: 2000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getNaturalSize', () => {
|
describe('getNaturalSize', () => {
|
||||||
it('should return naturalWidth/naturalHeight for images', () => {
|
it('should return naturalWidth/naturalHeight for images', () => {
|
||||||
const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000, width: 800, height: 600 });
|
const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000 });
|
||||||
expect(getNaturalSize(img)).toEqual({ width: 4000, height: 3000 });
|
expect(getNaturalSize(img)).toEqual({ width: 4000, height: 3000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -49,13 +49,6 @@ export const scaleToFit = (dimensions: Size, container: Size): Size => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getElementSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
|
|
||||||
if (element instanceof HTMLVideoElement) {
|
|
||||||
return { width: element.clientWidth, height: element.clientHeight };
|
|
||||||
}
|
|
||||||
return { width: element.width, height: element.height };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
|
export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
|
||||||
if (element instanceof HTMLVideoElement) {
|
if (element instanceof HTMLVideoElement) {
|
||||||
return { width: element.videoWidth, height: element.videoHeight };
|
return { width: element.videoWidth, height: element.videoHeight };
|
||||||
@@ -63,17 +56,18 @@ export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Si
|
|||||||
return { width: element.naturalWidth, height: element.naturalHeight };
|
return { width: element.naturalWidth, height: element.naturalHeight };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => {
|
export function computeContentMetrics(imageSize: Size, containerSize: Size): ContentMetrics {
|
||||||
const natural = getNaturalSize(element);
|
if (imageSize.width === 0 || imageSize.height === 0) {
|
||||||
const client = getElementSize(element);
|
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
||||||
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, client);
|
}
|
||||||
|
const { width: contentWidth, height: contentHeight } = scaleToFit(imageSize, containerSize);
|
||||||
return {
|
return {
|
||||||
contentWidth,
|
contentWidth,
|
||||||
contentHeight,
|
contentHeight,
|
||||||
offsetX: (client.width - contentWidth) / 2,
|
offsetX: (containerSize.width - contentWidth) / 2,
|
||||||
offsetY: (client.height - contentHeight) / 2,
|
offsetY: (containerSize.height - contentHeight) / 2,
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
|
export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
|
||||||
if ('contentWidth' in sizeOrMetrics) {
|
if ('contentWidth' in sizeOrMetrics) {
|
||||||
@@ -109,3 +103,25 @@ export function mapNormalizedRectToContent(
|
|||||||
height: br.y - tl.y,
|
height: br.y - tl.y,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mapContentToNatural(point: Point, metrics: ContentMetrics, naturalSize: Size): Point {
|
||||||
|
return {
|
||||||
|
x: ((point.x - metrics.offsetX) / metrics.contentWidth) * naturalSize.width,
|
||||||
|
y: ((point.y - metrics.offsetY) / metrics.contentHeight) * naturalSize.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapContentRectToNatural(rect: Rect, metrics: ContentMetrics, naturalSize: Size): Rect {
|
||||||
|
const topLeft = mapContentToNatural({ x: rect.left, y: rect.top }, metrics, naturalSize);
|
||||||
|
const bottomRight = mapContentToNatural(
|
||||||
|
{ x: rect.left + rect.width, y: rect.top + rect.height },
|
||||||
|
metrics,
|
||||||
|
naturalSize,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
top: topLeft.y,
|
||||||
|
left: topLeft.x,
|
||||||
|
width: bottomRight.x - topLeft.x,
|
||||||
|
height: bottomRight.y - topLeft.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -309,7 +309,7 @@
|
|||||||
<GalleryViewer
|
<GalleryViewer
|
||||||
assets={searchResultAssets}
|
assets={searchResultAssets}
|
||||||
assetInteraction={assetMultiSelectManager}
|
assetInteraction={assetMultiSelectManager}
|
||||||
onIntersected={loadNextPage}
|
onEndReached={loadNextPage}
|
||||||
showArchiveIcon={true}
|
showArchiveIcon={true}
|
||||||
{viewport}
|
{viewport}
|
||||||
onReload={onSearchQueryUpdate}
|
onReload={onSearchQueryUpdate}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||||
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
|
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
// People
|
// People
|
||||||
let peopleEnabled = $state(authManager.preferences.people?.enabled ?? false);
|
let peopleEnabled = $state(authManager.preferences.people?.enabled ?? false);
|
||||||
let peopleSidebar = $state(authManager.preferences.people?.sidebarWeb ?? false);
|
let peopleSidebar = $state(authManager.preferences.people?.sidebarWeb ?? false);
|
||||||
|
let peopleMinFaces = $state(authManager.preferences.people?.minimumFaces ?? serverConfigManager.value.minFaces);
|
||||||
|
|
||||||
// Ratings
|
// Ratings
|
||||||
let ratingsEnabled = $state(authManager.preferences.ratings?.enabled ?? false);
|
let ratingsEnabled = $state(authManager.preferences.ratings?.enabled ?? false);
|
||||||
@@ -43,7 +45,7 @@
|
|||||||
albums: { defaultAssetOrder },
|
albums: { defaultAssetOrder },
|
||||||
folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar },
|
folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar },
|
||||||
memories: { enabled: memoriesEnabled, duration: memoriesDuration },
|
memories: { enabled: memoriesEnabled, duration: memoriesDuration },
|
||||||
people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar },
|
people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar, minimumFaces: peopleMinFaces },
|
||||||
ratings: { enabled: ratingsEnabled },
|
ratings: { enabled: ratingsEnabled },
|
||||||
sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar },
|
sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar },
|
||||||
tags: { enabled: tagsEnabled, sidebarWeb: tagsSidebar },
|
tags: { enabled: tagsEnabled, sidebarWeb: tagsSidebar },
|
||||||
@@ -117,6 +119,9 @@
|
|||||||
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
|
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
|
||||||
<Switch bind:checked={peopleSidebar} />
|
<Switch bind:checked={peopleSidebar} />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label={$t('minFaces')} description={$t('minFaces_description')}>
|
||||||
|
<NumberInput bind:value={peopleMinFaces} />
|
||||||
|
</Field>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
|
|||||||
@@ -38,6 +38,8 @@
|
|||||||
withCoordinates: true,
|
withCoordinates: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isOwnAsset = (asset: TimelineAsset) => asset.ownerId === authManager.user.id;
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
const handleUpdate = async () => {
|
||||||
if (!point) {
|
if (!point) {
|
||||||
return;
|
return;
|
||||||
@@ -54,7 +56,7 @@
|
|||||||
|
|
||||||
await updateAssets({
|
await updateAssets({
|
||||||
assetBulkUpdateDto: {
|
assetBulkUpdateDto: {
|
||||||
ids: assetMultiSelectManager.assets.map((asset) => asset.id),
|
ids: assetMultiSelectManager.assets.filter((asset) => isOwnAsset(asset)).map((asset) => asset.id),
|
||||||
latitude: point.lat,
|
latitude: point.lat,
|
||||||
longitude: point.lng,
|
longitude: point.lng,
|
||||||
},
|
},
|
||||||
@@ -124,7 +126,7 @@
|
|||||||
}, 1500);
|
}, 1500);
|
||||||
point = { lat: asset.latitude, lng: asset.longitude };
|
point = { lat: asset.latitude, lng: asset.longitude };
|
||||||
void setQueryValue('at', asset.id);
|
void setQueryValue('at', asset.id);
|
||||||
} else {
|
} else if (isOwnAsset(asset)) {
|
||||||
onClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
|
onClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -199,6 +201,9 @@
|
|||||||
onThumbnailClick={handleThumbnailClick}
|
onThumbnailClick={handleThumbnailClick}
|
||||||
>
|
>
|
||||||
{#snippet customThumbnailLayout(asset: TimelineAsset)}
|
{#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)}
|
{#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">
|
<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')}
|
{asset.city || $t('gps')}
|
||||||
|
|||||||
Reference in New Issue
Block a user