Compare commits

...

36 Commits

Author SHA1 Message Date
Alex The Bot
9c0f444e4d Version v1.62.0 2023-06-19 15:43:49 +00:00
Jason Rasmussen
6b0f91cafd fix(server): only show assets 'on-this-day' with thumbnails (#2851) 2023-06-19 09:12:18 -05:00
Dan Cowell
3f71d2d33d chore(deps): change compose service dependencies to use alpine variants (#2825)
* chore(deps): change compose service dependencies to use alpine variants

* chore(deps): pin manifest hashes for dependency containers
2023-06-18 20:51:46 -05:00
Sergey Kondrikov
f2942588f2 chore(mobile): Add debug build type suffix to the applicationId and version (#2826) 2023-06-17 23:10:57 -05:00
Alex
b47027efc2 fix(mobile): Sort newest first for asset selection in album (#2833) 2023-06-17 23:09:55 -05:00
Zeeshan Khan
34201be74c feat(ml) backend takes image over HTTP (#2783)
* using pydantic BaseSetting

* ML API takes image file as input

* keeping image in memory

* reducing duplicate code

* using bytes instead of UploadFile & other small code improvements

* removed form-multipart, using HTTP body

* format code

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-06-17 22:49:19 -05:00
Covalent
3e804f16df feat(web,server): add thumbhash support (#2649)
* add thumbhash: server generation and web impl

* move logic to infra & use byta in db

* remove unnecesary logs

* update generated API and simplify thumbhash gen

* fix check errors

* removed unnecessary library and css tag

* style edits

* syntax mistake

* update server test, change thumbhash job name

* fix tests

* Update server/src/domain/asset/response-dto/asset-response.dto.ts

Co-authored-by: Thomas <9749173+uhthomas@users.noreply.github.com>

* add unit test, change migration date

* change to official thumbhash impl

* update call method to not use eval

* "generate missing" looks for thumbhash

* improve queue & improve syntax

* update syntax again

* update tests

* fix thumbhash generation

* consolidate queueing to avoid duplication

* cover all types of incorrect thumbnail cases

* split out jest tasks

* put back thumbnail duration loading for images without thumbhash

* Remove stray package.json

---------

Co-authored-by: Luke McCarthy <mail@lukehmcc.com>
Co-authored-by: Thomas <9749173+uhthomas@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-06-17 22:22:31 -05:00
Thomas
3512140148 feat(web): add padding to memory asset navigation (#2822)
The bars are 2 pixels tall, which can be tricky to click. Additional padding
increases the height to 16 pixels, without changing how it looks, and makes for
much easier clicking.

In addition, remove the onDestroy lifecycle for the tween as it's not
necessary. It was a relic from using animation frames.
2023-06-16 23:37:11 +01:00
Jason Rasmussen
bff6914a73 chore(server): organize imports (#2779)
* feat: lint rule for organize imports

* chore: organize imports
2023-06-16 19:54:17 +00:00
Jason Rasmussen
652add635f refactor: rename get auth user decorator (#2778)
Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-06-16 19:39:53 +00:00
Jason Rasmussen
fde410e2ac refactor(server): send job command (#2777)
* refactor: send job command

* chore: open api
2023-06-16 14:36:07 -05:00
Jason Rasmussen
f04e47803c refactor(server): access checks (#2776)
* refactor(server): access checks

* chore: simply asset module
2023-06-16 14:01:34 -05:00
Thomas
61d74263d9 fix(web): hide memory lane navigation properly on scaled resolutions (#2819)
Fixes: #2817
2023-06-16 13:55:11 -05:00
Alex Tran
66ee065c0c pause renovate 2023-06-16 13:52:29 -05:00
Thomas
09bcf6974e feat(web): show number of assets in memory progress bar (#2813)
Fixes: #2810
2023-06-16 13:17:39 -05:00
renovate[bot]
5d7d615433 chore(deps): update web (#2806)
* chore(deps): update web

* fixed svelte-check being a nuisance

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-06-16 12:45:05 -05:00
renovate[bot]
5387048dc3 fix(deps): update dependency tailwindcss to v3.3.2 (#2808)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-16 17:14:29 +00:00
renovate[bot]
6930df71cf fix(deps): update dependency docusaurus-preset-openapi to v0.6.4 (#2800)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-16 11:40:04 -05:00
renovate[bot]
52bbf6da5d fix(deps): update dependency url to v0.11.1 (#2802)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-16 11:39:54 -05:00
Alex
1cd5df7558 fix(web): not displaying assets in album after adding shared user (#2804) 2023-06-16 11:39:40 -05:00
renovate[bot]
74429798e2 fix(deps): update dependency autoprefixer to v10.4.14 (#2799)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-16 11:39:25 -05:00
renovate[bot]
651f3ea5eb chore(deps): update typesense/typesense docker tag to v0.24.1 (#2798)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-16 11:39:11 -05:00
renovate[bot]
0909335d02 chore(deps): update python:3.11.4-slim-bullseye docker digest to 91d194f (#2797)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-16 11:38:59 -05:00
renovate[bot]
827e4b5f75 chore(deps): update python:3.11.4-bullseye docker digest to 5b40167 (#2796)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-16 11:17:23 -05:00
renovate[bot]
c8ff07fff0 fix(deps): update dependency postcss to v8.4.24 (#2801)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-16 11:16:44 -05:00
Thomas
4a21cb2d00 chore(web): hide memory lane navigation when it's no longer possible to scroll (#2791)
Fixes: #2790
2023-06-16 11:06:38 -05:00
Jason Rasmussen
07f7fffae7 refactor(server): album count (#2746)
* refactor(server): album count

* chore: open api
2023-06-16 10:48:48 -05:00
renovate[bot]
441ee2ef90 chore(deps): update dependency typescript to v5 (#2795)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-16 10:43:40 -05:00
renovate[bot]
acad133e3a chore(deps): update dependency @tsconfig/docusaurus to v1.0.7 (#2793)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-16 15:30:14 +00:00
renovate[bot]
ef8714fda9 chore(deps): update dependency vite to v4.1.5 [security] (#2792)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-16 15:28:49 +00:00
Thomas
16171eee8d pin image digests (#2754)
Manifest list digests can be found with:

```sh
docker buildx imagetools inspect python:3.11.4-bullseye
docker buildx imagetools inspect python:3.11.4-slim-bullseye
docker buildx imagetools inspect ghcr.io/nginxinc/nginx-unprivileged:1.25.0-alpine3.17
```

The node images are pinned in #2736

Fixes #2751
Partially fixes #2752
2023-06-16 10:28:41 -05:00
renovate[bot]
d3c1781478 Configure Renovate (#2739)
* Add renovate.json

* Update renovate.json

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-06-16 10:22:52 -05:00
Thomas
329b52e670 use svelte motion tweening for animation (#2788)
It look like Svelte has a concept of 'tweening' for writing animations, which should reduce the complexity of the animation code.

Thanks to @probablykasper for finding this.

A lot of the logic has been rewritten for reactivity, which further reduces
complexity.
2023-06-16 10:09:28 -05:00
Alex
a1b9a1d244 fix(web): error when refreshing asset view in memory page (#2789) 2023-06-16 10:09:16 -05:00
phillibl
377cec9fb1 Update xmp-sidecars.md (#2785)
Fixed a spelling mistake
2023-06-16 09:42:49 -05:00
phillibl
48b9c63268 Update README.md (#2787)
Changed Partner Sharing to Yes for mobile
2023-06-16 09:39:06 -05:00
158 changed files with 4720 additions and 3805 deletions

View File

@@ -82,7 +82,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
| Public Sharing | No | Yes |
| Archive and Favorites | Yes | Yes |
| Global Map | No | Yes |
| Partner Sharing | No | Yes |
| Partner Sharing | Yes | Yes |
| Facial recognition and clustering | No | Yes |
# Support the project

View File

@@ -36,7 +36,6 @@ services:
- 3003:3003
volumes:
- ../machine-learning/app:/usr/src/app
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- model-cache:/cache
env_file:
- .env
@@ -95,7 +94,7 @@ services:
typesense:
container_name: immich_typesense
image: typesense/typesense:0.24.0
image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd
environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data
@@ -106,11 +105,11 @@ services:
redis:
container_name: immich_redis
image: redis:6.2
image: redis:6.2-alpine@sha256:70a7a5b641117670beae0d80658430853896b5ef269ccf00d1827427e3263fa3
database:
container_name: immich_postgres
image: postgres:14
image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
env_file:
- .env
environment:

View File

@@ -25,12 +25,12 @@ services:
- immich-test-network
immich-redis-test:
container_name: immich-redis-test
image: redis:6.2
image: redis:6.2-alpine@sha256:70a7a5b641117670beae0d80658430853896b5ef269ccf00d1827427e3263fa3
networks:
- immich-test-network
immich-database-test:
container_name: immich-database-test
image: postgres:14
image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
env_file:
- .env.test
environment:

View File

@@ -33,7 +33,6 @@ services:
container_name: immich_machine_learning
image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- model-cache:/cache
env_file:
- .env
@@ -48,7 +47,7 @@ services:
typesense:
container_name: immich_typesense
image: typesense/typesense:0.24.0
image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd
environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data
@@ -60,12 +59,12 @@ services:
redis:
container_name: immich_redis
image: redis:6.2
image: redis:6.2-alpine@sha256:70a7a5b641117670beae0d80658430853896b5ef269ccf00d1827427e3263fa3
restart: always
database:
container_name: immich_postgres
image: postgres:14
image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
env_file:
- .env
environment:

View File

@@ -4,7 +4,7 @@ Immich can ingest XMP sidecars on file upload (via the CLI) as well as detect ne
<img src={require('./img/xmp-sidecars.png').default} title='XMP sidecars' />
XMP sidecars are external XML files that contain metadata related to media files. Many applications read and write these files either exclusively or in addition to the metadata written to image files. They can be a powerful tool for editing and storing metadata of a media file without modifying the mdia file itself. When Immich receives or detects an XMP sidecar for a media file, it will attempt to extract the metadata from both the sidecar as well as the media file. It will prioritize the metadata for fields in the sidecar but will fall back and use the metadata in the media file if necessary.
XMP sidecars are external XML files that contain metadata related to media files. Many applications read and write these files either exclusively or in addition to the metadata written to image files. They can be a powerful tool for editing and storing metadata of a media file without modifying the media file itself. When Immich receives or detects an XMP sidecar for a media file, it will attempt to extract the metadata from both the sidecar as well as the media file. It will prioritize the metadata for fields in the sidecar but will fall back and use the metadata in the media file if necessary.
When importing files via the CLI bulk uploader, Immich will automatically detect XMP sidecar files as files that exist next to the original media file and have the exact same name with an additional `.xmp` file extension (i.e., `PXL_20230401_203352928.MP.jpg` and `PXL_20230401_203352928.MP.jpg.xmp`).

776
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -35,7 +35,7 @@
"@docusaurus/module-type-aliases": "2.1.0",
"@tsconfig/docusaurus": "^1.0.5",
"prettier": "^2.8.8",
"typescript": "^4.7.4"
"typescript": "^5.0.0"
},
"browserslist": {
"production": [

View File

@@ -1,4 +1,5 @@
FROM python:3.11 as builder
FROM python:3.11.4-bullseye@sha256:5b401676aff858495a5c9c726c60b8b73fe52833e9e16eccdb59e93d52741727 as builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=true
@@ -12,7 +13,8 @@ ENV VIRTUAL_ENV="/opt/venv" PATH="/opt/venv/bin:${PATH}"
COPY poetry.lock pyproject.toml ./
RUN poetry install --sync --no-interaction --no-ansi --no-root --only main
FROM python:3.11-slim
FROM python:3.11.4-slim-bullseye@sha256:91d194f58f50594cda71dcd2e8fdefd90e7ecc57d07823813b67c8521e565dcd
WORKDIR /usr/src/app
ENV NODE_ENV=production \
TRANSFORMERS_CACHE=/cache \

View File

@@ -0,0 +1,22 @@
from pydantic import BaseSettings
class Settings(BaseSettings):
cache_folder: str = "/cache"
classification_model: str = "microsoft/resnet-50"
clip_image_model: str = "clip-ViT-B-32"
clip_text_model: str = "clip-ViT-B-32"
facial_recognition_model: str = "buffalo_l"
min_tag_score: float = 0.9
eager_startup: bool = True
model_ttl: int = 300
host: str = "0.0.0.0"
port: int = 3003
workers: int = 1
min_face_score: float = 0.7
class Config(BaseSettings.Config):
env_prefix = 'MACHINE_LEARNING_'
case_sensitive = False
settings = Settings()

View File

@@ -1,4 +1,5 @@
import os
import io
from typing import Any
from cache import ModelCache
@@ -9,52 +10,44 @@ from schemas import (
MessageResponse,
TextModelRequest,
TextResponse,
VisionModelRequest,
)
import uvicorn
from PIL import Image
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, Depends, Body
from models import get_model, run_classification, run_facial_recognition
classification_model = os.getenv(
"MACHINE_LEARNING_CLASSIFICATION_MODEL", "microsoft/resnet-50"
)
clip_image_model = os.getenv("MACHINE_LEARNING_CLIP_IMAGE_MODEL", "clip-ViT-B-32")
clip_text_model = os.getenv("MACHINE_LEARNING_CLIP_TEXT_MODEL", "clip-ViT-B-32")
facial_recognition_model = os.getenv(
"MACHINE_LEARNING_FACIAL_RECOGNITION_MODEL", "buffalo_l"
)
min_tag_score = float(os.getenv("MACHINE_LEARNING_MIN_TAG_SCORE", 0.9))
eager_startup = (
os.getenv("MACHINE_LEARNING_EAGER_STARTUP", "true") == "true"
) # loads all models at startup
model_ttl = int(os.getenv("MACHINE_LEARNING_MODEL_TTL", 300))
from config import settings
_model_cache = None
app = FastAPI()
@app.on_event("startup")
async def startup_event() -> None:
global _model_cache
_model_cache = ModelCache(ttl=model_ttl, revalidate=True)
_model_cache = ModelCache(ttl=settings.model_ttl, revalidate=True)
models = [
(classification_model, "image-classification"),
(clip_image_model, "clip"),
(clip_text_model, "clip"),
(facial_recognition_model, "facial-recognition"),
(settings.classification_model, "image-classification"),
(settings.clip_image_model, "clip"),
(settings.clip_text_model, "clip"),
(settings.facial_recognition_model, "facial-recognition"),
]
# Get all models
for model_name, model_type in models:
if eager_startup:
if settings.eager_startup:
await _model_cache.get_cached_model(model_name, model_type)
else:
get_model(model_name, model_type)
def dep_model_cache():
if _model_cache is None:
raise HTTPException(status_code=500, detail="Unable to load model.")
def dep_input_image(image: bytes = Body(...)) -> Image:
return Image.open(io.BytesIO(image))
@app.get("/", response_model=MessageResponse)
async def root() -> dict[str, str]:
return {"message": "Immich ML"}
@@ -65,29 +58,36 @@ def ping() -> str:
return "pong"
@app.post("/image-classifier/tag-image", response_model=TagResponse, status_code=200)
async def image_classification(payload: VisionModelRequest) -> list[str]:
if _model_cache is None:
raise HTTPException(status_code=500, detail="Unable to load model.")
model = await _model_cache.get_cached_model(
classification_model, "image-classification"
)
labels = run_classification(model, payload.image_path, min_tag_score)
return labels
@app.post(
"/image-classifier/tag-image",
response_model=TagResponse,
status_code=200,
dependencies=[Depends(dep_model_cache)],
)
async def image_classification(
image: Image = Depends(dep_input_image)
) -> list[str]:
try:
model = await _model_cache.get_cached_model(
settings.classification_model, "image-classification"
)
labels = run_classification(model, image, settings.min_tag_score)
except Exception as ex:
raise HTTPException(status_code=500, detail=str(ex))
else:
return labels
@app.post(
"/sentence-transformer/encode-image",
response_model=EmbeddingResponse,
status_code=200,
dependencies=[Depends(dep_model_cache)],
)
async def clip_encode_image(payload: VisionModelRequest) -> list[float]:
if _model_cache is None:
raise HTTPException(status_code=500, detail="Unable to load model.")
model = await _model_cache.get_cached_model(clip_image_model, "clip")
image = Image.open(payload.image_path)
async def clip_encode_image(
image: Image = Depends(dep_input_image)
) -> list[float]:
model = await _model_cache.get_cached_model(settings.clip_image_model, "clip")
embedding = model.encode(image).tolist()
return embedding
@@ -96,33 +96,38 @@ async def clip_encode_image(payload: VisionModelRequest) -> list[float]:
"/sentence-transformer/encode-text",
response_model=EmbeddingResponse,
status_code=200,
dependencies=[Depends(dep_model_cache)],
)
async def clip_encode_text(payload: TextModelRequest) -> list[float]:
if _model_cache is None:
raise HTTPException(status_code=500, detail="Unable to load model.")
model = await _model_cache.get_cached_model(clip_text_model, "clip")
async def clip_encode_text(
payload: TextModelRequest
) -> list[float]:
model = await _model_cache.get_cached_model(settings.clip_text_model, "clip")
embedding = model.encode(payload.text).tolist()
return embedding
@app.post(
"/facial-recognition/detect-faces", response_model=FaceResponse, status_code=200
"/facial-recognition/detect-faces",
response_model=FaceResponse,
status_code=200,
dependencies=[Depends(dep_model_cache)],
)
async def facial_recognition(payload: VisionModelRequest) -> list[dict[str, Any]]:
if _model_cache is None:
raise HTTPException(status_code=500, detail="Unable to load model.")
async def facial_recognition(
image: bytes = Body(...),
) -> list[dict[str, Any]]:
model = await _model_cache.get_cached_model(
facial_recognition_model, "facial-recognition"
settings.facial_recognition_model, "facial-recognition"
)
faces = run_facial_recognition(model, payload.image_path)
faces = run_facial_recognition(model, image)
return faces
if __name__ == "__main__":
host = os.getenv("MACHINE_LEARNING_HOST", "0.0.0.0")
port = int(os.getenv("MACHINE_LEARNING_PORT", 3003))
is_dev = os.getenv("NODE_ENV") == "development"
uvicorn.run("main:app", host=host, port=port, reload=is_dev, workers=1)
uvicorn.run(
"main:app",
host=settings.host,
port=settings.port,
reload=is_dev,
workers=settings.workers,
)

View File

@@ -1,14 +1,15 @@
import torch
from insightface.app import FaceAnalysis
from pathlib import Path
import os
from transformers import pipeline, Pipeline
from sentence_transformers import SentenceTransformer
from typing import Any
from typing import Any, BinaryIO
import cv2 as cv
import numpy as np
from PIL import Image
from config import settings
cache_folder = os.getenv("MACHINE_LEARNING_CACHE_FOLDER", "/cache")
device = "cuda" if torch.cuda.is_available() else "cpu"
@@ -49,9 +50,9 @@ def get_model(model_name: str, model_type: str, **model_kwargs):
def run_classification(
model: Pipeline, image_path: str, min_score: float | None = None
model: Pipeline, image: Image, min_score: float | None = None
):
predictions: list[dict[str, Any]] = model(image_path) # type: ignore
predictions: list[dict[str, Any]] = model(image) # type: ignore
result = {
tag
for pred in predictions
@@ -63,9 +64,10 @@ def run_classification(
def run_facial_recognition(
model: FaceAnalysis, image_path: str
model: FaceAnalysis, image: bytes
) -> list[dict[str, Any]]:
img = cv.imread(image_path)
file_bytes = np.frombuffer(image, dtype=np.uint8)
img = cv.imdecode(file_bytes, cv.IMREAD_COLOR)
height, width, _ = img.shape
results = []
faces = model.get(img)
@@ -101,7 +103,7 @@ def _load_facial_recognition(
if isinstance(cache_dir, Path):
cache_dir = cache_dir.as_posix()
if min_face_score is None:
min_face_score = float(os.getenv("MACHINE_LEARNING_MIN_FACE_SCORE", 0.7))
min_face_score = settings.min_face_score
model = FaceAnalysis(
name=model_name,
@@ -114,4 +116,4 @@ def _load_facial_recognition(
def _get_cache_dir(model_name: str, model_type: str) -> Path:
return Path(cache_folder, device, model_type, model_name)
return Path(settings.cache_folder, device, model_type, model_name)

View File

@@ -9,14 +9,6 @@ def to_lower_camel(string: str) -> str:
return "".join(tokens)
class VisionModelRequest(BaseModel):
image_path: str
class Config:
alias_generator = to_lower_camel
allow_population_by_field_name = True
class TextModelRequest(BaseModel):
text: str

View File

@@ -1733,6 +1733,8 @@ files = [
{file = "scikit_image-0.21.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:c01e3ab0a1fabfd8ce30686d4401b7ed36e6126c9d4d05cb94abf6bdc46f7ac9"},
{file = "scikit_image-0.21.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ef5d8d1099317b7b315b530348cbfa68ab8ce32459de3c074d204166951025c"},
{file = "scikit_image-0.21.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b1e96c59cab640ca5c5b22c501524cfaf34cbe0cb51ba73bd9a9ede3fb6e1d"},
{file = "scikit_image-0.21.0-cp39-cp39-win_amd64.whl", hash = "sha256:9cffcddd2a5594c0a06de2ae3e1e25d662745a26f94fda31520593669677c010"},
{file = "scikit_image-0.21.0.tar.gz", hash = "sha256:b33e823c54e6f11873ea390ee49ef832b82b9f70752c8759efd09d5a4e3d87f0"},
]
[package.dependencies]
@@ -2088,9 +2090,9 @@ opt-einsum = ["opt-einsum (>=3.3)"]
[[package]]
name = "torch"
version = "2.0.1+cpu"
description = ""
description = "Tensors and Dynamic neural networks in Python with strong GPU acceleration"
optional = false
python-versions = "*"
python-versions = ">=3.8.0"
files = [
{file = "torch-2.0.1+cpu-cp310-cp310-linux_x86_64.whl", hash = "sha256:fec257249ba014c68629a1994b0c6e7356e20e1afc77a87b9941a40e5095285d"},
{file = "torch-2.0.1+cpu-cp310-cp310-win_amd64.whl", hash = "sha256:ca88b499973c4c027e32c4960bf20911d7e984bd0c55cda181dc643559f3d93f"},
@@ -2102,6 +2104,16 @@ files = [
{file = "torch-2.0.1+cpu-cp39-cp39-win_amd64.whl", hash = "sha256:f263f8e908288427ae81441fef540377f61e339a27632b1bbe33cf78292fdaea"},
]
[package.dependencies]
filelock = "*"
jinja2 = "*"
networkx = "*"
sympy = "*"
typing-extensions = "*"
[package.extras]
opt-einsum = ["opt-einsum (>=3.3)"]
[package.source]
type = "legacy"
url = "https://download.pytorch.org/whl/cpu"

View File

@@ -72,6 +72,11 @@ android {
}
buildTypes {
debug {
applicationIdSuffix '.debug'
versionNameSuffix '-DEBUG'
}
release {
signingConfig signingConfigs.release
}

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 84,
"android.injected.version.name" => "1.61.0",
"android.injected.version.code" => 85,
"android.injected.version.name" => "1.62.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.61.0"
version_number: "1.62.0"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -190,7 +190,7 @@ final remoteAssetsProvider =
.remoteIdIsNotNull()
.filter()
.ownerIdEqualTo(userId)
.sortByFileCreatedAt();
.sortByFileCreatedAtDesc();
final settings = ref.watch(appSettingsServiceProvider);
final groupBy =
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];

View File

@@ -76,7 +76,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
return {
"major": int.parse(major),
"minor": int.parse(minor),
"patch": int.parse(patch),
"patch": int.parse(patch.replaceAll("-DEBUG", "")),
};
}
}

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.61.0
- API version: 1.62.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
@@ -83,7 +83,7 @@ Class | Method | HTTP request | Description
*AlbumApi* | [**createAlbumSharedLink**](doc//AlbumApi.md#createalbumsharedlink) | **POST** /album/create-shared-link |
*AlbumApi* | [**deleteAlbum**](doc//AlbumApi.md#deletealbum) | **DELETE** /album/{id} |
*AlbumApi* | [**downloadArchive**](doc//AlbumApi.md#downloadarchive) | **GET** /album/{id}/download |
*AlbumApi* | [**getAlbumCountByUserId**](doc//AlbumApi.md#getalbumcountbyuserid) | **GET** /album/count-by-user-id |
*AlbumApi* | [**getAlbumCount**](doc//AlbumApi.md#getalbumcount) | **GET** /album/count |
*AlbumApi* | [**getAlbumInfo**](doc//AlbumApi.md#getalbuminfo) | **GET** /album/{id} |
*AlbumApi* | [**getAllAlbums**](doc//AlbumApi.md#getallalbums) | **GET** /album |
*AlbumApi* | [**removeAssetFromAlbum**](doc//AlbumApi.md#removeassetfromalbum) | **DELETE** /album/{id}/assets |
@@ -125,7 +125,7 @@ Class | Method | HTTP request | Description
*AuthenticationApi* | [**logoutAuthDevices**](doc//AuthenticationApi.md#logoutauthdevices) | **DELETE** /auth/devices |
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
*JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs |
*JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} |
*JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{id} |
*OAuthApi* | [**callback**](doc//OAuthApi.md#callback) | **POST** /oauth/callback |
*OAuthApi* | [**generateConfig**](doc//OAuthApi.md#generateconfig) | **POST** /oauth/config |
*OAuthApi* | [**link**](doc//OAuthApi.md#link) | **POST** /oauth/link |

View File

@@ -15,7 +15,7 @@ Method | HTTP request | Description
[**createAlbumSharedLink**](AlbumApi.md#createalbumsharedlink) | **POST** /album/create-shared-link |
[**deleteAlbum**](AlbumApi.md#deletealbum) | **DELETE** /album/{id} |
[**downloadArchive**](AlbumApi.md#downloadarchive) | **GET** /album/{id}/download |
[**getAlbumCountByUserId**](AlbumApi.md#getalbumcountbyuserid) | **GET** /album/count-by-user-id |
[**getAlbumCount**](AlbumApi.md#getalbumcount) | **GET** /album/count |
[**getAlbumInfo**](AlbumApi.md#getalbuminfo) | **GET** /album/{id} |
[**getAllAlbums**](AlbumApi.md#getallalbums) | **GET** /album |
[**removeAssetFromAlbum**](AlbumApi.md#removeassetfromalbum) | **DELETE** /album/{id}/assets |
@@ -364,8 +364,8 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getAlbumCountByUserId**
> AlbumCountResponseDto getAlbumCountByUserId()
# **getAlbumCount**
> AlbumCountResponseDto getAlbumCount()
@@ -390,10 +390,10 @@ import 'package:openapi/api.dart';
final api_instance = AlbumApi();
try {
final result = api_instance.getAlbumCountByUserId();
final result = api_instance.getAlbumCount();
print(result);
} catch (e) {
print('Exception when calling AlbumApi->getAlbumCountByUserId: $e\n');
print('Exception when calling AlbumApi->getAlbumCount: $e\n');
}
```

View File

@@ -10,7 +10,7 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**owned** | **int** | |
**shared** | **int** | |
**sharing** | **int** | |
**notShared** | **int** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -16,6 +16,7 @@ Name | Type | Description | Notes
**originalPath** | **String** | |
**originalFileName** | **String** | |
**resized** | **bool** | |
**thumbhash** | **String** | base64 encoded thumbhash |
**fileCreatedAt** | [**DateTime**](DateTime.md) | |
**fileModifiedAt** | [**DateTime**](DateTime.md) | |
**updatedAt** | [**DateTime**](DateTime.md) | |

View File

@@ -10,7 +10,7 @@ All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**getAllJobsStatus**](JobApi.md#getalljobsstatus) | **GET** /jobs |
[**sendJobCommand**](JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} |
[**sendJobCommand**](JobApi.md#sendjobcommand) | **PUT** /jobs/{id} |
# **getAllJobsStatus**
@@ -65,7 +65,7 @@ This endpoint does not need any parameter.
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **sendJobCommand**
> JobStatusDto sendJobCommand(jobId, jobCommandDto)
> JobStatusDto sendJobCommand(id, jobCommandDto)
@@ -88,11 +88,11 @@ import 'package:openapi/api.dart';
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = JobApi();
final jobId = ; // JobName |
final id = ; // JobName |
final jobCommandDto = JobCommandDto(); // JobCommandDto |
try {
final result = api_instance.sendJobCommand(jobId, jobCommandDto);
final result = api_instance.sendJobCommand(id, jobCommandDto);
print(result);
} catch (e) {
print('Exception when calling JobApi->sendJobCommand: $e\n');
@@ -103,7 +103,7 @@ try {
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**jobId** | [**JobName**](.md)| |
**id** | [**JobName**](.md)| |
**jobCommandDto** | [**JobCommandDto**](JobCommandDto.md)| |
### Return type

View File

@@ -332,10 +332,10 @@ class AlbumApi {
return null;
}
/// Performs an HTTP 'GET /album/count-by-user-id' operation and returns the [Response].
Future<Response> getAlbumCountByUserIdWithHttpInfo() async {
/// Performs an HTTP 'GET /album/count' operation and returns the [Response].
Future<Response> getAlbumCountWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/album/count-by-user-id';
final path = r'/album/count';
// ignore: prefer_final_locals
Object? postBody;
@@ -358,8 +358,8 @@ class AlbumApi {
);
}
Future<AlbumCountResponseDto?> getAlbumCountByUserId() async {
final response = await getAlbumCountByUserIdWithHttpInfo();
Future<AlbumCountResponseDto?> getAlbumCount() async {
final response = await getAlbumCountWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@@ -57,16 +57,16 @@ class JobApi {
return null;
}
/// Performs an HTTP 'PUT /jobs/{jobId}' operation and returns the [Response].
/// Performs an HTTP 'PUT /jobs/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [JobName] jobId (required):
/// * [JobName] id (required):
///
/// * [JobCommandDto] jobCommandDto (required):
Future<Response> sendJobCommandWithHttpInfo(JobName jobId, JobCommandDto jobCommandDto,) async {
Future<Response> sendJobCommandWithHttpInfo(JobName id, JobCommandDto jobCommandDto,) async {
// ignore: prefer_const_declarations
final path = r'/jobs/{jobId}'
.replaceAll('{jobId}', jobId.toString());
final path = r'/jobs/{id}'
.replaceAll('{id}', id.toString());
// ignore: prefer_final_locals
Object? postBody = jobCommandDto;
@@ -91,11 +91,11 @@ class JobApi {
/// Parameters:
///
/// * [JobName] jobId (required):
/// * [JobName] id (required):
///
/// * [JobCommandDto] jobCommandDto (required):
Future<JobStatusDto?> sendJobCommand(JobName jobId, JobCommandDto jobCommandDto,) async {
final response = await sendJobCommandWithHttpInfo(jobId, jobCommandDto,);
Future<JobStatusDto?> sendJobCommand(JobName id, JobCommandDto jobCommandDto,) async {
final response = await sendJobCommandWithHttpInfo(id, jobCommandDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@@ -15,36 +15,36 @@ class AlbumCountResponseDto {
AlbumCountResponseDto({
required this.owned,
required this.shared,
required this.sharing,
required this.notShared,
});
int owned;
int shared;
int sharing;
int notShared;
@override
bool operator ==(Object other) => identical(this, other) || other is AlbumCountResponseDto &&
other.owned == owned &&
other.shared == shared &&
other.sharing == sharing;
other.notShared == notShared;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(owned.hashCode) +
(shared.hashCode) +
(sharing.hashCode);
(notShared.hashCode);
@override
String toString() => 'AlbumCountResponseDto[owned=$owned, shared=$shared, sharing=$sharing]';
String toString() => 'AlbumCountResponseDto[owned=$owned, shared=$shared, notShared=$notShared]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'owned'] = this.owned;
json[r'shared'] = this.shared;
json[r'sharing'] = this.sharing;
json[r'notShared'] = this.notShared;
return json;
}
@@ -69,7 +69,7 @@ class AlbumCountResponseDto {
return AlbumCountResponseDto(
owned: mapValueOfType<int>(json, r'owned')!,
shared: mapValueOfType<int>(json, r'shared')!,
sharing: mapValueOfType<int>(json, r'sharing')!,
notShared: mapValueOfType<int>(json, r'notShared')!,
);
}
return null;
@@ -119,7 +119,7 @@ class AlbumCountResponseDto {
static const requiredKeys = <String>{
'owned',
'shared',
'sharing',
'notShared',
};
}

View File

@@ -21,6 +21,7 @@ class AssetResponseDto {
required this.originalPath,
required this.originalFileName,
required this.resized,
required this.thumbhash,
required this.fileCreatedAt,
required this.fileModifiedAt,
required this.updatedAt,
@@ -52,6 +53,9 @@ class AssetResponseDto {
bool resized;
/// base64 encoded thumbhash
String? thumbhash;
DateTime fileCreatedAt;
DateTime fileModifiedAt;
@@ -101,6 +105,7 @@ class AssetResponseDto {
other.originalPath == originalPath &&
other.originalFileName == originalFileName &&
other.resized == resized &&
other.thumbhash == thumbhash &&
other.fileCreatedAt == fileCreatedAt &&
other.fileModifiedAt == fileModifiedAt &&
other.updatedAt == updatedAt &&
@@ -126,6 +131,7 @@ class AssetResponseDto {
(originalPath.hashCode) +
(originalFileName.hashCode) +
(resized.hashCode) +
(thumbhash == null ? 0 : thumbhash!.hashCode) +
(fileCreatedAt.hashCode) +
(fileModifiedAt.hashCode) +
(updatedAt.hashCode) +
@@ -141,7 +147,7 @@ class AssetResponseDto {
(checksum.hashCode);
@override
String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, originalFileName=$originalFileName, resized=$resized, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, updatedAt=$updatedAt, isFavorite=$isFavorite, isArchived=$isArchived, mimeType=$mimeType, duration=$duration, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId, tags=$tags, people=$people, checksum=$checksum]';
String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, originalFileName=$originalFileName, resized=$resized, thumbhash=$thumbhash, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, updatedAt=$updatedAt, isFavorite=$isFavorite, isArchived=$isArchived, mimeType=$mimeType, duration=$duration, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId, tags=$tags, people=$people, checksum=$checksum]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -153,6 +159,11 @@ class AssetResponseDto {
json[r'originalPath'] = this.originalPath;
json[r'originalFileName'] = this.originalFileName;
json[r'resized'] = this.resized;
if (this.thumbhash != null) {
json[r'thumbhash'] = this.thumbhash;
} else {
// json[r'thumbhash'] = null;
}
json[r'fileCreatedAt'] = this.fileCreatedAt.toUtc().toIso8601String();
json[r'fileModifiedAt'] = this.fileModifiedAt.toUtc().toIso8601String();
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
@@ -212,6 +223,7 @@ class AssetResponseDto {
originalPath: mapValueOfType<String>(json, r'originalPath')!,
originalFileName: mapValueOfType<String>(json, r'originalFileName')!,
resized: mapValueOfType<bool>(json, r'resized')!,
thumbhash: mapValueOfType<String>(json, r'thumbhash'),
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', '')!,
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', '')!,
updatedAt: mapDateTime(json, r'updatedAt', '')!,
@@ -280,6 +292,7 @@ class AssetResponseDto {
'originalPath',
'originalFileName',
'resized',
'thumbhash',
'fileCreatedAt',
'fileModifiedAt',
'updatedAt',

View File

@@ -47,8 +47,8 @@ void main() {
// TODO
});
//Future<AlbumCountResponseDto> getAlbumCountByUserId() async
test('test getAlbumCountByUserId', () async {
//Future<AlbumCountResponseDto> getAlbumCount() async
test('test getAlbumCount', () async {
// TODO
});

View File

@@ -26,8 +26,8 @@ void main() {
// TODO
});
// int sharing
test('to test the property `sharing`', () async {
// int notShared
test('to test the property `notShared`', () async {
// TODO
});

View File

@@ -56,6 +56,12 @@ void main() {
// TODO
});
// base64 encoded thumbhash
// String thumbhash
test('to test the property `thumbhash`', () async {
// TODO
});
// DateTime fileCreatedAt
test('to test the property `fileCreatedAt`', () async {
// TODO

View File

@@ -22,7 +22,7 @@ void main() {
// TODO
});
//Future<JobStatusDto> sendJobCommand(JobName jobId, JobCommandDto jobCommandDto) async
//Future<JobStatusDto> sendJobCommand(JobName id, JobCommandDto jobCommandDto) async
test('test sendJobCommand', () async {
// TODO
});

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.61.0+84
version: 1.62.0+85
isar_version: &isar_version 3.1.0+1
environment:

View File

@@ -1,4 +1,4 @@
FROM ghcr.io/nginxinc/nginx-unprivileged:1.23
FROM ghcr.io/nginxinc/nginx-unprivileged:1.25.0-alpine3.17@sha256:e57300e9f60e521c5af3ec8fdc710285a371647e8033bcb8a36020c4394db3e3
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE

19
renovate.json Normal file
View File

@@ -0,0 +1,19 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:base"],
"packageRules": [
{
"matchPaths": ["mobile"],
"groupName": "mobile"
},
{
"matchPaths": ["server"],
"groupName": "server"
},
{
"matchPaths": ["web"],
"groupName": "web"
}
],
"enabled": false
}

View File

@@ -2,5 +2,6 @@
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120,
"semi": true
"semi": true,
"organizeImportsSkipDestructiveCodeActions": true
}

View File

@@ -1,13 +1,12 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { clearDb, getAuthUser, authCustom } from '../test/test-utils';
import { CreateAlbumDto } from '@app/domain';
import { AlbumResponseDto, AuthService, CreateAlbumDto, SharedLinkResponseDto, UserService } from '@app/domain';
import { CreateAlbumShareLinkDto } from '@app/immich/api-v1/album/dto/create-album-shared-link.dto';
import { AuthUserDto } from '@app/immich/decorators/auth-user.decorator';
import { AlbumResponseDto, AuthService, SharedLinkResponseDto, UserService } from '@app/domain';
import { DataSource } from 'typeorm';
import { AppModule } from '@app/immich/app.module';
import { AuthUserDto } from '@app/immich/decorators/auth-user.decorator';
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { DataSource } from 'typeorm';
import { authCustom, clearDb, getAuthUser } from '../test/test-utils';
async function _createAlbum(app: INestApplication, data: CreateAlbumDto) {
const res = await request(app.getHttpServer()).post('/album').send(data);

View File

@@ -1,11 +1,10 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { clearDb, authCustom } from '../test/test-utils';
import { CreateUserDto, UserService, AuthUserDto, UserResponseDto } from '@app/domain';
import { DataSource } from 'typeorm';
import { AuthService } from '@app/domain';
import { AuthService, AuthUserDto, CreateUserDto, UserResponseDto, UserService } from '@app/domain';
import { AppModule } from '@app/immich/app.module';
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { DataSource } from 'typeorm';
import { authCustom, clearDb } from '../test/test-utils';
function _createUser(userService: UserService, data: CreateUserDto) {
return userService.createUser(data);

View File

@@ -95,9 +95,9 @@
]
}
},
"/album/count-by-user-id": {
"/album/count": {
"get": {
"operationId": "getAlbumCountByUserId",
"operationId": "getAlbumCount",
"parameters": [],
"responses": {
"200": {
@@ -2387,12 +2387,12 @@
]
}
},
"/jobs/{jobId}": {
"/jobs/{id}": {
"put": {
"operationId": "sendJobCommand",
"parameters": [
{
"name": "jobId",
"name": "id",
"required": true,
"in": "path",
"schema": {
@@ -4354,7 +4354,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.61.0",
"version": "1.62.0",
"contact": {}
},
"tags": [],
@@ -4530,14 +4530,14 @@
"shared": {
"type": "integer"
},
"sharing": {
"notShared": {
"type": "integer"
}
},
"required": [
"owned",
"shared",
"sharing"
"notShared"
]
},
"AlbumResponseDto": {
@@ -4865,6 +4865,11 @@
"resized": {
"type": "boolean"
},
"thumbhash": {
"type": "string",
"nullable": true,
"description": "base64 encoded thumbhash"
},
"fileCreatedAt": {
"format": "date-time",
"type": "string"
@@ -4926,6 +4931,7 @@
"originalPath",
"originalFileName",
"resized",
"thumbhash",
"fileCreatedAt",
"fileModifiedAt",
"updatedAt",

View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.61.0",
"version": "1.62.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.61.0",
"version": "1.62.0",
"license": "UNLICENSED",
"dependencies": {
"@babel/runtime": "^7.20.13",
@@ -46,6 +46,7 @@
"rxjs": "^7.2.0",
"sanitize-filename": "^1.6.3",
"sharp": "^0.31.3",
"thumbhash": "^0.1.1",
"typeorm": "^0.3.11",
"typesense": "^1.5.3",
"ua-parser-js": "^1.0.35"
@@ -83,6 +84,7 @@
"jest": "^27.2.5",
"jest-when": "^3.5.2",
"prettier": "^2.3.2",
"prettier-plugin-organize-imports": "^3.2.2",
"rimraf": "^3.0.2",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
@@ -4233,9 +4235,9 @@
}
},
"node_modules/bullmq": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-3.14.1.tgz",
"integrity": "sha512-Fom78UKljYsnJmwbROVPx3eFLuVfQjQbw9KCnVupLzT31RQHhFHV2xd/4J4oWl4u34bZ1JmEUfNnqNBz+IOJuA==",
"version": "3.15.4",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-3.15.4.tgz",
"integrity": "sha512-jig63/PWODJEsAuswiCVUHaDWMv5fGpU36SjI0watAdXZMmy9K/iMKQAfPXmfZeK98bY/+co/efaDWVh3eVImw==",
"dependencies": {
"cron-parser": "^4.6.0",
"glob": "^8.0.3",
@@ -9374,6 +9376,26 @@
"node": ">=6.0.0"
}
},
"node_modules/prettier-plugin-organize-imports": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-3.2.2.tgz",
"integrity": "sha512-e97lE6odGSiHonHJMTYC0q0iLXQyw0u5z/PJpvP/3vRy6/Zi9kLBwFAbEGjDzIowpjQv8b+J04PDamoUSQbzGA==",
"dev": true,
"peerDependencies": {
"@volar/vue-language-plugin-pug": "^1.0.4",
"@volar/vue-typescript": "^1.0.4",
"prettier": ">=2.0",
"typescript": ">=2.9"
},
"peerDependenciesMeta": {
"@volar/vue-language-plugin-pug": {
"optional": true
},
"@volar/vue-typescript": {
"optional": true
}
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -10785,6 +10807,11 @@
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
},
"node_modules/thumbhash": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/thumbhash/-/thumbhash-0.1.1.tgz",
"integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg=="
},
"node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
@@ -15220,9 +15247,9 @@
"integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="
},
"bullmq": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-3.14.1.tgz",
"integrity": "sha512-Fom78UKljYsnJmwbROVPx3eFLuVfQjQbw9KCnVupLzT31RQHhFHV2xd/4J4oWl4u34bZ1JmEUfNnqNBz+IOJuA==",
"version": "3.15.4",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-3.15.4.tgz",
"integrity": "sha512-jig63/PWODJEsAuswiCVUHaDWMv5fGpU36SjI0watAdXZMmy9K/iMKQAfPXmfZeK98bY/+co/efaDWVh3eVImw==",
"requires": {
"cron-parser": "^4.6.0",
"glob": "^8.0.3",
@@ -19106,6 +19133,13 @@
"fast-diff": "^1.1.2"
}
},
"prettier-plugin-organize-imports": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-3.2.2.tgz",
"integrity": "sha512-e97lE6odGSiHonHJMTYC0q0iLXQyw0u5z/PJpvP/3vRy6/Zi9kLBwFAbEGjDzIowpjQv8b+J04PDamoUSQbzGA==",
"dev": true,
"requires": {}
},
"pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -20157,6 +20191,11 @@
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
},
"thumbhash": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/thumbhash/-/thumbhash-0.1.1.tgz",
"integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg=="
},
"tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.61.0",
"version": "1.62.0",
"description": "",
"author": "",
"private": true,
@@ -75,6 +75,7 @@
"rxjs": "^7.2.0",
"sanitize-filename": "^1.6.3",
"sharp": "^0.31.3",
"thumbhash": "^0.1.1",
"typeorm": "^0.3.11",
"typesense": "^1.5.3",
"ua-parser-js": "^1.0.35"
@@ -109,6 +110,7 @@
"jest": "^27.2.5",
"jest-when": "^3.5.2",
"prettier": "^2.3.2",
"prettier-plugin-organize-imports": "^3.2.2",
"rimraf": "^3.0.2",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",

View File

@@ -2,6 +2,8 @@ export const IAccessRepository = 'IAccessRepository';
export interface IAccessRepository {
hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>;
hasAlbumAssetAccess(userId: string, assetId: string): Promise<boolean>;
hasOwnerAssetAccess(userId: string, assetId: string): Promise<boolean>;
hasPartnerAssetAccess(userId: string, assetId: string): Promise<boolean>;
hasSharedLinkAssetAccess(userId: string, assetId: string): Promise<boolean>;
}

View File

@@ -1,7 +1,7 @@
import { AlbumEntity } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { AssetResponseDto, mapAsset } from '../../asset';
import { mapUser, UserResponseDto } from '../../user';
import { AssetResponseDto, mapAsset } from '../asset';
import { mapUser, UserResponseDto } from '../user';
export class AlbumResponseDto {
id!: string;
@@ -63,3 +63,14 @@ export function mapAlbumExcludeAssetInfo(entity: AlbumEntity): AlbumResponseDto
assetCount: entity.assets?.length || 0,
};
}
export class AlbumCountResponseDto {
@ApiProperty({ type: 'integer' })
owned!: number;
@ApiProperty({ type: 'integer' })
shared!: number;
@ApiProperty({ type: 'integer' })
notShared!: number;
}

View File

@@ -1,5 +1,4 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import _ from 'lodash';
import {
albumStub,
authStub,
@@ -9,6 +8,7 @@ import {
newUserRepositoryMock,
userEntityStub,
} from '@test';
import _ from 'lodash';
import { IAssetRepository } from '../asset';
import { IJobRepository, JobName } from '../job';
import { IUserRepository } from '../user';
@@ -35,6 +35,23 @@ describe(AlbumService.name, () => {
expect(sut).toBeDefined();
});
describe('getCount', () => {
it('should get the album count', async () => {
albumMock.getOwned.mockResolvedValue([]),
albumMock.getShared.mockResolvedValue([]),
albumMock.getNotShared.mockResolvedValue([]),
await expect(sut.getCount(authStub.admin)).resolves.toEqual({
owned: 0,
shared: 0,
notShared: 0,
});
expect(albumMock.getOwned).toHaveBeenCalledWith(authStub.admin.id);
expect(albumMock.getShared).toHaveBeenCalledWith(authStub.admin.id);
expect(albumMock.getNotShared).toHaveBeenCalledWith(authStub.admin.id);
});
});
describe('getAll', () => {
it('gets list of albums for auth user', async () => {
albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]);

View File

@@ -4,9 +4,9 @@ import { IAssetRepository, mapAsset } from '../asset';
import { AuthUserDto } from '../auth';
import { IJobRepository, JobName } from '../job';
import { IUserRepository } from '../user';
import { AlbumCountResponseDto, AlbumResponseDto, mapAlbum } from './album-response.dto';
import { IAlbumRepository } from './album.repository';
import { AddUsersDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto } from './dto';
import { AlbumResponseDto, mapAlbum } from './response-dto';
@Injectable()
export class AlbumService {
@@ -17,6 +17,20 @@ export class AlbumService {
@Inject(IUserRepository) private userRepository: IUserRepository,
) {}
async getCount(authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
const [owned, shared, notShared] = await Promise.all([
this.albumRepository.getOwned(authUser.id),
this.albumRepository.getShared(authUser.id),
this.albumRepository.getNotShared(authUser.id),
]);
return {
owned: owned.length,
shared: shared.length,
notShared: notShared.length,
};
}
async getAll({ id: ownerId }: AuthUserDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
await this.updateInvalidThumbnails();

View File

@@ -1,5 +1,5 @@
import { ArrayNotEmpty } from 'class-validator';
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
import { ArrayNotEmpty } from 'class-validator';
export class AddUsersDto {
@ValidateUUID({ each: true })

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateAlbumDto {

View File

@@ -1,6 +1,6 @@
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional } from 'class-validator';
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
export class UpdateAlbumDto {
@IsOptional()

View File

@@ -1,8 +1,8 @@
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
import { toBoolean } from '@app/immich/utils/transform.util';
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional } from 'class-validator';
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
import { toBoolean } from '@app/immich/utils/transform.util';
export class GetAlbumsDto {
@IsOptional()

View File

@@ -1,4 +1,4 @@
export * from './album-response.dto';
export * from './album.repository';
export * from './album.service';
export * from './dto';
export * from './response-dto';

View File

@@ -1 +0,0 @@
export * from './album-response.dto';

View File

@@ -1,7 +1,7 @@
import { toBoolean } from '@app/immich/utils/transform.util';
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsDate, IsOptional } from 'class-validator';
import { toBoolean } from '@app/immich/utils/transform.util';
export class MapMarkerDto {
@ApiProperty()

View File

@@ -16,6 +16,8 @@ export class AssetResponseDto {
originalPath!: string;
originalFileName!: string;
resized!: boolean;
/**base64 encoded thumbhash */
thumbhash!: string | null;
fileCreatedAt!: Date;
fileModifiedAt!: Date;
updatedAt!: Date;
@@ -42,6 +44,7 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
originalPath: entity.originalPath,
originalFileName: entity.originalFileName,
resized: !!entity.resizePath,
thumbhash: entity.thumbhash?.toString('base64') ?? null,
fileCreatedAt: entity.fileCreatedAt,
fileModifiedAt: entity.fileModifiedAt,
updatedAt: entity.updatedAt,
@@ -68,6 +71,7 @@ export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
originalPath: entity.originalPath,
originalFileName: entity.originalFileName,
resized: !!entity.resizePath,
thumbhash: entity.thumbhash?.toString('base64') || null,
fileCreatedAt: entity.fileCreatedAt,
fileModifiedAt: entity.fileModifiedAt,
updatedAt: entity.updatedAt,

View File

@@ -1,8 +1,5 @@
import { SystemConfig, UserEntity } from '@app/infra/entities';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { IncomingHttpHeaders } from 'http';
import { generators, Issuer } from 'openid-client';
import { Socket } from 'socket.io';
import {
authStub,
keyStub,
@@ -18,6 +15,9 @@ import {
userEntityStub,
userTokenEntityStub,
} from '@test';
import { IncomingHttpHeaders } from 'http';
import { generators, Issuer } from 'openid-client';
import { Socket } from 'socket.io';
import { IKeyRepository } from '../api-key';
import { ICryptoRepository } from '../crypto/crypto.repository';
import { ISharedLinkRepository } from '../shared-link';

View File

@@ -7,21 +7,27 @@ import {
Logger,
UnauthorizedException,
} from '@nestjs/common';
import cookieParser from 'cookie';
import { IncomingHttpHeaders } from 'http';
import { IKeyRepository } from '../api-key';
import { APIKeyCore } from '../api-key/api-key.core';
import { ICryptoRepository } from '../crypto/crypto.repository';
import { OAuthCore } from '../oauth/oauth.core';
import { ISharedLinkRepository, SharedLinkCore } from '../shared-link';
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
import { IUserRepository, UserCore } from '../user';
import { IUserTokenRepository, UserTokenCore } from '../user-token';
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_API_KEY_HEADER } from './auth.constant';
import { AuthCore, LoginDetails } from './auth.core';
import { ICryptoRepository } from '../crypto/crypto.repository';
import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto';
import { AdminSignupResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse } from './response-dto';
import { IUserTokenRepository, UserTokenCore } from '../user-token';
import cookieParser from 'cookie';
import { ISharedLinkRepository, SharedLinkCore } from '../shared-link';
import { APIKeyCore } from '../api-key/api-key.core';
import { IKeyRepository } from '../api-key';
import { AuthDeviceResponseDto, mapUserToken } from './response-dto';
import {
AdminSignupResponseDto,
AuthDeviceResponseDto,
LoginResponseDto,
LogoutResponseDto,
mapAdminSignupResponse,
mapUserToken,
} from './response-dto';
@Injectable()
export class AuthService {

View File

@@ -1,2 +1,2 @@
export * from './facial-recognition.services';
export * from './face.repository';
export * from './facial-recognition.services';

View File

@@ -6,5 +6,5 @@ export class JobIdDto {
@IsNotEmpty()
@IsEnum(QueueName)
@ApiProperty({ type: String, enum: QueueName, enumName: 'JobName' })
jobId!: QueueName;
id!: QueueName;
}

View File

@@ -27,6 +27,7 @@ export enum JobName {
QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails',
GENERATE_JPEG_THUMBNAIL = 'generate-jpeg-thumbnail',
GENERATE_WEBP_THUMBNAIL = 'generate-webp-thumbnail',
GENERATE_THUMBHASH_THUMBNAIL = 'generate-thumbhash-thumbnail',
// metadata
QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction',
@@ -92,6 +93,7 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
[JobName.QUEUE_GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_JPEG_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_WEBP_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_THUMBHASH_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
// metadata
[JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,

View File

@@ -31,6 +31,7 @@ export type JobItem =
| { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob }
| { name: JobName.GENERATE_JPEG_THUMBNAIL; data: IEntityJob }
| { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IEntityJob }
| { name: JobName.GENERATE_THUMBHASH_THUMBNAIL; data: IEntityJob }
// User Deletion
| { name: JobName.USER_DELETE_CHECK; data?: IBaseJob }

View File

@@ -7,9 +7,9 @@ import {
newJobRepositoryMock,
newSystemConfigRepositoryMock,
} from '@test';
import { IJobRepository, JobCommand, JobHandler, JobItem, JobName, JobService, QueueName } from '.';
import { IAssetRepository } from '../asset';
import { ICommunicationRepository } from '../communication';
import { IJobRepository, JobCommand, JobHandler, JobItem, JobName, JobService, QueueName } from '.';
import { ISystemConfigRepository } from '../system-config';
import { SystemConfigCore } from '../system-config/system-config.core';
@@ -261,7 +261,13 @@ describe(JobService.name, () => {
},
{
item: { name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-1' } },
jobs: [JobName.GENERATE_WEBP_THUMBNAIL, JobName.CLASSIFY_IMAGE, JobName.ENCODE_CLIP, JobName.RECOGNIZE_FACES],
jobs: [
JobName.GENERATE_WEBP_THUMBNAIL,
JobName.CLASSIFY_IMAGE,
JobName.ENCODE_CLIP,
JobName.RECOGNIZE_FACES,
JobName.GENERATE_THUMBHASH_THUMBNAIL,
],
},
{
item: { name: JobName.CLASSIFY_IMAGE, data: { id: 'asset-1' } },

View File

@@ -23,22 +23,28 @@ export class JobService {
this.configCore = new SystemConfigCore(configRepository);
}
handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<void> {
async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<JobStatusDto> {
this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`);
switch (dto.command) {
case JobCommand.START:
return this.start(queueName, dto);
await this.start(queueName, dto);
break;
case JobCommand.PAUSE:
return this.jobRepository.pause(queueName);
await this.jobRepository.pause(queueName);
break;
case JobCommand.RESUME:
return this.jobRepository.resume(queueName);
await this.jobRepository.resume(queueName);
break;
case JobCommand.EMPTY:
return this.jobRepository.empty(queueName);
await this.jobRepository.empty(queueName);
break;
}
return this.getJobStatus(queueName);
}
async getJobStatus(queueName: QueueName): Promise<JobStatusDto> {
@@ -154,6 +160,7 @@ export class JobService {
case JobName.GENERATE_JPEG_THUMBNAIL: {
await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: item.data });
await this.jobRepository.queue({ name: JobName.GENERATE_THUMBHASH_THUMBNAIL, data: item.data });
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: item.data });
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: item.data });
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: item.data });

View File

@@ -47,6 +47,7 @@ export interface IMediaRepository {
// image
resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void>;
crop(input: string, options: CropOptions): Promise<Buffer>;
generateThumbhash(imagePath: string): Promise<Buffer>;
// video
extractVideoThumbnail(input: string, output: string, size: number): Promise<void>;

View File

@@ -54,9 +54,9 @@ describe(MediaService.name, () => {
});
});
it('should queue all assets with missing thumbnails', async () => {
it('should queue all assets with missing resize path', async () => {
assetMock.getWithout.mockResolvedValue({
items: [assetEntityStub.image],
items: [assetEntityStub.noResizePath],
hasNextPage: false,
});
@@ -69,6 +69,38 @@ describe(MediaService.name, () => {
data: { id: assetEntityStub.image.id },
});
});
it('should queue all assets with missing webp path', async () => {
assetMock.getWithout.mockResolvedValue({
items: [assetEntityStub.noWebpPath],
hasNextPage: false,
});
await sut.handleQueueGenerateThumbnails({ force: false });
expect(assetMock.getAll).not.toHaveBeenCalled();
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.GENERATE_WEBP_THUMBNAIL,
data: { id: assetEntityStub.image.id },
});
});
it('should queue all assets with missing thumbhash', async () => {
assetMock.getWithout.mockResolvedValue({
items: [assetEntityStub.noThumbhash],
hasNextPage: false,
});
await sut.handleQueueGenerateThumbnails({ force: false });
expect(assetMock.getAll).not.toHaveBeenCalled();
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.GENERATE_THUMBHASH_THUMBNAIL,
data: { id: assetEntityStub.image.id },
});
});
});
describe('handleGenerateJpegThumbnail', () => {
@@ -129,6 +161,25 @@ describe(MediaService.name, () => {
});
});
describe('handleGenerateThumbhashThumbnail', () => {
it('should skip thumbhash generation if resize path is missing', async () => {
assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]);
await sut.handleGenerateThumbhashThumbnail({ id: assetEntityStub.noResizePath.id });
expect(mediaMock.generateThumbhash).not.toHaveBeenCalled();
});
it('should generate a thumbhash', async () => {
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer);
await sut.handleGenerateThumbhashThumbnail({ id: assetEntityStub.image.id });
expect(mediaMock.generateThumbhash).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext');
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
});
});
describe('handleQueueVideoConversion', () => {
it('should queue all video assets', async () => {
assetMock.getAll.mockResolvedValue({

View File

@@ -37,7 +37,16 @@ export class MediaService {
for await (const assets of assetPagination) {
for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: asset.id } });
if (!asset.resizePath || force) {
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: asset.id } });
continue;
}
if (!asset.webpPath) {
await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { id: asset.id } });
}
if (!asset.thumbhash) {
await this.jobRepository.queue({ name: JobName.GENERATE_THUMBHASH_THUMBNAIL, data: { id: asset.id } });
}
}
}
@@ -87,6 +96,18 @@ export class MediaService {
return true;
}
async handleGenerateThumbhashThumbnail({ id }: IEntityJob): Promise<boolean> {
const [asset] = await this.assetRepository.getByIds([id]);
if (!asset?.resizePath) {
return false;
}
const thumbhash = await this.mediaRepository.generateThumbhash(asset.resizePath);
await this.assetRepository.save({ id: asset.id, thumbhash });
return true;
}
async handleQueueVideoConversion(job: IBaseJob) {
const { force } = job;

View File

@@ -1,5 +1,5 @@
import { constants } from 'fs/promises';
import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock, newStorageRepositoryMock } from '@test';
import { constants } from 'fs/promises';
import { IAssetRepository, WithoutProperty, WithProperty } from '../asset';
import { IJobRepository, JobName } from '../job';
import { IStorageRepository } from '../storage';

View File

@@ -1,6 +1,5 @@
import { SystemConfig, UserEntity } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import { generators, Issuer } from 'openid-client';
import {
authStub,
loginResponseStub,
@@ -12,6 +11,7 @@ import {
userEntityStub,
userTokenEntityStub,
} from '@test';
import { generators, Issuer } from 'openid-client';
import { OAuthService } from '.';
import { LoginDetails } from '../auth';
import { ICryptoRepository } from '../crypto';

View File

@@ -1,7 +1,7 @@
import { PartnerEntity } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AuthUserDto } from '../auth';
import { IPartnerRepository, PartnerDirection, PartnerIds } from '.';
import { AuthUserDto } from '../auth';
import { mapUser, UserResponseDto } from '../user';
@Injectable()

View File

@@ -1,5 +1,4 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { IJobRepository, JobName } from '..';
import {
assetEntityStub,
authStub,
@@ -8,6 +7,7 @@ import {
newStorageRepositoryMock,
personStub,
} from '@test';
import { IJobRepository, JobName } from '..';
import { IStorageRepository } from '../storage';
import { IPersonRepository } from './person.repository';
import { PersonService } from './person.service';

View File

@@ -1,7 +1,7 @@
import { toBoolean } from '@app/immich/utils/transform.util';
import { AssetType } from '@app/infra/entities';
import { Transform } from 'class-transformer';
import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { toBoolean } from '@app/immich/utils/transform.util';
export class SearchDto {
@IsString()

View File

@@ -1,6 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { plainToInstance } from 'class-transformer';
import {
albumStub,
assetEntityStub,
@@ -15,6 +14,7 @@ import {
newSearchRepositoryMock,
searchStub,
} from '@test';
import { plainToInstance } from 'class-transformer';
import { IAlbumRepository } from '../album/album.repository';
import { IAssetRepository } from '../asset/asset.repository';
import { IFaceRepository } from '../facial-recognition';

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IServerVersion } from '@app/domain';
import { ApiProperty } from '@nestjs/swagger';
export class ServerVersionReponseDto implements IServerVersion {
@ApiProperty({ type: 'integer' })

View File

@@ -1,5 +1,5 @@
export * from './dto';
export * from './response-dto';
export * from './shared-link.core';
export * from './shared-link.service';
export * from './shared-link.repository';
export * from './shared-link.service';

View File

@@ -1,7 +1,7 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { authStub, newSharedLinkRepositoryMock, sharedLinkResponseStub, sharedLinkStub } from '@test';
import { SharedLinkService } from './shared-link.service';
import { ISharedLinkRepository } from './shared-link.repository';
import { SharedLinkService } from './shared-link.service';
describe(SharedLinkService.name, () => {
let sut: SharedLinkService;

View File

@@ -1,4 +1,3 @@
import { when } from 'jest-when';
import {
assetEntityStub,
newAssetRepositoryMock,
@@ -8,8 +7,9 @@ import {
systemConfigStub,
userEntityStub,
} from '@test';
import { IAssetRepository } from '../asset';
import { when } from 'jest-when';
import { StorageTemplateService } from '.';
import { IAssetRepository } from '../asset';
import { IStorageRepository } from '../storage/storage.repository';
import { ISystemConfigRepository } from '../system-config';
import { IUserRepository } from '../user';

View File

@@ -1,7 +1,7 @@
import { IsEnum, IsString, IsInt, IsBoolean, Min, Max } from 'class-validator';
import { TranscodePreset } from '@app/infra/entities';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsInt, IsString, Max, Min } from 'class-validator';
export class SystemConfigFFmpegDto {
@IsInt()

View File

@@ -1,8 +1,8 @@
import { SystemConfig } from '@app/infra/entities';
import { Type } from 'class-transformer';
import { IsObject, ValidateNested } from 'class-validator';
import { SystemConfigJobDto } from './system-config-job.dto';
import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
import { SystemConfigJobDto } from './system-config-job.dto';
import { SystemConfigOAuthDto } from './system-config-oauth.dto';
import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto';
import { SystemConfigStorageTemplateDto } from './system-config-storage-template.dto';

View File

@@ -1,4 +1,8 @@
import { Inject, Injectable } from '@nestjs/common';
import { ISystemConfigRepository } from '.';
import { IJobRepository, JobName } from '../job';
import { mapConfig, SystemConfigDto } from './dto/system-config.dto';
import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto';
import {
supportedDayTokens,
supportedHourTokens,
@@ -8,10 +12,6 @@ import {
supportedSecondTokens,
supportedYearTokens,
} from './system-config.constants';
import { Inject, Injectable } from '@nestjs/common';
import { IJobRepository, JobName } from '../job';
import { mapConfig, SystemConfigDto } from './dto/system-config.dto';
import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto';
import { SystemConfigCore, SystemConfigValidator } from './system-config.core';
@Injectable()

View File

@@ -1,7 +1,7 @@
import { TagType } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import { when } from 'jest-when';
import { assetEntityStub, authStub, newTagRepositoryMock, tagResponseStub, tagStub } from '@test';
import { when } from 'jest-when';
import { AssetIdErrorReason } from '../asset';
import { ITagRepository } from './tag.repository';
import { TagService } from './tag.service';

View File

@@ -1,2 +1,2 @@
export * from './user-token.repository';
export * from './user-token.core';
export * from './user-token.repository';

View File

@@ -1,6 +1,6 @@
import { toEmail, toSanitized } from '@app/immich/utils/transform.util';
import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { toEmail, toSanitized } from '@app/immich/utils/transform.util';
export class CreateUserDto {
@IsEmail({ require_tld: false })

View File

@@ -1,7 +1,7 @@
import { toEmail, toSanitized } from '@app/immich/utils/transform.util';
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import { toEmail, toSanitized } from '@app/immich/utils/transform.util';
export class UpdateUserDto {
@IsOptional()

View File

@@ -1,6 +1,5 @@
import { UserEntity } from '@app/infra/entities';
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { when } from 'jest-when';
import {
newAlbumRepositoryMock,
newAssetRepositoryMock,
@@ -9,6 +8,7 @@ import {
newStorageRepositoryMock,
newUserRepositoryMock,
} from '@test';
import { when } from 'jest-when';
import { IAlbumRepository } from '../album';
import { IAssetRepository } from '../asset';
import { AuthUserDto } from '../auth';

View File

@@ -9,7 +9,6 @@ import { ICryptoRepository } from '../crypto/crypto.repository';
import { IEntityJob, IJobRepository, JobName } from '../job';
import { StorageCore, StorageFolder } from '../storage';
import { IStorageRepository } from '../storage/storage.repository';
import { IUserRepository } from './user.repository';
import { CreateUserDto, UpdateUserDto, UserCountDto } from './dto';
import {
CreateProfileImageResponseDto,
@@ -20,6 +19,7 @@ import {
UserResponseDto,
} from './response-dto';
import { UserCore } from './user.core';
import { IUserRepository } from './user.repository';
@Injectable()
export class UserService {

View File

@@ -1,11 +1,10 @@
import { AlbumEntity, AssetEntity } from '@app/infra/entities';
import { dataSource } from '@app/infra/database.config';
import { AlbumEntity, AssetEntity } from '@app/infra/entities';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AddAssetsDto } from './dto/add-assets.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
export interface IAlbumRepository {
@@ -13,8 +12,6 @@ export interface IAlbumRepository {
removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise<number>;
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto>;
updateThumbnails(): Promise<number | undefined>;
getCountByUserId(userId: string): Promise<AlbumCountResponseDto>;
getSharedWithUserAlbumCount(userId: string, assetId: string): Promise<number>;
}
export const IAlbumRepository = 'IAlbumRepository';
@@ -26,14 +23,6 @@ export class AlbumRepository implements IAlbumRepository {
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
) {}
async getCountByUserId(userId: string): Promise<AlbumCountResponseDto> {
const ownedAlbums = await this.albumRepository.find({ where: { ownerId: userId }, relations: ['sharedUsers'] });
const sharedAlbums = await this.albumRepository.count({ where: { sharedUsers: { id: userId } } });
const sharedAlbumCount = ownedAlbums.filter((album) => album.sharedUsers?.length > 0).length;
return new AlbumCountResponseDto(ownedAlbums.length, sharedAlbums, sharedAlbumCount);
}
async get(albumId: string): Promise<AlbumEntity | null> {
return this.albumRepository.findOne({
where: { id: albumId },
@@ -140,25 +129,4 @@ export class AlbumRepository implements IAlbumRepository {
return result.affected;
}
async getSharedWithUserAlbumCount(userId: string, assetId: string): Promise<number> {
return this.albumRepository.count({
where: [
{
ownerId: userId,
assets: {
id: assetId,
},
},
{
sharedUsers: {
id: userId,
},
assets: {
id: assetId,
},
},
],
});
}
}

View File

@@ -1,19 +1,18 @@
import { Controller, Get, Post, Body, Param, Delete, Put, Query, Response } from '@nestjs/common';
import { AlbumService } from './album.service';
import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { AddAssetsDto } from './dto/add-assets.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AlbumResponseDto } from '@app/domain';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
import { Body, Controller, Delete, Get, Param, Post, Put, Query, Response } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Response as Res } from 'express';
import { DownloadDto } from '../asset/dto/download-library.dto';
import { CreateAlbumShareLinkDto as CreateAlbumSharedLinkDto } from './dto/create-album-shared-link.dto';
import { UseValidation } from '../../decorators/use-validation.decorator';
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
import { handleDownload } from '../../app.utils';
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
import { AuthUser, AuthUserDto } from '../../decorators/auth-user.decorator';
import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator';
import { UseValidation } from '../../decorators/use-validation.decorator';
import { DownloadDto } from '../asset/dto/download-library.dto';
import { AlbumService } from './album.service';
import { AddAssetsDto } from './dto/add-assets.dto';
import { CreateAlbumShareLinkDto as CreateAlbumSharedLinkDto } from './dto/create-album-shared-link.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
@ApiTags('Album')
@Controller('album')
@@ -22,15 +21,10 @@ import { handleDownload } from '../../app.utils';
export class AlbumController {
constructor(private readonly service: AlbumService) {}
@Get('count-by-user-id')
getAlbumCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
return this.service.getCountByUserId(authUser);
}
@SharedLinkRoute()
@Put(':id/assets')
addAssetsToAlbum(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AddAssetsDto,
): Promise<AddAssetsResponseDto> {
@@ -41,13 +35,13 @@ export class AlbumController {
@SharedLinkRoute()
@Get(':id')
getAlbumInfo(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
getAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
return this.service.get(authUser, id);
}
@Delete(':id/assets')
removeAssetFromAlbum(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Body() dto: RemoveAssetsDto,
@Param() { id }: UUIDParamDto,
): Promise<AlbumResponseDto> {
@@ -58,7 +52,7 @@ export class AlbumController {
@Get(':id/download')
@ApiOkResponse({ content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } } })
downloadArchive(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Query() dto: DownloadDto,
@Response({ passthrough: true }) res: Res,
@@ -67,7 +61,7 @@ export class AlbumController {
}
@Post('create-shared-link')
createAlbumSharedLink(@GetAuthUser() authUser: AuthUserDto, @Body() dto: CreateAlbumSharedLinkDto) {
createAlbumSharedLink(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateAlbumSharedLinkDto) {
return this.service.createSharedLink(authUser, dto);
}
}

View File

@@ -1,20 +1,14 @@
import { Module } from '@nestjs/common';
import { AlbumService } from './album.service';
import { AlbumController } from './album.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AlbumEntity, AssetEntity } from '@app/infra/entities';
import { AlbumRepository, IAlbumRepository } from './album-repository';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DownloadModule } from '../../modules/download/download.module';
const ALBUM_REPOSITORY_PROVIDER = {
provide: IAlbumRepository,
useClass: AlbumRepository,
};
import { AlbumRepository, IAlbumRepository } from './album-repository';
import { AlbumController } from './album.controller';
import { AlbumService } from './album.service';
@Module({
imports: [TypeOrmModule.forFeature([AlbumEntity, AssetEntity]), DownloadModule],
controllers: [AlbumController],
providers: [AlbumService, ALBUM_REPOSITORY_PROVIDER],
exports: [ALBUM_REPOSITORY_PROVIDER],
providers: [AlbumService, { provide: IAlbumRepository, useClass: AlbumRepository }],
})
export class AlbumModule {}

View File

@@ -1,13 +1,12 @@
import { AlbumService } from './album.service';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { NotFoundException, ForbiddenException } from '@nestjs/common';
import { AlbumResponseDto, ICryptoRepository, ISharedLinkRepository, mapUser } from '@app/domain';
import { AlbumEntity, UserEntity } from '@app/infra/entities';
import { AlbumResponseDto, ICryptoRepository, mapUser } from '@app/domain';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
import { IAlbumRepository } from './album-repository';
import { DownloadService } from '../../modules/download/download.service';
import { ISharedLinkRepository } from '@app/domain';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { newCryptoRepositoryMock, newSharedLinkRepositoryMock, userEntityStub } from '@test';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { DownloadService } from '../../modules/download/download.service';
import { IAlbumRepository } from './album-repository';
import { AlbumService } from './album.service';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
describe('Album service', () => {
let sut: AlbumService;
@@ -98,8 +97,6 @@ describe('Album service', () => {
get: jest.fn(),
removeAssets: jest.fn(),
updateThumbnails: jest.fn(),
getCountByUserId: jest.fn(),
getSharedWithUserAlbumCount: jest.fn(),
};
sharedLinkRepositoryMock = newSharedLinkRepositoryMock();

View File

@@ -1,22 +1,22 @@
import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import {
AlbumResponseDto,
ICryptoRepository,
ISharedLinkRepository,
mapAlbum,
mapSharedLink,
SharedLinkCore,
SharedLinkResponseDto,
} from '@app/domain';
import { AlbumEntity, SharedLinkType } from '@app/infra/entities';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { AlbumResponseDto, mapAlbum } from '@app/domain';
import { IAlbumRepository } from './album-repository';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
import { AddAssetsDto } from './dto/add-assets.dto';
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from '../asset/dto/download-library.dto';
import {
SharedLinkCore,
ISharedLinkRepository,
mapSharedLink,
SharedLinkResponseDto,
ICryptoRepository,
} from '@app/domain';
import { IAlbumRepository } from './album-repository';
import { AddAssetsDto } from './dto/add-assets.dto';
import { CreateAlbumShareLinkDto } from './dto/create-album-shared-link.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
@Injectable()
export class AlbumService {
@@ -90,10 +90,6 @@ export class AlbumService {
};
}
async getCountByUserId(authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
return this.albumRepository.getCountByUserId(authUser.id);
}
async downloadArchive(authUser: AuthUserDto, albumId: string, dto: DownloadDto) {
this.shareCore.checkDownloadAccess(authUser);

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsBoolean, IsDate, IsOptional, IsString } from 'class-validator';

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { AlbumResponseDto } from '@app/domain';
import { ApiProperty } from '@nestjs/swagger';
export class AddAssetsResponseDto {
@ApiProperty({ type: 'integer' })

View File

@@ -1,18 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
export class AlbumCountResponseDto {
@ApiProperty({ type: 'integer' })
owned!: number;
@ApiProperty({ type: 'integer' })
shared!: number;
@ApiProperty({ type: 'integer' })
sharing!: number;
constructor(owned: number, shared: number, sharing: number) {
this.owned = owned;
this.shared = shared;
this.sharing = sharing;
}
}

View File

@@ -1,19 +1,19 @@
import { SearchPropertiesDto } from './dto/search-properties.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm/repository/Repository';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
import { GetAssetCountByTimeBucketDto, TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { In } from 'typeorm/find-options/operator/In';
import { UpdateAssetDto } from './dto/update-asset.dto';
import { IsNull, Not } from 'typeorm';
import { In } from 'typeorm/find-options/operator/In';
import { Repository } from 'typeorm/repository/Repository';
import { AssetSearchDto } from './dto/asset-search.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { GetAssetCountByTimeBucketDto, TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { SearchPropertiesDto } from './dto/search-properties.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
export interface AssetCheck {
id: string;
@@ -39,7 +39,6 @@ export interface IAssetRepository {
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise<AssetCheck[]>;
getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]>;
countByIdAndUser(assetId: string, userId: string): Promise<number>;
}
export const IAssetRepository = 'IAssetRepository';
@@ -329,15 +328,6 @@ export class AssetRepository implements IAssetRepository {
return assets.map((asset) => asset.deviceAssetId);
}
countByIdAndUser(assetId: string, ownerId: string): Promise<number> {
return this.assetRepository.count({
where: {
id: assetId,
ownerId,
},
});
}
private getAssetCount(items: any): AssetCountByUserIdResponseDto {
const assetCountByUserId = new AssetCountByUserIdResponseDto();

View File

@@ -1,62 +1,61 @@
import { AddAssetsDto } from '../album/dto/add-assets.dto';
import { AssetResponseDto, ImmichReadStream, SharedLinkResponseDto } from '@app/domain';
import {
Controller,
Post,
UseInterceptors,
Body,
Controller,
Delete,
Get,
Header,
Headers,
HttpCode,
Param,
ValidationPipe,
ParseFilePipe,
Patch,
Post,
Put,
Query,
Response,
Headers,
Delete,
HttpCode,
Header,
Put,
UploadedFiles,
Patch,
StreamableFile,
ParseFilePipe,
UploadedFiles,
UseInterceptors,
ValidationPipe,
} from '@nestjs/common';
import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator';
import { AssetService } from './asset.service';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { ServeFileDto } from './dto/serve-file.dto';
import { Response as Res } from 'express';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetResponseDto, ImmichReadStream } from '@app/domain';
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto';
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
import { DeleteAssetResponseDto } from './response-dto/delete-asset-response.dto';
import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto';
import { AssetCountByTimeBucketResponseDto } from './response-dto/asset-count-by-time-group-response.dto';
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
import { DownloadDto } from './dto/download-library.dto';
import { DownloadFilesDto } from './dto/download-files.dto';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { SharedLinkResponseDto } from '@app/domain';
import { AssetSearchDto } from './dto/asset-search.dto';
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
import FileNotEmptyValidator from '../validation/file-not-empty-validator';
import { RemoveAssetsDto } from '../album/dto/remove-assets.dto';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
import { AssetBulkUploadCheckResponseDto } from './response-dto/asset-check-response.dto';
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
import { DeviceIdDto } from './dto/device-id.dto';
import { Response as Res } from 'express';
import { handleDownload } from '../../app.utils';
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
import { AuthUser, AuthUserDto } from '../../decorators/auth-user.decorator';
import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator';
import { AddAssetsDto } from '../album/dto/add-assets.dto';
import { RemoveAssetsDto } from '../album/dto/remove-assets.dto';
import FileNotEmptyValidator from '../validation/file-not-empty-validator';
import { AssetService } from './asset.service';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
import { AssetSearchDto } from './dto/asset-search.dto';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { DeviceIdDto } from './dto/device-id.dto';
import { DownloadFilesDto } from './dto/download-files.dto';
import { DownloadDto } from './dto/download-library.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
import { ServeFileDto } from './dto/serve-file.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
import { AssetBulkUploadCheckResponseDto } from './response-dto/asset-check-response.dto';
import { AssetCountByTimeBucketResponseDto } from './response-dto/asset-count-by-time-group-response.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { DeleteAssetResponseDto } from './response-dto/delete-asset-response.dto';
function asStreamableFile({ stream, type, length }: ImmichReadStream) {
return new StreamableFile(stream, { type, length });
@@ -92,7 +91,7 @@ export class AssetController {
type: CreateAssetDto,
})
async uploadFile(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles,
@Body(new ValidationPipe()) dto: CreateAssetDto,
@Response({ passthrough: true }) res: Res,
@@ -121,7 +120,7 @@ export class AssetController {
@SharedLinkRoute()
@Get('/download/:id')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
downloadFile(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
downloadFile(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
return this.assetService.downloadFile(authUser, id).then(asStreamableFile);
}
@@ -129,7 +128,7 @@ export class AssetController {
@Post('/download-files')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
downloadFiles(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Response({ passthrough: true }) res: Res,
@Body(new ValidationPipe()) dto: DownloadFilesDto,
) {
@@ -143,7 +142,7 @@ export class AssetController {
@Get('/download-library')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
downloadLibrary(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Query(new ValidationPipe({ transform: true })) dto: DownloadDto,
@Response({ passthrough: true }) res: Res,
) {
@@ -155,7 +154,7 @@ export class AssetController {
@Header('Cache-Control', 'max-age=31536000')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
serveFile(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Headers() headers: Record<string, string>,
@Response({ passthrough: true }) res: Res,
@Query(new ValidationPipe({ transform: true })) query: ServeFileDto,
@@ -169,7 +168,7 @@ export class AssetController {
@Header('Cache-Control', 'max-age=31536000')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
getAssetThumbnail(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Headers() headers: Record<string, string>,
@Response({ passthrough: true }) res: Res,
@Param() { id }: UUIDParamDto,
@@ -179,23 +178,23 @@ export class AssetController {
}
@Get('/curated-objects')
getCuratedObjects(@GetAuthUser() authUser: AuthUserDto): Promise<CuratedObjectsResponseDto[]> {
getCuratedObjects(@AuthUser() authUser: AuthUserDto): Promise<CuratedObjectsResponseDto[]> {
return this.assetService.getCuratedObject(authUser);
}
@Get('/curated-locations')
getCuratedLocations(@GetAuthUser() authUser: AuthUserDto): Promise<CuratedLocationsResponseDto[]> {
getCuratedLocations(@AuthUser() authUser: AuthUserDto): Promise<CuratedLocationsResponseDto[]> {
return this.assetService.getCuratedLocation(authUser);
}
@Get('/search-terms')
getAssetSearchTerms(@GetAuthUser() authUser: AuthUserDto): Promise<string[]> {
getAssetSearchTerms(@AuthUser() authUser: AuthUserDto): Promise<string[]> {
return this.assetService.getAssetSearchTerm(authUser);
}
@Post('/search')
searchAsset(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: SearchAssetDto,
): Promise<AssetResponseDto[]> {
return this.assetService.searchAsset(authUser, dto);
@@ -203,19 +202,19 @@ export class AssetController {
@Post('/count-by-time-bucket')
getAssetCountByTimeBucket(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: GetAssetCountByTimeBucketDto,
): Promise<AssetCountByTimeBucketResponseDto> {
return this.assetService.getAssetCountByTimeBucket(authUser, dto);
}
@Get('/count-by-user-id')
getAssetCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
getAssetCountByUserId(@AuthUser() authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this.assetService.getAssetCountByUserId(authUser);
}
@Get('/stat/archive')
getArchivedAssetCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
getArchivedAssetCountByUserId(@AuthUser() authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this.assetService.getArchivedAssetCountByUserId(authUser);
}
/**
@@ -229,7 +228,7 @@ export class AssetController {
schema: { type: 'string' },
})
getAllAssets(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Query(new ValidationPipe({ transform: true })) dto: AssetSearchDto,
): Promise<AssetResponseDto[]> {
return this.assetService.getAllAssets(authUser, dto);
@@ -237,7 +236,7 @@ export class AssetController {
@Post('/time-bucket')
getAssetByTimeBucket(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: GetAssetByTimeBucketDto,
): Promise<AssetResponseDto[]> {
return this.assetService.getAssetByTimeBucket(authUser, dto);
@@ -247,7 +246,7 @@ export class AssetController {
* Get all asset of a device that are in the database, ID only.
*/
@Get('/:deviceId')
getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param() { deviceId }: DeviceIdDto) {
getUserAssetsByDeviceId(@AuthUser() authUser: AuthUserDto, @Param() { deviceId }: DeviceIdDto) {
return this.assetService.getUserAssetsByDeviceId(authUser, deviceId);
}
@@ -256,7 +255,7 @@ export class AssetController {
*/
@SharedLinkRoute()
@Get('/assetById/:id')
getAssetById(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto> {
getAssetById(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto> {
return this.assetService.getAssetById(authUser, id);
}
@@ -265,7 +264,7 @@ export class AssetController {
*/
@Put('/:id')
updateAsset(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body(ValidationPipe) dto: UpdateAssetDto,
): Promise<AssetResponseDto> {
@@ -274,7 +273,7 @@ export class AssetController {
@Delete('/')
deleteAsset(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: DeleteAssetDto,
): Promise<DeleteAssetResponseDto[]> {
return this.assetService.deleteAll(authUser, dto);
@@ -287,7 +286,7 @@ export class AssetController {
@Post('/check')
@HttpCode(200)
checkDuplicateAsset(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: CheckDuplicateAssetDto,
): Promise<CheckDuplicateAssetResponseDto> {
return this.assetService.checkDuplicatedAsset(authUser, dto);
@@ -299,7 +298,7 @@ export class AssetController {
@Post('/exist')
@HttpCode(200)
checkExistingAssets(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> {
return this.assetService.checkExistingAssets(authUser, dto);
@@ -311,7 +310,7 @@ export class AssetController {
@Post('/bulk-upload-check')
@HttpCode(200)
bulkUploadCheck(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: AssetBulkUploadCheckDto,
): Promise<AssetBulkUploadCheckResponseDto> {
return this.assetService.bulkUploadCheck(authUser, dto);
@@ -319,7 +318,7 @@ export class AssetController {
@Post('/shared-link')
createAssetsSharedLink(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: CreateAssetsShareLinkDto,
): Promise<SharedLinkResponseDto> {
return this.assetService.createAssetsSharedLink(authUser, dto);
@@ -328,7 +327,7 @@ export class AssetController {
@SharedLinkRoute()
@Patch('/shared-link/add')
addAssetsToSharedLink(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: AddAssetsDto,
): Promise<SharedLinkResponseDto> {
return this.assetService.addAssetsToSharedLink(authUser, dto);
@@ -337,7 +336,7 @@ export class AssetController {
@SharedLinkRoute()
@Patch('/shared-link/remove')
removeAssetsFromSharedLink(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) dto: RemoveAssetsDto,
): Promise<SharedLinkResponseDto> {
return this.assetService.removeAssetsFromSharedLink(authUser, dto);

View File

@@ -1,8 +1,8 @@
import { AuthUserDto, IJobRepository, JobName } from '@app/domain';
import { AssetEntity, AssetType, UserEntity } from '@app/infra/entities';
import { parse } from 'node:path';
import { IAssetRepository } from './asset-repository';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
import { parse } from 'node:path';
export class AssetCore {
constructor(private repository: IAssetRepository, private jobRepository: IJobRepository) {}
@@ -35,6 +35,7 @@ export class AssetCore {
livePhotoVideo: livePhotoAssetId != null ? ({ id: livePhotoAssetId } as AssetEntity) : null,
resizePath: null,
webpPath: null,
thumbhash: null,
encodedVideoPath: null,
tags: [],
sharedLinks: [],

View File

@@ -1,26 +1,18 @@
import { Module } from '@nestjs/common';
import { AssetService } from './asset.service';
import { AssetController } from './asset.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity, ExifEntity } from '@app/infra/entities';
import { AssetRepository, IAssetRepository } from './asset-repository';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DownloadModule } from '../../modules/download/download.module';
import { AlbumModule } from '../album/album.module';
const ASSET_REPOSITORY_PROVIDER = {
provide: IAssetRepository,
useClass: AssetRepository,
};
import { AssetRepository, IAssetRepository } from './asset-repository';
import { AssetController } from './asset.controller';
import { AssetService } from './asset.service';
@Module({
imports: [
//
TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
DownloadModule,
AlbumModule,
],
controllers: [AssetController],
providers: [AssetService, ASSET_REPOSITORY_PROVIDER],
exports: [ASSET_REPOSITORY_PROVIDER],
providers: [AssetService, { provide: IAssetRepository, useClass: AssetRepository }],
})
export class AssetModule {}

View File

@@ -1,13 +1,3 @@
import { IAssetRepository } from './asset-repository';
import { AssetService } from './asset.service';
import { QueryFailedError, Repository } from 'typeorm';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { CreateAssetDto } from './dto/create-asset.dto';
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { DownloadService } from '../../modules/download/download.service';
import { AlbumRepository, IAlbumRepository } from '../album/album-repository';
import {
IAccessRepository,
ICryptoRepository,
@@ -16,6 +6,8 @@ import {
IStorageRepository,
JobName,
} from '@app/domain';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import {
assetEntityStub,
authStub,
@@ -28,10 +20,17 @@ import {
sharedLinkResponseStub,
sharedLinkStub,
} from '@test';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { BadRequestException } from '@nestjs/common';
import { when } from 'jest-when';
import { QueryFailedError, Repository } from 'typeorm';
import { DownloadService } from '../../modules/download/download.service';
import { IAssetRepository } from './asset-repository';
import { AssetService } from './asset.service';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { CreateAssetDto } from './dto/create-asset.dto';
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { AssetRejectReason, AssetUploadAction } from './response-dto/asset-check-response.dto';
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
const _getCreateAssetDto = (): CreateAssetDto => {
const createAssetDto = new CreateAssetDto();
@@ -134,7 +133,6 @@ describe('AssetService', () => {
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
let accessMock: jest.Mocked<IAccessRepository>;
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
@@ -160,13 +158,8 @@ describe('AssetService', () => {
getAssetCountByUserId: jest.fn(),
getArchivedAssetCountByUserId: jest.fn(),
getExistingAssets: jest.fn(),
countByIdAndUser: jest.fn(),
};
albumRepositoryMock = {
getSharedWithUserAlbumCount: jest.fn(),
} as unknown as jest.Mocked<AlbumRepository>;
downloadServiceMock = {
downloadArchive: jest.fn(),
};
@@ -180,7 +173,6 @@ describe('AssetService', () => {
sut = new AssetService(
accessMock,
assetRepositoryMock,
albumRepositoryMock,
a,
downloadServiceMock as DownloadService,
sharedLinkRepositoryMock,
@@ -203,13 +195,13 @@ describe('AssetService', () => {
const dto: CreateAssetsShareLinkDto = { assetIds: [asset1.id] };
assetRepositoryMock.getById.mockResolvedValue(asset1);
assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
sharedLinkRepositoryMock.create.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.createAssetsSharedLink(authStub.user1, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
expect(assetRepositoryMock.countByIdAndUser).toHaveBeenCalledWith(asset1.id, authStub.user1.id);
expect(accessMock.hasOwnerAssetAccess).toHaveBeenCalledWith(authStub.user1.id, asset1.id);
});
});
@@ -383,7 +375,7 @@ describe('AssetService', () => {
describe('deleteAll', () => {
it('should return failed status when an asset is missing', async () => {
assetRepositoryMock.get.mockResolvedValue(null);
assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
{ id: 'asset1', status: 'FAILED' },
@@ -395,7 +387,7 @@ describe('AssetService', () => {
it('should return failed status a delete fails', async () => {
assetRepositoryMock.get.mockResolvedValue({ id: 'asset1' } as AssetEntity);
assetRepositoryMock.remove.mockRejectedValue('delete failed');
assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
{ id: 'asset1', status: 'FAILED' },
@@ -405,7 +397,7 @@ describe('AssetService', () => {
});
it('should delete a live photo', async () => {
assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
await expect(sut.deleteAll(authStub.user1, { ids: [assetEntityStub.livePhotoStillAsset.id] })).resolves.toEqual([
{ id: assetEntityStub.livePhotoStillAsset.id, status: 'SUCCESS' },
@@ -454,7 +446,7 @@ describe('AssetService', () => {
.calledWith(asset2.id)
.mockResolvedValue(asset2 as AssetEntity);
assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'] })).resolves.toEqual([
{ id: 'asset1', status: 'SUCCESS' },
@@ -499,7 +491,7 @@ describe('AssetService', () => {
describe('downloadFile', () => {
it('should download a single file', async () => {
assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
assetRepositoryMock.get.mockResolvedValue(_getAsset_1());
await sut.downloadFile(authStub.admin, 'id_1');
@@ -535,4 +527,60 @@ describe('AssetService', () => {
expect(assetRepositoryMock.getAssetsByChecksums).toHaveBeenCalledWith(authStub.admin.id, [file1, file2]);
});
});
describe('getAssetById', () => {
it('should allow owner access', async () => {
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
await sut.getAssetById(authStub.admin, assetEntityStub.image.id);
expect(accessMock.hasOwnerAssetAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
});
it('should allow shared link access', async () => {
accessMock.hasSharedLinkAssetAccess.mockResolvedValue(true);
assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
await sut.getAssetById(authStub.adminSharedLink, assetEntityStub.image.id);
expect(accessMock.hasSharedLinkAssetAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLinkId,
assetEntityStub.image.id,
);
});
it('should allow partner sharing access', async () => {
accessMock.hasOwnerAssetAccess.mockResolvedValue(false);
accessMock.hasPartnerAssetAccess.mockResolvedValue(true);
assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
await sut.getAssetById(authStub.admin, assetEntityStub.image.id);
expect(accessMock.hasPartnerAssetAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
});
it('should allow shared album access', async () => {
accessMock.hasOwnerAssetAccess.mockResolvedValue(false);
accessMock.hasPartnerAssetAccess.mockResolvedValue(false);
accessMock.hasAlbumAssetAccess.mockResolvedValue(true);
assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
await sut.getAssetById(authStub.admin, assetEntityStub.image.id);
expect(accessMock.hasAlbumAssetAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
});
it('should throw an error for no access', async () => {
accessMock.hasOwnerAssetAccess.mockResolvedValue(false);
accessMock.hasPartnerAssetAccess.mockResolvedValue(false);
accessMock.hasSharedLinkAssetAccess.mockResolvedValue(false);
accessMock.hasAlbumAssetAccess.mockResolvedValue(false);
await expect(sut.getAssetById(authStub.admin, assetEntityStub.image.id)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(assetRepositoryMock.getById).not.toHaveBeenCalled();
});
it('should throw an error for an invalid shared link', async () => {
accessMock.hasSharedLinkAssetAccess.mockResolvedValue(false);
await expect(sut.getAssetById(authStub.adminSharedLink, assetEntityStub.image.id)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(accessMock.hasOwnerAssetAccess).not.toHaveBeenCalled();
expect(assetRepositoryMock.getById).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,4 +1,20 @@
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import {
AssetResponseDto,
getLivePhotoMotionFilename,
IAccessRepository,
ICryptoRepository,
IJobRepository,
ImmichReadStream,
ISharedLinkRepository,
IStorageRepository,
JobName,
mapAsset,
mapAssetWithoutExif,
mapSharedLink,
SharedLinkCore,
SharedLinkResponseDto,
} from '@app/domain';
import { AssetEntity, AssetType, SharedLinkType } from '@app/infra/entities';
import {
BadRequestException,
ForbiddenException,
@@ -10,64 +26,49 @@ import {
StreamableFile,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { QueryFailedError, Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { AssetEntity, AssetType, SharedLinkType } from '@app/infra/entities';
import { constants, createReadStream, stat } from 'fs';
import { ServeFileDto } from './dto/serve-file.dto';
import { Response as Res } from 'express';
import { promisify } from 'util';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
import { constants, createReadStream, stat } from 'fs';
import fs from 'fs/promises';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import {
AssetResponseDto,
getLivePhotoMotionFilename,
IAccessRepository,
ImmichReadStream,
IStorageRepository,
JobName,
mapAsset,
mapAssetWithoutExif,
} from '@app/domain';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
import { QueryFailedError, Repository } from 'typeorm';
import { promisify } from 'util';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { DownloadService } from '../../modules/download/download.service';
import { AddAssetsDto } from '../album/dto/add-assets.dto';
import { RemoveAssetsDto } from '../album/dto/remove-assets.dto';
import { IAssetRepository } from './asset-repository';
import { AssetCore } from './asset.core';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
import { AssetSearchDto } from './dto/asset-search.dto';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { DownloadFilesDto } from './dto/download-files.dto';
import { DownloadDto } from './dto/download-library.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
import { SearchPropertiesDto } from './dto/search-properties.dto';
import { ServeFileDto } from './dto/serve-file.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
import {
AssetBulkUploadCheckResponseDto,
AssetRejectReason,
AssetUploadAction,
} from './response-dto/asset-check-response.dto';
import {
AssetCountByTimeBucketResponseDto,
mapAssetCountByTimeBucket,
} from './response-dto/asset-count-by-time-group-response.dto';
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { AssetCore } from './asset.core';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
import { ICryptoRepository, IJobRepository } from '@app/domain';
import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from './dto/download-library.dto';
import { IAlbumRepository } from '../album/album-repository';
import { SharedLinkCore } from '@app/domain';
import { ISharedLinkRepository } from '@app/domain';
import { DownloadFilesDto } from './dto/download-files.dto';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { mapSharedLink, SharedLinkResponseDto } from '@app/domain';
import { AssetSearchDto } from './dto/asset-search.dto';
import { AddAssetsDto } from '../album/dto/add-assets.dto';
import { RemoveAssetsDto } from '../album/dto/remove-assets.dto';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
import {
AssetUploadAction,
AssetRejectReason,
AssetBulkUploadCheckResponseDto,
} from './response-dto/asset-check-response.dto';
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
const fileInfo = promisify(stat);
@@ -85,7 +86,6 @@ export class AssetService {
constructor(
@Inject(IAccessRepository) private accessRepository: IAccessRepository,
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
@Inject(IAlbumRepository) private _albumRepository: IAlbumRepository,
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
private downloadService: DownloadService,
@@ -567,31 +567,32 @@ export class AssetService {
const sharedLinkId = authUser.sharedLinkId;
for (const assetId of assetIds) {
// Step 1: Check if asset is part of a public shared
if (sharedLinkId) {
const canAccess = await this.accessRepository.hasSharedLinkAssetAccess(sharedLinkId, assetId);
if (canAccess) {
continue;
}
} else {
// Step 2: Check if user owns asset
if ((await this._assetRepository.countByIdAndUser(assetId, authUser.id)) == 1) {
continue;
}
// Step 3: Check if any partner owns the asset
const canAccess = await this.accessRepository.hasPartnerAssetAccess(authUser.id, assetId);
if (canAccess) {
continue;
}
throw new ForbiddenException();
}
// Avoid additional checks if ownership is required
if (!mustBeOwner) {
// Step 2: Check if asset is part of an album shared with me
if ((await this._albumRepository.getSharedWithUserAlbumCount(authUser.id, assetId)) > 0) {
continue;
}
}
const isOwner = await this.accessRepository.hasOwnerAssetAccess(authUser.id, assetId);
if (isOwner) {
continue;
}
if (mustBeOwner) {
throw new ForbiddenException();
}
const isPartnerShared = await this.accessRepository.hasPartnerAssetAccess(authUser.id, assetId);
if (isPartnerShared) {
continue;
}
const isAlbumShared = await this.accessRepository.hasAlbumAssetAccess(authUser.id, assetId);
if (isAlbumShared) {
continue;
}
throw new ForbiddenException();

View File

@@ -1,4 +1,4 @@
import { ParseUUIDPipe, Injectable, ArgumentMetadata } from '@nestjs/common';
import { ArgumentMetadata, Injectable, ParseUUIDPipe } from '@nestjs/common';
@Injectable()
export class ParseMeUUIDPipe extends ParseUUIDPipe {

View File

@@ -1,9 +1,16 @@
import { AddUsersDto, AlbumService, AuthUserDto, CreateAlbumDto, UpdateAlbumDto } from '@app/domain';
import {
AddUsersDto,
AlbumCountResponseDto,
AlbumService,
AuthUserDto,
CreateAlbumDto,
UpdateAlbumDto,
} from '@app/domain';
import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ParseMeUUIDPipe } from '../api-v1/validation/parse-me-uuid-pipe';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { AuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto';
@@ -15,34 +22,39 @@ import { UUIDParamDto } from './dto/uuid-param.dto';
export class AlbumController {
constructor(private service: AlbumService) {}
@Get('count')
getAlbumCount(@AuthUser() authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
return this.service.getCount(authUser);
}
@Get()
getAllAlbums(@GetAuthUser() authUser: AuthUserDto, @Query() query: GetAlbumsDto) {
getAllAlbums(@AuthUser() authUser: AuthUserDto, @Query() query: GetAlbumsDto) {
return this.service.getAll(authUser, query);
}
@Post()
createAlbum(@GetAuthUser() authUser: AuthUserDto, @Body() dto: CreateAlbumDto) {
createAlbum(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateAlbumDto) {
return this.service.create(authUser, dto);
}
@Patch(':id')
updateAlbumInfo(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateAlbumDto) {
updateAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateAlbumDto) {
return this.service.update(authUser, id, dto);
}
@Delete(':id')
deleteAlbum(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
deleteAlbum(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
return this.service.delete(authUser, id);
}
@Put(':id/users')
addUsersToAlbum(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: AddUsersDto) {
addUsersToAlbum(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: AddUsersDto) {
return this.service.addUsers(authUser, id, dto);
}
@Delete(':id/user/:userId')
removeUserFromAlbum(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
) {

View File

@@ -8,7 +8,7 @@ import {
} from '@app/domain';
import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { AuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto';
@@ -21,23 +21,23 @@ export class APIKeyController {
constructor(private service: APIKeyService) {}
@Post()
createKey(@GetAuthUser() authUser: AuthUserDto, @Body() dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
createKey(@AuthUser() authUser: AuthUserDto, @Body() dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
return this.service.create(authUser, dto);
}
@Get()
getKeys(@GetAuthUser() authUser: AuthUserDto): Promise<APIKeyResponseDto[]> {
getKeys(@AuthUser() authUser: AuthUserDto): Promise<APIKeyResponseDto[]> {
return this.service.getAll(authUser);
}
@Get(':id')
getKey(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {
getKey(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {
return this.service.getById(authUser, id);
}
@Put(':id')
updateKey(
@GetAuthUser() authUser: AuthUserDto,
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: APIKeyUpdateDto,
): Promise<APIKeyResponseDto> {
@@ -45,7 +45,7 @@ export class APIKeyController {
}
@Delete(':id')
deleteKey(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
deleteKey(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(authUser, id);
}
}

View File

@@ -1,6 +1,6 @@
import { SystemConfigService } from '@app/domain';
import { Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiExcludeEndpoint } from '@nestjs/swagger';
import { SystemConfigService } from '@app/domain';
@Controller()
export class AppController {

Some files were not shown because too many files have changed in this diff Show More