Compare commits

..

22 Commits

Author SHA1 Message Date
mertalev
2997d3128b use vbr for qsv if maxrate is set 2024-08-03 11:37:08 -04:00
renovate[bot]
3968d76a57 fix(deps): update machine-learning (#11320) 2024-08-03 09:24:09 -04:00
Zack Pollard
55b31d1ce2 chore(web): fix weblate and other cleanup (#11532) 2024-08-02 13:35:47 +00:00
oidq
37cc6fbf27 fix(web): prevent change-location suggestion race-condition (#11523)
When debouncer activated on deletion, the handleSearchPlaces() function
would fire a request with empty query. UI would then show Immich API error.
2024-08-02 05:52:17 +00:00
Weblate (bot)
899b8a0ce7 chore(web): update translations (#11458)
Translate-URL: https://hosted.weblate.org/projects/immich/immich/bg/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ca/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/he/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/hu/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ko/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/nl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/pl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ro/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ru/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sk/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/tr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/vi/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_Hant/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_SIMPLIFIED/
Translation: Immich/immich

Co-authored-by: Atakan Dulker <atakandulker@gmail.com>
Co-authored-by: Czerjak N <czerjaknorbert@gmail.com>
Co-authored-by: Dmitry Banny <dj.icecore@gmail.com>
Co-authored-by: ElTopo <cameos@gmail.com>
Co-authored-by: Enoé Mugnaschi <enmuro@gmail.com>
Co-authored-by: Junghyuk Kwon <kwon@junghy.uk>
Co-authored-by: Laurentiu <laurfb@gmail.com>
Co-authored-by: Luna Kowalik <0skar16.contact@gmail.com>
Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
Co-authored-by: Pheggas <petko252@gmail.com>
Co-authored-by: Shawn <xiaxinx@gmail.com>
Co-authored-by: Vladimir Petrov (Vlado) <mr.vlado@gmail.com>
Co-authored-by: Voinea Laurentiu Gabriel <gabivoinea29@gmail.com>
Co-authored-by: chapvic <victor@chapaev.org>
Co-authored-by: dkorecko <reset259@gmail.com>
Co-authored-by: dvbthien <dvbthien@dvbthien.onmicrosoft.com>
Co-authored-by: oopzzozzo <ek3ru8m4@gmail.com>
Co-authored-by: 李奕寯 <eugenelego88@gmail.com>
2024-08-01 23:30:44 -04:00
Justin Forseth
d3a5490e71 feat(server): search unknown place (#10866)
* Allow submission of null country

* Update searchAssetBuilder to handle nulls

andWhere({country:null}) produces `"exifInfo"."country" = NULL`. We want
`"exifInfo"."country" IS NULL`, so we have to treat NULL as a special
case

* Allow null country in frontend

* Make the query code a bit more straightforward

* Remove unused brackets import

* Remove log message

* Don't change whitespace for no reason

* Fix prettier style issue

* Update search.dto.ts validators per @jrasm91's recommendation

* Update api types

* Combine null country and state into one guard clause

* chore: clean up

* chore: add e2e for null/empty city, state, country search

* refactor: server returns suggestion for null values

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
2024-08-02 03:27:40 +00:00
Michel Heusschen
3afb5b497f fix(web): correctly format future timeline dates (#11506) 2024-08-01 07:39:26 -04:00
Michel Heusschen
1f0f880ecb fix(web): websocket over ipv6 (#11508) 2024-08-01 07:36:31 -04:00
martyfuhry
2c05ceaf50 fix(server): external domain url validation (#11493)
* fix(web): Changes externalDomain to IsUrl()

* refactor(web): asset viewer actions (#11449)

* refactor(web): asset viewer actions

* motion photo slot and more refactoring

fix(web): Changes externalDomain to IsUrl()

---------

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
2024-07-31 14:09:30 -04:00
Yuvraj P
01f8b7e458 fix(mobile): Crop presets break crop rectangle #11462 (#11467)
Fix Issue 11464
2024-07-31 12:19:19 -05:00
Michel Heusschen
b73f7fe16f refactor: deduplicate MemoryType and ReactionType enums (#11479)
* refactor: deduplicate memorytype and reactiontype enums

* fix mobile
2024-07-31 12:08:31 -05:00
Michel Heusschen
281cfc95a4 refactor(web): asset viewer actions (#11449)
* refactor(web): asset viewer actions

* motion photo slot and more refactoring
2024-07-31 12:25:38 -04:00
renovate[bot]
3a3ea6135e chore(deps): update typescript-projects (#11437)
* chore(deps): update typescript-projects

* chore: formatting

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
2024-07-31 15:40:23 +00:00
Jason Rasmussen
c44271e9b2 fix(deps): vitest@2 (#11491) 2024-07-31 11:26:35 -04:00
Jason Rasmussen
86904a8382 feat(web): more languages (#11488) 2024-07-31 10:26:17 -04:00
renovate[bot]
cf54829b3b chore(deps): update dependency eslint-plugin-unicorn to v55 (#11435)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-07-31 08:49:35 -04:00
dependabot[bot]
990627e00d chore(deps): bump stumpylog/image-cleaner-action from 0.7.0 to 0.8.0 (#11480)
Bumps [stumpylog/image-cleaner-action](https://github.com/stumpylog/image-cleaner-action) from 0.7.0 to 0.8.0.
- [Release notes](https://github.com/stumpylog/image-cleaner-action/releases)
- [Changelog](https://github.com/stumpylog/image-cleaner-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/stumpylog/image-cleaner-action/compare/v0.7.0...v0.8.0)

---
updated-dependencies:
- dependency-name: stumpylog/image-cleaner-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-31 08:48:06 -04:00
Mert
41580696c7 feat(ml): add more search models (#11468)
* update export code

* add uuid glob, sort model names

* add new models to ml, sort names

* add new models to server, sort by dims and name

* typo in name

* update export dependencies

* onnx save function

* format
2024-07-31 04:34:45 +00:00
renovate[bot]
2423bb36c4 chore(deps): update grafana/grafana docker tag to v11.1.3 (#11451)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-07-31 00:09:13 -04:00
Ben McCann
82b899649d fix: make HTML valid (#11465) 2024-07-31 00:05:08 -04:00
Alex
8ee8450d18 chore(mobile): post release task (#11456) 2024-07-30 21:41:10 -05:00
dependabot[bot]
6d47d52b3c chore(deps): bump docker/setup-buildx-action from 3.5.0 to 3.6.1 (#11445)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.5.0 to 3.6.1.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3.5.0...v3.6.1)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-30 16:22:11 -04:00
114 changed files with 8585 additions and 8139 deletions

View File

@@ -59,7 +59,7 @@ jobs:
uses: docker/setup-qemu-action@v3.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.5.0
uses: docker/setup-buildx-action@v3.6.1
- name: Login to GitHub Container Registry
uses: docker/login-action@v3

View File

@@ -35,7 +35,7 @@ jobs:
steps:
- name: Clean temporary images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@v0.7.0
uses: stumpylog/image-cleaner-action/ephemeral@v0.8.0
with:
token: "${{ env.TOKEN }}"
owner: "immich-app"
@@ -64,7 +64,7 @@ jobs:
steps:
- name: Clean untagged images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@v0.7.0
uses: stumpylog/image-cleaner-action/untagged@v0.8.0
with:
token: "${{ env.TOKEN }}"
owner: "immich-app"

View File

@@ -66,7 +66,7 @@ jobs:
uses: docker/setup-qemu-action@v3.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.5.0
uses: docker/setup-buildx-action@v3.6.1
- name: Login to Docker Hub
# Only push to Docker Hub when making a release

1080
cli/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,22 +21,22 @@
"@types/node": "^20.14.12",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"@vitest/coverage-v8": "^1.2.2",
"@vitest/coverage-v8": "^2.0.5",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
"commander": "^12.0.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^54.0.0",
"eslint-plugin-unicorn": "^55.0.0",
"mock-fs": "^5.2.0",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3",
"vite": "^5.0.12",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^1.2.2",
"vitest-fetch-mock": "^0.2.2",
"vitest": "^2.0.5",
"vitest-fetch-mock": "^0.3.0",
"yaml": "^2.3.1"
},
"scripts": {

View File

@@ -87,7 +87,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:11.1.0-ubuntu@sha256:c7fc29ec783d5e7fc1bdfaad6f92345a345cffbc5d21c388ca228175006fc107
image: grafana/grafana:11.1.3-ubuntu@sha256:e10453733015f31103cb530425f32c994816b50102886fa885dafea2c50a711c
volumes:
- grafana-data:/var/lib/grafana

24
docs/package-lock.json generated
View File

@@ -12754,9 +12754,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.39",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz",
"integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==",
"version": "8.4.40",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz",
"integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==",
"funding": [
{
"type": "opencollective",
@@ -13600,9 +13600,9 @@
}
},
"node_modules/prettier": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz",
"integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"dev": true,
"license": "MIT",
"bin": {
@@ -16014,9 +16014,9 @@
}
},
"node_modules/tailwindcss": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.4.tgz",
"integrity": "sha512-ZoyXOdJjISB7/BcLTR6SEsLgKtDStYyYZVLsUtWChO4Ps20CBad7lfJKVDiejocV4ME1hLmyY0WJE3hSDcmQ2A==",
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.7.tgz",
"integrity": "sha512-rxWZbe87YJb4OcSopb7up2Ba4U82BoiSGUdoDr3Ydrg9ckxFS/YWsvhN323GMcddgU65QRy7JndC7ahhInhvlQ==",
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -16376,9 +16376,9 @@
}
},
"node_modules/typescript": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz",
"integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==",
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",

1420
e2e/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,11 +30,11 @@
"@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"@vitest/coverage-v8": "^1.3.0",
"@vitest/coverage-v8": "^2.0.5",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^54.0.0",
"eslint-plugin-unicorn": "^55.0.0",
"exiftool-vendored": "^28.0.0",
"jose": "^5.6.3",
"luxon": "^3.4.4",
@@ -47,7 +47,7 @@
"supertest": "^7.0.0",
"typescript": "^5.3.3",
"utimes": "^5.2.1",
"vitest": "^1.6.0"
"vitest": "^2.0.5"
},
"volta": {
"node": "20.16.0"

View File

@@ -1,4 +1,4 @@
import { AssetMediaResponseDto, LoginResponseDto, deleteAssets, getMapMarkers, updateAsset } from '@immich/sdk';
import { AssetMediaResponseDto, LoginResponseDto, deleteAssets, updateAsset } from '@immich/sdk';
import { DateTime } from 'luxon';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
@@ -32,9 +32,6 @@ describe('/search', () => {
let assetOneJpg5: AssetMediaResponseDto;
let assetSprings: AssetMediaResponseDto;
let assetLast: AssetMediaResponseDto;
let cities: string[];
let states: string[];
let countries: string[];
beforeAll(async () => {
await utils.resetDatabase();
@@ -85,7 +82,7 @@ describe('/search', () => {
// note: the coordinates here are not the actual coordinates of the images and are random for most of them
const coordinates = [
{ latitude: 48.853_41, longitude: 2.3488 }, // paris
{ latitude: 63.0695, longitude: -151.0074 }, // denali
{ latitude: 35.6895, longitude: 139.691_71 }, // tokyo
{ latitude: 52.524_37, longitude: 13.410_53 }, // berlin
{ latitude: 1.314_663_1, longitude: 103.845_409_3 }, // singapore
{ latitude: 41.013_84, longitude: 28.949_66 }, // istanbul
@@ -101,16 +98,15 @@ describe('/search', () => {
{ latitude: 31.634_16, longitude: -7.999_94 }, // marrakesh
{ latitude: 38.523_735_4, longitude: -78.488_619_4 }, // tanners ridge
{ latitude: 59.938_63, longitude: 30.314_13 }, // st. petersburg
{ latitude: 35.6895, longitude: 139.691_71 }, // tokyo
];
const updates = assets.map((asset, i) =>
updateAsset({ id: asset.id, updateAssetDto: coordinates[i] }, { headers: asBearerAuth(admin.accessToken) }),
const updates = coordinates.map((dto, i) =>
updateAsset({ id: assets[i].id, updateAssetDto: dto }, { headers: asBearerAuth(admin.accessToken) }),
);
await Promise.all(updates);
for (const asset of assets) {
await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
for (const [i] of coordinates.entries()) {
await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: assets[i].id });
}
[
@@ -137,12 +133,6 @@ describe('/search', () => {
assetLast = assets.at(-1) as AssetMediaResponseDto;
await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) });
const mapMarkers = await getMapMarkers({}, { headers: asBearerAuth(admin.accessToken) });
const nonTrashed = mapMarkers.filter((mark) => mark.id !== assetSilver.id);
cities = [...new Set(nonTrashed.map((mark) => mark.city).filter((entry): entry is string => !!entry))].sort();
states = [...new Set(nonTrashed.map((mark) => mark.state).filter((entry): entry is string => !!entry))].sort();
countries = [...new Set(nonTrashed.map((mark) => mark.country).filter((entry): entry is string => !!entry))].sort();
}, 30_000);
afterAll(async () => {
@@ -321,23 +311,120 @@ describe('/search', () => {
},
{
should: 'should search by city',
deferred: () => ({ dto: { city: 'Accra' }, assets: [assetHeic] }),
deferred: () => ({
dto: {
city: 'Accra',
includeNull: true,
},
assets: [assetHeic],
}),
},
{
should: "should search city ('')",
deferred: () => ({
dto: {
city: '',
isVisible: true,
includeNull: true,
},
assets: [assetLast],
}),
},
{
should: 'should search city (null)',
deferred: () => ({
dto: {
city: null,
isVisible: true,
includeNull: true,
},
assets: [assetLast],
}),
},
{
should: 'should search by state',
deferred: () => ({ dto: { state: 'New York' }, assets: [assetDensity] }),
deferred: () => ({
dto: {
state: 'New York',
includeNull: true,
},
assets: [assetDensity],
}),
},
{
should: "should search state ('')",
deferred: () => ({
dto: {
state: '',
isVisible: true,
withExif: true,
includeNull: true,
},
assets: [assetLast, assetNotocactus],
}),
},
{
should: 'should search state (null)',
deferred: () => ({
dto: {
state: null,
isVisible: true,
includeNull: true,
},
assets: [assetLast, assetNotocactus],
}),
},
{
should: 'should search by country',
deferred: () => ({ dto: { country: 'France' }, assets: [assetFalcon] }),
deferred: () => ({
dto: {
country: 'France',
includeNull: true,
},
assets: [assetFalcon],
}),
},
{
should: "should search country ('')",
deferred: () => ({
dto: {
country: '',
isVisible: true,
includeNull: true,
},
assets: [assetLast],
}),
},
{
should: 'should search country (null)',
deferred: () => ({
dto: {
country: null,
isVisible: true,
includeNull: true,
},
assets: [assetLast],
}),
},
{
should: 'should search by make',
deferred: () => ({ dto: { make: 'Canon' }, assets: [assetFalcon, assetDenali] }),
deferred: () => ({
dto: {
make: 'Canon',
includeNull: true,
},
assets: [assetFalcon, assetDenali],
}),
},
{
should: 'should search by model',
deferred: () => ({ dto: { model: 'Canon EOS 7D' }, assets: [assetDenali] }),
deferred: () => ({
dto: {
model: 'Canon EOS 7D',
includeNull: true,
},
assets: [assetDenali],
}),
},
{
should: 'should allow searching the upload library (libraryId: null)',
@@ -450,32 +537,79 @@ describe('/search', () => {
it('should get suggestions for country', async () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=country')
.get('/search/suggestions?type=country&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual(countries);
expect(body).toEqual([
'Cuba',
'France',
'Georgia',
'Germany',
'Ghana',
'Japan',
'Morocco',
"People's Republic of China",
'Russian Federation',
'Singapore',
'Spain',
'Switzerland',
'United States of America',
null,
]);
expect(status).toBe(200);
});
it('should get suggestions for state', async () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=state')
.get('/search/suggestions?type=state&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toHaveLength(states.length);
expect(body).toEqual(expect.arrayContaining(states));
expect(body).toEqual([
'Andalusia',
'Berlin',
'Glarus',
'Greater Accra',
'Havana',
'Île-de-France',
'Marrakesh-Safi',
'Mississippi',
'New York',
'Shanghai',
'St.-Petersburg',
'Tbilisi',
'Tokyo',
'Virginia',
null,
]);
expect(status).toBe(200);
});
it('should get suggestions for city', async () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=city')
.get('/search/suggestions?type=city&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual(cities);
expect(body).toEqual([
'Accra',
'Berlin',
'Glarus',
'Havana',
'Marrakesh',
'Montalbán de Córdoba',
'New York City',
'Novena',
'Paris',
'Philadelphia',
'Saint Petersburg',
'Shanghai',
'Stanley',
'Tbilisi',
'Tokyo',
null,
]);
expect(status).toBe(200);
});
it('should get suggestions for camera make', async () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=camera-make')
.get('/search/suggestions?type=camera-make&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([
'Apple',
@@ -485,13 +619,14 @@ describe('/search', () => {
'PENTAX Corporation',
'samsung',
'SONY',
null,
]);
expect(status).toBe(200);
});
it('should get suggestions for camera model', async () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=camera-model')
.get('/search/suggestions?type=camera-model&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([
'Canon EOS 7D',
@@ -506,6 +641,7 @@ describe('/search', () => {
'SM-F711N',
'SM-S906U',
'SM-T970',
null,
]);
expect(status).toBe(200);
});

View File

@@ -424,12 +424,12 @@ export const utils = {
createPartner: (accessToken: string, id: string) => createPartner({ id }, { headers: asBearerAuth(accessToken) }),
setAuthCookies: async (context: BrowserContext, accessToken: string) =>
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') =>
await context.addCookies([
{
name: 'immich_access_token',
value: accessToken,
domain: '127.0.0.1',
domain,
path: '/',
expires: 1_742_402_728,
httpOnly: true,
@@ -439,7 +439,7 @@ export const utils = {
{
name: 'immich_auth_type',
value: 'password',
domain: '127.0.0.1',
domain,
path: '/',
expires: 1_742_402_728,
httpOnly: true,
@@ -449,7 +449,7 @@ export const utils = {
{
name: 'immich_is_authenticated',
value: 'true',
domain: '127.0.0.1',
domain,
path: '/',
expires: 1_742_402_728,
httpOnly: false,

View File

@@ -10,6 +10,9 @@ test.describe('Asset Viewer Navbar', () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test.beforeEach(async () => {
asset = await utils.createAsset(admin.accessToken);
});
@@ -49,4 +52,14 @@ test.describe('Asset Viewer Navbar', () => {
}
});
});
test.describe('actions', () => {
test('favorite asset with shortcut', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${asset.id}`);
await page.waitForSelector('#immich-asset-viewer');
await page.keyboard.press('f');
await expect(page.locator('#notification-list').getByTestId('message')).toHaveText('Added to favorites');
});
});
});

View File

@@ -0,0 +1,56 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { expect, type Page, test } from '@playwright/test';
import { utils } from 'src/utils';
test.describe('Slideshow', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken);
});
const openSlideshow = async (page: Page) => {
await page.goto(`/photos/${asset.id}`);
await page.waitForSelector('#immich-asset-viewer');
await page.getByRole('button', { name: 'More' }).click();
await page.getByRole('menuitem', { name: 'Slideshow' }).click();
};
test('open slideshow', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await openSlideshow(page);
await expect(page.getByRole('button', { name: 'Exit Slideshow' })).toBeVisible();
});
test('exit slideshow with button', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await openSlideshow(page);
const exitButton = page.getByRole('button', { name: 'Exit Slideshow' });
await exitButton.click();
await expect(exitButton).not.toBeVisible();
});
test('exit slideshow with shortcut', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await openSlideshow(page);
const exitButton = page.getByRole('button', { name: 'Exit Slideshow' });
await expect(exitButton).toBeVisible();
await page.keyboard.press('Escape');
await expect(exitButton).not.toBeVisible();
});
test('favorite shortcut is disabled', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await openSlideshow(page);
await expect(page.getByRole('button', { name: 'Exit Slideshow' })).toBeVisible();
await page.keyboard.press('f');
await expect(page.locator('#notification-list')).not.toBeVisible();
});
});

View File

@@ -0,0 +1,25 @@
import { LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { utils } from 'src/utils';
test.describe('Websocket', () => {
let admin: LoginResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test('connects using ipv4', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('http://127.0.0.1:2283/');
await expect(page.locator('#sidebar')).toContainText('Server Online');
});
test('connects using ipv6', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken, '[::1]');
await page.goto('http://[::1]:2283/');
await expect(page.locator('#sidebar')).toContainText('Server Online');
});
});

View File

@@ -13,6 +13,7 @@ export default defineConfig({
include: ['src/{api,cli,immich-admin}/specs/*.e2e-spec.ts'],
globalSetup,
testTimeout: 15_000,
pool: 'threads',
poolOptions: {
threads: {
singleThread: true,

View File

@@ -1,6 +1,6 @@
ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:ef4b550f029a76b94f8e6cc6e4a8ed0e870fc6c5af1c4e9d77faaea50f41f6cd as builder-cpu
FROM python:3.11-bookworm@sha256:f89d36dbb4728313572f88877b8be7d11fd03bea964cdf0a6b0f61edfcde3709 as builder-cpu
FROM builder-cpu as builder-openvino
@@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv
COPY poetry.lock pyproject.toml ./
RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev
FROM python:3.11-slim-bookworm@sha256:ee317183d292ee6ed30e90bc325043ca3f7d2e8c79ac5019575490b5256ae244 as prod-cpu
FROM python:3.11-slim-bookworm@sha256:7f49f147e57a65a5ca731203ed350ac5c88fa54aeb942924dd7057fe34a18e79 as prod-cpu
FROM prod-cpu as prod-openvino

View File

@@ -65,7 +65,7 @@ class Ann(metaclass=_Singleton):
self.input_shapes: dict[int, tuple[tuple[int], ...]] = {}
self.ann: int | None = None
self.new()
if self.tuning_file is not None:
# make sure tuning file exists (without clearing contents)
# once filled, the tuning file reduces the cost/time of the first
@@ -105,7 +105,7 @@ class Ann(metaclass=_Singleton):
raise ValueError("model_path must be a file with extension .armnn, .tflite or .onnx")
if not exists(model_path):
raise ValueError("model_path must point to an existing file!")
save_cached_network = False
if cached_network_path is not None and not exists(cached_network_path):
save_cached_network = True

View File

@@ -2,53 +2,64 @@ from app.config import clean_name
from app.schemas import ModelSource
_OPENCLIP_MODELS = {
"RN50__openai",
"RN50__yfcc15m",
"RN50__cc12m",
"RN101__openai",
"RN101__yfcc15m",
"RN50x4__openai",
"RN50__cc12m",
"RN50__openai",
"RN50__yfcc15m",
"RN50x16__openai",
"RN50x4__openai",
"RN50x64__openai",
"ViT-B-32__openai",
"ViT-B-16-SigLIP-256__webli",
"ViT-B-16-SigLIP-384__webli",
"ViT-B-16-SigLIP-512__webli",
"ViT-B-16-SigLIP-i18n-256__webli",
"ViT-B-16-SigLIP__webli",
"ViT-B-16-plus-240__laion400m_e31",
"ViT-B-16-plus-240__laion400m_e32",
"ViT-B-16__laion400m_e31",
"ViT-B-16__laion400m_e32",
"ViT-B-16__openai",
"ViT-B-32__laion2b-s34b-b79k",
"ViT-B-32__laion2b_e16",
"ViT-B-32__laion400m_e31",
"ViT-B-32__laion400m_e32",
"ViT-B-32__laion2b-s34b-b79k",
"ViT-B-16__openai",
"ViT-B-16__laion400m_e31",
"ViT-B-16__laion400m_e32",
"ViT-B-16-plus-240__laion400m_e31",
"ViT-B-16-plus-240__laion400m_e32",
"ViT-L-14__openai",
"ViT-B-32__openai",
"ViT-H-14-378-quickgelu__dfn5b",
"ViT-H-14-quickgelu__dfn5b",
"ViT-H-14__laion2b-s32b-b79k",
"ViT-L-14-336__openai",
"ViT-L-14-quickgelu__dfn2b",
"ViT-L-14__laion2b-s32b-b82k",
"ViT-L-14__laion400m_e31",
"ViT-L-14__laion400m_e32",
"ViT-L-14__laion2b-s32b-b82k",
"ViT-L-14-336__openai",
"ViT-H-14__laion2b-s32b-b79k",
"ViT-L-14__openai",
"ViT-L-16-SigLIP-256__webli",
"ViT-L-16-SigLIP-384__webli",
"ViT-SO400M-14-SigLIP-384__webli",
"ViT-g-14__laion2b-s12b-b42k",
"ViT-L-14-quickgelu__dfn2b",
"ViT-H-14-quickgelu__dfn5b",
"ViT-H-14-378-quickgelu__dfn5b",
"XLM-Roberta-Base-ViT-B-32__laion5b_s13b_b90k",
"XLM-Roberta-Large-ViT-H-14__frozen_laion5b_s13b_b90k",
"nllb-clip-base-siglip__mrl",
"nllb-clip-base-siglip__v1",
"nllb-clip-large-siglip__mrl",
"nllb-clip-large-siglip__v1",
}
_MCLIP_MODELS = {
"LABSE-Vit-L-14",
"XLM-Roberta-Large-Vit-B-32",
"XLM-Roberta-Large-Vit-B-16Plus",
"XLM-Roberta-Large-Vit-B-32",
"XLM-Roberta-Large-Vit-L-14",
}
_INSIGHTFACE_MODELS = {
"antelopev2",
"buffalo_l",
"buffalo_m",
"buffalo_s",
"buffalo_m",
"buffalo_l",
}

View File

@@ -1,4 +1,4 @@
FROM mambaorg/micromamba:bookworm-slim@sha256:94d6837f023c0fc0bb68782dd2a984ff7fe0e21ea7e533056c9b8ca060e31de2 as builder
FROM mambaorg/micromamba:bookworm-slim@sha256:eb744eed8e9308edaea942ddd92ad8da8a9b904ca0796fa240b72de51ce0d353 as builder
ENV TRANSFORMERS_CACHE=/cache \
PYTHONDONTWRITEBYTECODE=1 \

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@ name: base
channels:
- conda-forge
- nvidia
- pytorch-nightly
- pytorch
platforms:
- linux-64
dependencies:
@@ -13,7 +13,7 @@ dependencies:
- orjson==3.*
- pip
- python==3.11.*
- pytorch
- pytorch>=2.3
- rich==13.*
- safetensors==0.*
- setuptools==68.*
@@ -21,5 +21,5 @@ dependencies:
- transformers==4.*
- pip:
- multilingual-clip
- onnx-simplifier
- onnxsim
category: main

View File

@@ -1,3 +1,4 @@
import os
import tempfile
import warnings
from pathlib import Path
@@ -8,7 +9,6 @@ from transformers import AutoTokenizer
from .openclip import OpenCLIPModelConfig
from .openclip import to_onnx as openclip_to_onnx
from .optimize import optimize
from .util import get_model_path
_MCLIP_TO_OPENCLIP = {
@@ -23,18 +23,20 @@ def to_onnx(
model_name: str,
output_dir_visual: Path | str,
output_dir_textual: Path | str,
) -> None:
) -> tuple[Path, Path]:
textual_path = get_model_path(output_dir_textual)
with tempfile.TemporaryDirectory() as tmpdir:
model = MultilingualCLIP.from_pretrained(model_name, cache_dir=tmpdir)
model = MultilingualCLIP.from_pretrained(model_name, cache_dir=os.environ.get("CACHE_DIR", tmpdir))
AutoTokenizer.from_pretrained(model_name).save_pretrained(output_dir_textual)
model.eval()
for param in model.parameters():
param.requires_grad_(False)
export_text_encoder(model, textual_path)
openclip_to_onnx(_MCLIP_TO_OPENCLIP[model_name], output_dir_visual)
optimize(textual_path)
visual_path, _ = openclip_to_onnx(_MCLIP_TO_OPENCLIP[model_name], output_dir_visual)
assert visual_path is not None, "Visual model export failed"
return visual_path, textual_path
def export_text_encoder(model: MultilingualCLIP, output_path: Path | str) -> None:
@@ -58,10 +60,10 @@ def export_text_encoder(model: MultilingualCLIP, output_path: Path | str) -> Non
args,
output_path.as_posix(),
input_names=["input_ids", "attention_mask"],
output_names=["text_embedding"],
output_names=["embedding"],
opset_version=17,
dynamic_axes={
"input_ids": {0: "batch_size", 1: "sequence_length"},
"attention_mask": {0: "batch_size", 1: "sequence_length"},
},
# dynamic_axes={
# "input_ids": {0: "batch_size", 1: "sequence_length"},
# "attention_mask": {0: "batch_size", 1: "sequence_length"},
# },
)

View File

@@ -1,3 +1,4 @@
import os
import tempfile
import warnings
from dataclasses import dataclass, field
@@ -7,7 +8,6 @@ import open_clip
import torch
from transformers import AutoTokenizer
from .optimize import optimize
from .util import get_model_path, save_config
@@ -23,25 +23,28 @@ class OpenCLIPModelConfig:
if open_clip_cfg is None:
raise ValueError(f"Unknown model {self.name}")
self.image_size = open_clip_cfg["vision_cfg"]["image_size"]
self.sequence_length = open_clip_cfg["text_cfg"]["context_length"]
self.sequence_length = open_clip_cfg["text_cfg"].get("context_length", 77)
def to_onnx(
model_cfg: OpenCLIPModelConfig,
output_dir_visual: Path | str | None = None,
output_dir_textual: Path | str | None = None,
) -> None:
) -> tuple[Path | None, Path | None]:
visual_path = None
textual_path = None
with tempfile.TemporaryDirectory() as tmpdir:
model = open_clip.create_model(
model_cfg.name,
pretrained=model_cfg.pretrained,
jit=False,
cache_dir=tmpdir,
cache_dir=os.environ.get("CACHE_DIR", tmpdir),
require_pretrained=True,
)
text_vision_cfg = open_clip.get_model_config(model_cfg.name)
model.eval()
for param in model.parameters():
param.requires_grad_(False)
@@ -53,8 +56,6 @@ def to_onnx(
save_config(text_vision_cfg, output_dir_visual.parent / "config.json")
export_image_encoder(model, model_cfg, visual_path)
optimize(visual_path)
if output_dir_textual is not None:
output_dir_textual = Path(output_dir_textual)
textual_path = get_model_path(output_dir_textual)
@@ -62,7 +63,7 @@ def to_onnx(
tokenizer_name = text_vision_cfg["text_cfg"].get("hf_tokenizer_name", "openai/clip-vit-base-patch32")
AutoTokenizer.from_pretrained(tokenizer_name).save_pretrained(output_dir_textual)
export_text_encoder(model, model_cfg, textual_path)
optimize(textual_path)
return visual_path, textual_path
def export_image_encoder(model: open_clip.CLIP, model_cfg: OpenCLIPModelConfig, output_path: Path | str) -> None:
@@ -83,9 +84,9 @@ def export_image_encoder(model: open_clip.CLIP, model_cfg: OpenCLIPModelConfig,
args,
output_path.as_posix(),
input_names=["image"],
output_names=["image_embedding"],
output_names=["embedding"],
opset_version=17,
dynamic_axes={"image": {0: "batch_size"}},
# dynamic_axes={"image": {0: "batch_size"}},
)
@@ -107,7 +108,7 @@ def export_text_encoder(model: open_clip.CLIP, model_cfg: OpenCLIPModelConfig, o
args,
output_path.as_posix(),
input_names=["text"],
output_names=["text_embedding"],
output_names=["embedding"],
opset_version=17,
dynamic_axes={"text": {0: "batch_size"}},
# dynamic_axes={"text": {0: "batch_size"}},
)

View File

@@ -5,13 +5,26 @@ import onnxruntime as ort
import onnxsim
def save_onnx(model: onnx.ModelProto, output_path: Path | str) -> None:
try:
onnx.save(model, output_path)
except ValueError as e:
if "The proto size is larger than the 2 GB limit." in str(e):
onnx.save(model, output_path, save_as_external_data=True, size_threshold=1_000_000)
else:
raise e
def optimize_onnxsim(model_path: Path | str, output_path: Path | str) -> None:
model_path = Path(model_path)
output_path = Path(output_path)
model = onnx.load(model_path.as_posix())
model, check = onnxsim.simplify(model, skip_shape_inference=True)
model, check = onnxsim.simplify(model)
assert check, "Simplified ONNX model could not be validated"
onnx.save(model, output_path.as_posix())
for file in model_path.parent.iterdir():
if file.name.startswith("Constant") or "onnx" in file.name or file.suffix == ".weight":
file.unlink()
save_onnx(model, output_path)
def optimize_ort(
@@ -33,6 +46,4 @@ def optimize(model_path: Path | str) -> None:
model_path = Path(model_path)
optimize_ort(model_path, model_path)
# onnxsim serializes large models as a blob, which uses much more memory when loading the model at runtime
if not any(file.name.startswith("Constant") for file in model_path.parent.iterdir()):
optimize_onnxsim(model_path, model_path)
optimize_onnxsim(model_path, model_path)

View File

@@ -3,74 +3,111 @@ import os
from pathlib import Path
from tempfile import TemporaryDirectory
from huggingface_hub import create_repo, login, upload_folder
import torch
from huggingface_hub import create_repo, upload_folder
from models import mclip, openclip
from models.optimize import optimize
from rich.progress import Progress
models = [
"RN50::openai",
"RN50::yfcc15m",
"RN50::cc12m",
"M-CLIP/LABSE-Vit-L-14",
"M-CLIP/XLM-Roberta-Large-Vit-B-16Plus",
"M-CLIP/XLM-Roberta-Large-Vit-B-32",
"M-CLIP/XLM-Roberta-Large-Vit-L-14",
"RN101::openai",
"RN101::yfcc15m",
"RN50x4::openai",
"RN50::cc12m",
"RN50::openai",
"RN50::yfcc15m",
"RN50x16::openai",
"RN50x4::openai",
"RN50x64::openai",
"ViT-B-32::openai",
"ViT-B-16-SigLIP-256::webli",
"ViT-B-16-SigLIP-384::webli",
"ViT-B-16-SigLIP-512::webli",
"ViT-B-16-SigLIP-i18n-256::webli",
"ViT-B-16-SigLIP::webli",
"ViT-B-16-plus-240::laion400m_e31",
"ViT-B-16-plus-240::laion400m_e32",
"ViT-B-16::laion400m_e31",
"ViT-B-16::laion400m_e32",
"ViT-B-16::openai",
"ViT-B-32::laion2b-s34b-b79k",
"ViT-B-32::laion2b_e16",
"ViT-B-32::laion400m_e31",
"ViT-B-32::laion400m_e32",
"ViT-B-32::laion2b-s34b-b79k",
"ViT-B-16::openai",
"ViT-B-16::laion400m_e31",
"ViT-B-16::laion400m_e32",
"ViT-B-16-plus-240::laion400m_e31",
"ViT-B-16-plus-240::laion400m_e32",
"ViT-L-14::openai",
"ViT-B-32::openai",
"ViT-H-14-378-quickgelu::dfn5b",
"ViT-H-14-quickgelu::dfn5b",
"ViT-H-14::laion2b-s32b-b79k",
"ViT-L-14-336::openai",
"ViT-L-14-quickgelu::dfn2b",
"ViT-L-14::laion2b-s32b-b82k",
"ViT-L-14::laion400m_e31",
"ViT-L-14::laion400m_e32",
"ViT-L-14::laion2b-s32b-b82k",
"ViT-L-14-336::openai",
"ViT-H-14::laion2b-s32b-b79k",
"ViT-L-14::openai",
"ViT-L-16-SigLIP-256::webli",
"ViT-L-16-SigLIP-384::webli",
"ViT-SO400M-14-SigLIP-384::webli",
"ViT-g-14::laion2b-s12b-b42k",
"M-CLIP/LABSE-Vit-L-14",
"M-CLIP/XLM-Roberta-Large-Vit-B-32",
"M-CLIP/XLM-Roberta-Large-Vit-B-16Plus",
"M-CLIP/XLM-Roberta-Large-Vit-L-14",
"nllb-clip-base-siglip::mrl",
"nllb-clip-base-siglip::v1",
"nllb-clip-large-siglip::mrl",
"nllb-clip-large-siglip::v1",
"xlm-roberta-base-ViT-B-32::laion5b_s13b_b90k",
"xlm-roberta-large-ViT-H-14::frozen_laion5b_s13b_b90k",
]
login(token=os.environ["HF_AUTH_TOKEN"])
# glob to delete old UUID blobs when reuploading models
uuid_char = "[a-fA-F0-9]"
uuid_glob = uuid_char * 8 + "-" + uuid_char * 4 + "-" + uuid_char * 4 + "-" + uuid_char * 4 + "-" + uuid_char * 12
# remote repo files to be deleted before uploading
# deletion is in the same commit as the upload, so it's atomic
delete_patterns = ["**/*onnx*", "**/Constant*", "**/*.weight", "**/*.bias", f"**/{uuid_glob}"]
with Progress() as progress:
task1 = progress.add_task("[green]Exporting models...", total=len(models))
task2 = progress.add_task("[yellow]Uploading models...", total=len(models))
task = progress.add_task("[green]Exporting models...", total=len(models))
token = os.environ.get("HF_AUTH_TOKEN")
torch.backends.mha.set_fastpath_enabled(False)
with TemporaryDirectory() as tmp:
tmpdir = Path(tmp)
for model in models:
model_name = model.split("/")[-1].replace("::", "__")
hf_model_name = model_name.replace("xlm-roberta-large", "XLM-Roberta-Large")
hf_model_name = model_name.replace("xlm-roberta-base", "XLM-Roberta-Base")
config_path = tmpdir / model_name / "config.json"
def upload() -> None:
progress.update(task2, description=f"[yellow]Uploading {model_name}")
repo_id = f"immich-app/{model_name}"
create_repo(repo_id, exist_ok=True)
upload_folder(repo_id=repo_id, folder_path=tmpdir / model_name)
progress.update(task2, advance=1)
def export() -> None:
progress.update(task1, description=f"[green]Exporting {model_name}")
visual_dir = tmpdir / model_name / "visual"
textual_dir = tmpdir / model_name / "textual"
progress.update(task, description=f"[green]Exporting {hf_model_name}")
visual_dir = tmpdir / hf_model_name / "visual"
textual_dir = tmpdir / hf_model_name / "textual"
if model.startswith("M-CLIP"):
mclip.to_onnx(model, visual_dir, textual_dir)
visual_path, textual_path = mclip.to_onnx(model, visual_dir, textual_dir)
else:
name, _, pretrained = model_name.partition("__")
openclip.to_onnx(openclip.OpenCLIPModelConfig(name, pretrained), visual_dir, textual_dir)
config = openclip.OpenCLIPModelConfig(name, pretrained)
visual_path, textual_path = openclip.to_onnx(config, visual_dir, textual_dir)
progress.update(task, description=f"[green]Optimizing {hf_model_name} (visual)")
optimize(visual_path)
progress.update(task, description=f"[green]Optimizing {hf_model_name} (textual)")
optimize(textual_path)
progress.update(task1, advance=1)
gc.collect()
def upload() -> None:
progress.update(task, description=f"[yellow]Uploading {hf_model_name}")
repo_id = f"immich-app/{hf_model_name}"
create_repo(repo_id, exist_ok=True)
upload_folder(
repo_id=repo_id,
folder_path=tmpdir / hf_model_name,
delete_patterns=delete_patterns,
token=token,
)
export()
upload()
if token is not None:
upload()
progress.update(task, advance=1)

View File

@@ -1236,13 +1236,13 @@ socks = ["socksio (==1.*)"]
[[package]]
name = "huggingface-hub"
version = "0.24.0"
version = "0.24.5"
description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub"
optional = false
python-versions = ">=3.8.0"
files = [
{file = "huggingface_hub-0.24.0-py3-none-any.whl", hash = "sha256:7ad92edefb93d8145c061f6df8d99df2ff85f8379ba5fac8a95aca0642afa5d7"},
{file = "huggingface_hub-0.24.0.tar.gz", hash = "sha256:6c7092736b577d89d57b3cdfea026f1b0dc2234ae783fa0d59caf1bf7d52dfa7"},
{file = "huggingface_hub-0.24.5-py3-none-any.whl", hash = "sha256:d93fb63b1f1a919a22ce91a14518974e81fc4610bf344dfe7572343ce8d3aced"},
{file = "huggingface_hub-0.24.5.tar.gz", hash = "sha256:7b45d6744dd53ce9cbf9880957de00e9d10a9ae837f1c9b7255fc8fa4e8264f3"},
]
[package.dependencies]
@@ -2466,13 +2466,13 @@ files = [
[[package]]
name = "pytest"
version = "8.2.2"
version = "8.3.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"},
{file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"},
{file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"},
{file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"},
]
[package.dependencies]
@@ -2480,7 +2480,7 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=1.5,<2.0"
pluggy = ">=1.5,<2"
tomli = {version = ">=1", markers = "python_version < \"3.11\""}
[package.extras]
@@ -2827,29 +2827,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "ruff"
version = "0.5.4"
version = "0.5.6"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.5.4-py3-none-linux_armv6l.whl", hash = "sha256:82acef724fc639699b4d3177ed5cc14c2a5aacd92edd578a9e846d5b5ec18ddf"},
{file = "ruff-0.5.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:da62e87637c8838b325e65beee485f71eb36202ce8e3cdbc24b9fcb8b99a37be"},
{file = "ruff-0.5.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e98ad088edfe2f3b85a925ee96da652028f093d6b9b56b76fc242d8abb8e2059"},
{file = "ruff-0.5.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c55efbecc3152d614cfe6c2247a3054cfe358cefbf794f8c79c8575456efe19"},
{file = "ruff-0.5.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9b85eaa1f653abd0a70603b8b7008d9e00c9fa1bbd0bf40dad3f0c0bdd06793"},
{file = "ruff-0.5.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cf497a47751be8c883059c4613ba2f50dd06ec672692de2811f039432875278"},
{file = "ruff-0.5.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:09c14ed6a72af9ccc8d2e313d7acf7037f0faff43cde4b507e66f14e812e37f7"},
{file = "ruff-0.5.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:628f6b8f97b8bad2490240aa84f3e68f390e13fabc9af5c0d3b96b485921cd60"},
{file = "ruff-0.5.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3520a00c0563d7a7a7c324ad7e2cde2355733dafa9592c671fb2e9e3cd8194c1"},
{file = "ruff-0.5.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93789f14ca2244fb91ed481456f6d0bb8af1f75a330e133b67d08f06ad85b516"},
{file = "ruff-0.5.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:029454e2824eafa25b9df46882f7f7844d36fd8ce51c1b7f6d97e2615a57bbcc"},
{file = "ruff-0.5.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9492320eed573a13a0bc09a2957f17aa733fff9ce5bf00e66e6d4a88ec33813f"},
{file = "ruff-0.5.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a6e1f62a92c645e2919b65c02e79d1f61e78a58eddaebca6c23659e7c7cb4ac7"},
{file = "ruff-0.5.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:768fa9208df2bec4b2ce61dbc7c2ddd6b1be9fb48f1f8d3b78b3332c7d71c1ff"},
{file = "ruff-0.5.4-py3-none-win32.whl", hash = "sha256:e1e7393e9c56128e870b233c82ceb42164966f25b30f68acbb24ed69ce9c3a4e"},
{file = "ruff-0.5.4-py3-none-win_amd64.whl", hash = "sha256:58b54459221fd3f661a7329f177f091eb35cf7a603f01d9eb3eb11cc348d38c4"},
{file = "ruff-0.5.4-py3-none-win_arm64.whl", hash = "sha256:bd53da65f1085fb5b307c38fd3c0829e76acf7b2a912d8d79cadcdb4875c1eb7"},
{file = "ruff-0.5.4.tar.gz", hash = "sha256:2795726d5f71c4f4e70653273d1c23a8182f07dd8e48c12de5d867bfb7557eed"},
{file = "ruff-0.5.6-py3-none-linux_armv6l.whl", hash = "sha256:a0ef5930799a05522985b9cec8290b185952f3fcd86c1772c3bdbd732667fdcd"},
{file = "ruff-0.5.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b652dc14f6ef5d1552821e006f747802cc32d98d5509349e168f6bf0ee9f8f42"},
{file = "ruff-0.5.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:80521b88d26a45e871f31e4b88938fd87db7011bb961d8afd2664982dfc3641a"},
{file = "ruff-0.5.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9bc8f328a9f1309ae80e4d392836e7dbc77303b38ed4a7112699e63d3b066ab"},
{file = "ruff-0.5.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4d394940f61f7720ad371ddedf14722ee1d6250fd8d020f5ea5a86e7be217daf"},
{file = "ruff-0.5.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:111a99cdb02f69ddb2571e2756e017a1496c2c3a2aeefe7b988ddab38b416d36"},
{file = "ruff-0.5.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e395daba77a79f6dc0d07311f94cc0560375ca20c06f354c7c99af3bf4560c5d"},
{file = "ruff-0.5.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c476acb43c3c51e3c614a2e878ee1589655fa02dab19fe2db0423a06d6a5b1b6"},
{file = "ruff-0.5.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e2ff8003f5252fd68425fd53d27c1f08b201d7ed714bb31a55c9ac1d4c13e2eb"},
{file = "ruff-0.5.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c94e084ba3eaa80c2172918c2ca2eb2230c3f15925f4ed8b6297260c6ef179ad"},
{file = "ruff-0.5.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1f77c1c3aa0669fb230b06fb24ffa3e879391a3ba3f15e3d633a752da5a3e670"},
{file = "ruff-0.5.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f908148c93c02873210a52cad75a6eda856b2cbb72250370ce3afef6fb99b1ed"},
{file = "ruff-0.5.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:563a7ae61ad284187d3071d9041c08019975693ff655438d8d4be26e492760bd"},
{file = "ruff-0.5.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:94fe60869bfbf0521e04fd62b74cbca21cbc5beb67cbb75ab33fe8c174f54414"},
{file = "ruff-0.5.6-py3-none-win32.whl", hash = "sha256:e6a584c1de6f8591c2570e171cc7ce482bb983d49c70ddf014393cd39e9dfaed"},
{file = "ruff-0.5.6-py3-none-win_amd64.whl", hash = "sha256:d7fe7dccb1a89dc66785d7aa0ac283b2269712d8ed19c63af908fdccca5ccc1a"},
{file = "ruff-0.5.6-py3-none-win_arm64.whl", hash = "sha256:57c6c0dd997b31b536bff49b9eee5ed3194d60605a4427f735eeb1f9c1b8d264"},
{file = "ruff-0.5.6.tar.gz", hash = "sha256:07c9e3c2a8e1fe377dd460371c3462671a728c981c3205a5217291422209f642"},
]
[[package]]
@@ -3263,13 +3263,13 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "uvicorn"
version = "0.30.1"
version = "0.30.5"
description = "The lightning-fast ASGI server."
optional = false
python-versions = ">=3.8"
files = [
{file = "uvicorn-0.30.1-py3-none-any.whl", hash = "sha256:cd17daa7f3b9d7a24de3617820e634d0933b69eed8e33a516071174427238c81"},
{file = "uvicorn-0.30.1.tar.gz", hash = "sha256:d46cd8e0fd80240baffbcd9ec1012a712938754afcf81bce56c024c1656aece8"},
{file = "uvicorn-0.30.5-py3-none-any.whl", hash = "sha256:b2d86de274726e9878188fa07576c9ceeff90a839e2b6e25c917fe05f5a6c835"},
{file = "uvicorn-0.30.5.tar.gz", hash = "sha256:ac6fdbd4425c5fd17a9fe39daf4d4d075da6fdc80f653e5894cdc2fd98752bee"},
]
[package.dependencies]

View File

@@ -383,7 +383,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 165;
CURRENT_PROJECT_VERSION = 167;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -525,7 +525,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 165;
CURRENT_PROJECT_VERSION = 167;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -553,7 +553,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 165;
CURRENT_PROJECT_VERSION = 167;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -58,11 +58,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.110.0</string>
<string>1.111.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>165</string>
<string>167</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -43,7 +43,7 @@ class Activity {
assetId = dto.assetId,
comment = dto.comment,
createdAt = dto.createdAt,
type = dto.type == ActivityResponseDtoTypeEnum.comment
type = dto.type == ReactionType.comment
? ActivityType.comment
: ActivityType.like,
user = User.fromSimpleUserDto(dto.user);

View File

@@ -192,6 +192,7 @@ class _AspectRatioButton extends StatelessWidget {
: Theme.of(context).iconTheme.color,
),
onPressed: () {
cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9);
aspectRatio.value = ratio;
cropController.aspectRatio = ratio;
},

View File

@@ -111,12 +111,15 @@ class SearchApi {
///
/// * [String] country:
///
/// * [bool] includeNull:
/// This property was added in v111.0.0
///
/// * [String] make:
///
/// * [String] model:
///
/// * [String] state:
Future<Response> getSearchSuggestionsWithHttpInfo(SearchSuggestionType type, { String? country, String? make, String? model, String? state, }) async {
Future<Response> getSearchSuggestionsWithHttpInfo(SearchSuggestionType type, { String? country, bool? includeNull, String? make, String? model, String? state, }) async {
// ignore: prefer_const_declarations
final path = r'/search/suggestions';
@@ -130,6 +133,9 @@ class SearchApi {
if (country != null) {
queryParams.addAll(_queryParams('', 'country', country));
}
if (includeNull != null) {
queryParams.addAll(_queryParams('', 'includeNull', includeNull));
}
if (make != null) {
queryParams.addAll(_queryParams('', 'make', make));
}
@@ -161,13 +167,16 @@ class SearchApi {
///
/// * [String] country:
///
/// * [bool] includeNull:
/// This property was added in v111.0.0
///
/// * [String] make:
///
/// * [String] model:
///
/// * [String] state:
Future<List<String>?> getSearchSuggestions(SearchSuggestionType type, { String? country, String? make, String? model, String? state, }) async {
final response = await getSearchSuggestionsWithHttpInfo(type, country: country, make: make, model: model, state: state, );
Future<List<String>?> getSearchSuggestions(SearchSuggestionType type, { String? country, bool? includeNull, String? make, String? model, String? state, }) async {
final response = await getSearchSuggestionsWithHttpInfo(type, country: country, includeNull: includeNull, make: make, model: model, state: state, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@@ -29,7 +29,7 @@ class ActivityResponseDto {
String id;
ActivityResponseDtoTypeEnum type;
ReactionType type;
UserResponseDto user;
@@ -86,7 +86,7 @@ class ActivityResponseDto {
comment: mapValueOfType<String>(json, r'comment'),
createdAt: mapDateTime(json, r'createdAt', r'')!,
id: mapValueOfType<String>(json, r'id')!,
type: ActivityResponseDtoTypeEnum.fromJson(json[r'type'])!,
type: ReactionType.fromJson(json[r'type'])!,
user: UserResponseDto.fromJson(json[r'user'])!,
);
}
@@ -143,77 +143,3 @@ class ActivityResponseDto {
};
}
class ActivityResponseDtoTypeEnum {
/// Instantiate a new enum with the provided [value].
const ActivityResponseDtoTypeEnum._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const comment = ActivityResponseDtoTypeEnum._(r'comment');
static const like = ActivityResponseDtoTypeEnum._(r'like');
/// List of all possible values in this [enum][ActivityResponseDtoTypeEnum].
static const values = <ActivityResponseDtoTypeEnum>[
comment,
like,
];
static ActivityResponseDtoTypeEnum? fromJson(dynamic value) => ActivityResponseDtoTypeEnumTypeTransformer().decode(value);
static List<ActivityResponseDtoTypeEnum> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ActivityResponseDtoTypeEnum>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ActivityResponseDtoTypeEnum.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [ActivityResponseDtoTypeEnum] to String,
/// and [decode] dynamic data back to [ActivityResponseDtoTypeEnum].
class ActivityResponseDtoTypeEnumTypeTransformer {
factory ActivityResponseDtoTypeEnumTypeTransformer() => _instance ??= const ActivityResponseDtoTypeEnumTypeTransformer._();
const ActivityResponseDtoTypeEnumTypeTransformer._();
String encode(ActivityResponseDtoTypeEnum data) => data.value;
/// Decodes a [dynamic value][data] to a ActivityResponseDtoTypeEnum.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
ActivityResponseDtoTypeEnum? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'comment': return ActivityResponseDtoTypeEnum.comment;
case r'like': return ActivityResponseDtoTypeEnum.like;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [ActivityResponseDtoTypeEnumTypeTransformer] instance.
static ActivityResponseDtoTypeEnumTypeTransformer? _instance;
}

View File

@@ -56,7 +56,7 @@ class MemoryResponseDto {
///
DateTime? seenAt;
MemoryResponseDtoTypeEnum type;
MemoryType type;
DateTime updatedAt;
@@ -133,7 +133,7 @@ class MemoryResponseDto {
memoryAt: mapDateTime(json, r'memoryAt', r'')!,
ownerId: mapValueOfType<String>(json, r'ownerId')!,
seenAt: mapDateTime(json, r'seenAt', r''),
type: MemoryResponseDtoTypeEnum.fromJson(json[r'type'])!,
type: MemoryType.fromJson(json[r'type'])!,
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
);
}
@@ -194,74 +194,3 @@ class MemoryResponseDto {
};
}
class MemoryResponseDtoTypeEnum {
/// Instantiate a new enum with the provided [value].
const MemoryResponseDtoTypeEnum._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const onThisDay = MemoryResponseDtoTypeEnum._(r'on_this_day');
/// List of all possible values in this [enum][MemoryResponseDtoTypeEnum].
static const values = <MemoryResponseDtoTypeEnum>[
onThisDay,
];
static MemoryResponseDtoTypeEnum? fromJson(dynamic value) => MemoryResponseDtoTypeEnumTypeTransformer().decode(value);
static List<MemoryResponseDtoTypeEnum> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MemoryResponseDtoTypeEnum>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MemoryResponseDtoTypeEnum.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [MemoryResponseDtoTypeEnum] to String,
/// and [decode] dynamic data back to [MemoryResponseDtoTypeEnum].
class MemoryResponseDtoTypeEnumTypeTransformer {
factory MemoryResponseDtoTypeEnumTypeTransformer() => _instance ??= const MemoryResponseDtoTypeEnumTypeTransformer._();
const MemoryResponseDtoTypeEnumTypeTransformer._();
String encode(MemoryResponseDtoTypeEnum data) => data.value;
/// Decodes a [dynamic value][data] to a MemoryResponseDtoTypeEnum.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
MemoryResponseDtoTypeEnum? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'on_this_day': return MemoryResponseDtoTypeEnum.onThisDay;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [MemoryResponseDtoTypeEnumTypeTransformer] instance.
static MemoryResponseDtoTypeEnumTypeTransformer? _instance;
}

View File

@@ -64,20 +64,8 @@ class MetadataSearchDto {
///
String? checksum;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? city;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? country;
///
@@ -184,12 +172,6 @@ class MetadataSearchDto {
///
bool? isVisible;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? lensModel;
String? libraryId;
@@ -202,12 +184,6 @@ class MetadataSearchDto {
///
String? make;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? model;
///
@@ -263,12 +239,6 @@ class MetadataSearchDto {
///
num? size;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? state;
///

View File

@@ -46,20 +46,8 @@ class SmartSearchDto {
this.withExif,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? city;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? country;
///
@@ -142,12 +130,6 @@ class SmartSearchDto {
///
bool? isVisible;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? lensModel;
String? libraryId;
@@ -160,12 +142,6 @@ class SmartSearchDto {
///
String? make;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? model;
/// Minimum value: 1
@@ -191,12 +167,6 @@ class SmartSearchDto {
///
num? size;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? state;
///

View File

@@ -4727,6 +4727,15 @@
"type": "string"
}
},
{
"name": "includeNull",
"required": false,
"in": "query",
"description": "This property was added in v111.0.0",
"schema": {
"type": "boolean"
}
},
{
"name": "make",
"required": false,
@@ -7216,11 +7225,7 @@
"type": "string"
},
"type": {
"enum": [
"comment",
"like"
],
"type": "string"
"$ref": "#/components/schemas/ReactionType"
},
"user": {
"$ref": "#/components/schemas/UserResponseDto"
@@ -9311,10 +9316,7 @@
"type": "string"
},
"type": {
"enum": [
"on_this_day"
],
"type": "string"
"$ref": "#/components/schemas/MemoryType"
},
"updatedAt": {
"format": "date-time",
@@ -9385,9 +9387,11 @@
"type": "string"
},
"city": {
"nullable": true,
"type": "string"
},
"country": {
"nullable": true,
"type": "string"
},
"createdAfter": {
@@ -9433,6 +9437,7 @@
"type": "boolean"
},
"lensModel": {
"nullable": true,
"type": "string"
},
"libraryId": {
@@ -9444,6 +9449,7 @@
"type": "string"
},
"model": {
"nullable": true,
"type": "string"
},
"order": {
@@ -9475,6 +9481,7 @@
"type": "number"
},
"state": {
"nullable": true,
"type": "string"
},
"takenAfter": {
@@ -10618,9 +10625,11 @@
"SmartSearchDto": {
"properties": {
"city": {
"nullable": true,
"type": "string"
},
"country": {
"nullable": true,
"type": "string"
},
"createdAfter": {
@@ -10656,6 +10665,7 @@
"type": "boolean"
},
"lensModel": {
"nullable": true,
"type": "string"
},
"libraryId": {
@@ -10667,6 +10677,7 @@
"type": "string"
},
"model": {
"nullable": true,
"type": "string"
},
"page": {
@@ -10689,6 +10700,7 @@
"type": "number"
},
"state": {
"nullable": true,
"type": "string"
},
"takenAfter": {

View File

@@ -32,9 +32,9 @@
}
},
"node_modules/typescript": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz",
"integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==",
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"dev": true,
"license": "Apache-2.0",
"bin": {

View File

@@ -26,7 +26,7 @@ export type ActivityResponseDto = {
comment?: string | null;
createdAt: string;
id: string;
"type": Type;
"type": ReactionType;
user: UserResponseDto;
};
export type ActivityCreateDto = {
@@ -572,7 +572,7 @@ export type MemoryResponseDto = {
memoryAt: string;
ownerId: string;
seenAt?: string;
"type": Type2;
"type": MemoryType;
updatedAt: string;
};
export type MemoryCreateDto = {
@@ -708,8 +708,8 @@ export type SearchExploreResponseDto = {
};
export type MetadataSearchDto = {
checksum?: string;
city?: string;
country?: string;
city?: string | null;
country?: string | null;
createdAfter?: string;
createdBefore?: string;
deviceAssetId?: string;
@@ -723,10 +723,10 @@ export type MetadataSearchDto = {
isNotInAlbum?: boolean;
isOffline?: boolean;
isVisible?: boolean;
lensModel?: string;
lensModel?: string | null;
libraryId?: string | null;
make?: string;
model?: string;
model?: string | null;
order?: AssetOrder;
originalFileName?: string;
originalPath?: string;
@@ -734,7 +734,7 @@ export type MetadataSearchDto = {
personIds?: string[];
previewPath?: string;
size?: number;
state?: string;
state?: string | null;
takenAfter?: string;
takenBefore?: string;
thumbnailPath?: string;
@@ -782,8 +782,8 @@ export type PlacesResponseDto = {
name: string;
};
export type SmartSearchDto = {
city?: string;
country?: string;
city?: string | null;
country?: string | null;
createdAfter?: string;
createdBefore?: string;
deviceId?: string;
@@ -794,15 +794,15 @@ export type SmartSearchDto = {
isNotInAlbum?: boolean;
isOffline?: boolean;
isVisible?: boolean;
lensModel?: string;
lensModel?: string | null;
libraryId?: string | null;
make?: string;
model?: string;
model?: string | null;
page?: number;
personIds?: string[];
query: string;
size?: number;
state?: string;
state?: string | null;
takenAfter?: string;
takenBefore?: string;
trashedAfter?: string;
@@ -2418,8 +2418,9 @@ export function searchSmart({ smartSearchDto }: {
body: smartSearchDto
})));
}
export function getSearchSuggestions({ country, make, model, state, $type }: {
export function getSearchSuggestions({ country, includeNull, make, model, state, $type }: {
country?: string;
includeNull?: boolean;
make?: string;
model?: string;
state?: string;
@@ -2430,6 +2431,7 @@ export function getSearchSuggestions({ country, make, model, state, $type }: {
data: string[];
}>(`/search/suggestions${QS.query(QS.explode({
country,
includeNull,
make,
model,
state,
@@ -3065,10 +3067,6 @@ export enum ReactionType {
Comment = "comment",
Like = "like"
}
export enum Type {
Comment = "comment",
Like = "like"
}
export enum UserAvatarColor {
Primary = "primary",
Pink = "pink",
@@ -3164,9 +3162,6 @@ export enum MapTheme {
Light = "light",
Dark = "dark"
}
export enum Type2 {
OnThisDay = "on_this_day"
}
export enum MemoryType {
OnThisDay = "on_this_day"
}

2787
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -50,7 +50,7 @@
"@opentelemetry/context-async-hooks": "^1.24.0",
"@opentelemetry/exporter-prometheus": "^0.52.0",
"@opentelemetry/sdk-node": "^0.52.0",
"@react-email/components": "^0.0.21",
"@react-email/components": "^0.0.22",
"@socket.io/redis-adapter": "^8.3.0",
"archiver": "^7.0.0",
"async-lock": "^1.4.0",
@@ -115,11 +115,11 @@
"@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"@vitest/coverage-v8": "^1.5.0",
"@vitest/coverage-v8": "^2.0.5",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^54.0.0",
"eslint-plugin-unicorn": "^55.0.0",
"mock-fs": "^5.2.0",
"prettier": "^3.0.2",
"prettier-plugin-organize-imports": "^4.0.0",
@@ -130,8 +130,8 @@
"typescript": "^5.3.3",
"unplugin-swc": "^1.4.5",
"utimes": "^5.2.1",
"vitest": "^1.6.0",
"vite-tsconfig-paths": "^4.3.2"
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^2.0.5"
},
"volta": {
"node": "20.16.0"

View File

@@ -93,39 +93,50 @@ export const supportedPresetTokens = [
type ModelInfo = { dimSize: number };
export const CLIP_MODEL_INFO: Record<string, ModelInfo> = {
RN50__openai: { dimSize: 1024 },
RN50__yfcc15m: { dimSize: 1024 },
RN50__cc12m: { dimSize: 1024 },
RN101__openai: { dimSize: 512 },
RN101__yfcc15m: { dimSize: 512 },
RN50x4__openai: { dimSize: 640 },
RN50x16__openai: { dimSize: 768 },
RN50x64__openai: { dimSize: 1024 },
'ViT-B-32__openai': { dimSize: 512 },
'ViT-B-16__laion400m_e31': { dimSize: 512 },
'ViT-B-16__laion400m_e32': { dimSize: 512 },
'ViT-B-16__openai': { dimSize: 512 },
'ViT-B-32__laion2b-s34b-b79k': { dimSize: 512 },
'ViT-B-32__laion2b_e16': { dimSize: 512 },
'ViT-B-32__laion400m_e31': { dimSize: 512 },
'ViT-B-32__laion400m_e32': { dimSize: 512 },
'ViT-B-32__laion2b-s34b-b79k': { dimSize: 512 },
'ViT-B-16__openai': { dimSize: 512 },
'ViT-B-16__laion400m_e31': { dimSize: 512 },
'ViT-B-16__laion400m_e32': { dimSize: 512 },
'ViT-B-32__openai': { dimSize: 512 },
'XLM-Roberta-Base-ViT-B-32__laion5b_s13b_b90k': { dimSize: 512 },
'XLM-Roberta-Large-Vit-B-32': { dimSize: 512 },
RN50x4__openai: { dimSize: 640 },
'ViT-B-16-plus-240__laion400m_e31': { dimSize: 640 },
'ViT-B-16-plus-240__laion400m_e32': { dimSize: 640 },
'ViT-L-14__openai': { dimSize: 768 },
'ViT-L-14__laion400m_e31': { dimSize: 768 },
'ViT-L-14__laion400m_e32': { dimSize: 768 },
'ViT-L-14__laion2b-s32b-b82k': { dimSize: 768 },
'XLM-Roberta-Large-Vit-B-16Plus': { dimSize: 640 },
'LABSE-Vit-L-14': { dimSize: 768 },
RN50x16__openai: { dimSize: 768 },
'ViT-B-16-SigLIP-256__webli': { dimSize: 768 },
'ViT-B-16-SigLIP-384__webli': { dimSize: 768 },
'ViT-B-16-SigLIP-512__webli': { dimSize: 768 },
'ViT-B-16-SigLIP-i18n-256__webli': { dimSize: 768 },
'ViT-B-16-SigLIP__webli': { dimSize: 768 },
'ViT-L-14-336__openai': { dimSize: 768 },
'ViT-L-14-quickgelu__dfn2b': { dimSize: 768 },
'ViT-H-14__laion2b-s32b-b79k': { dimSize: 1024 },
'ViT-H-14-quickgelu__dfn5b': { dimSize: 1024 },
'ViT-H-14-378-quickgelu__dfn5b': { dimSize: 1024 },
'ViT-g-14__laion2b-s12b-b42k': { dimSize: 1024 },
'LABSE-Vit-L-14': { dimSize: 768 },
'XLM-Roberta-Large-Vit-B-32': { dimSize: 512 },
'XLM-Roberta-Large-Vit-B-16Plus': { dimSize: 640 },
'ViT-L-14__laion2b-s32b-b82k': { dimSize: 768 },
'ViT-L-14__laion400m_e31': { dimSize: 768 },
'ViT-L-14__laion400m_e32': { dimSize: 768 },
'ViT-L-14__openai': { dimSize: 768 },
'XLM-Roberta-Large-Vit-L-14': { dimSize: 768 },
'XLM-Roberta-Large-ViT-H-14__frozen_laion5b_s13b_b90k': { dimSize: 1024 },
'nllb-clip-base-siglip__mrl': { dimSize: 768 },
'nllb-clip-base-siglip__v1': { dimSize: 768 },
RN50__cc12m: { dimSize: 1024 },
RN50__openai: { dimSize: 1024 },
RN50__yfcc15m: { dimSize: 1024 },
RN50x64__openai: { dimSize: 1024 },
'ViT-H-14-378-quickgelu__dfn5b': { dimSize: 1024 },
'ViT-H-14-quickgelu__dfn5b': { dimSize: 1024 },
'ViT-H-14__laion2b-s32b-b79k': { dimSize: 1024 },
'ViT-L-16-SigLIP-256__webli': { dimSize: 1024 },
'ViT-L-16-SigLIP-384__webli': { dimSize: 1024 },
'ViT-g-14__laion2b-s12b-b42k': { dimSize: 1024 },
'XLM-Roberta-Large-ViT-H-14__frozen_laion5b_s13b_b90k': { dimSize: 1024 },
'ViT-SO400M-14-SigLIP-384__webli': { dimSize: 1152 },
'nllb-clip-large-siglip__mrl': { dimSize: 1152 },
'nllb-clip-large-siglip__v1': { dimSize: 1152 },
};

View File

@@ -62,6 +62,7 @@ export class SearchController {
@Get('suggestions')
@Authenticated()
getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise<string[]> {
return this.service.getSearchSuggestions(auth, dto);
// TODO fix open api generation to indicate that results can be nullable
return this.service.getSearchSuggestions(auth, dto) as Promise<string[]>;
}
}

View File

@@ -19,6 +19,7 @@ export type MaybeDuplicate<T> = { duplicate: boolean; value: T };
export class ActivityResponseDto {
id!: string;
createdAt!: Date;
@ApiProperty({ enumName: 'ReactionType', enum: ReactionType })
type!: ReactionType;
user!: UserResponseDto;
assetId!: string | null;
@@ -53,7 +54,7 @@ export class ActivitySearchDto extends ActivityDto {
userId?: string;
}
const isComment = (dto: ActivityCreateDto) => dto.type === 'comment';
const isComment = (dto: ActivityCreateDto) => dto.type === ReactionType.COMMENT;
export class ActivityCreateDto extends ActivityDto {
@IsEnum(ReactionType)

View File

@@ -61,6 +61,7 @@ export class MemoryResponseDto {
memoryAt!: Date;
seenAt?: Date;
ownerId!: string;
@ApiProperty({ enumName: 'MemoryType', enum: MemoryType })
type!: MemoryType;
data!: MemoryData;
isSaved!: boolean;

View File

@@ -1,6 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
import { PropertyLifecycle } from 'src/decorators';
import { AlbumResponseDto } from 'src/dtos/album.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetOrder } from 'src/entities/album.entity';
@@ -75,34 +76,29 @@ class BaseSearchDto {
takenAfter?: Date;
@IsString()
@IsNotEmpty()
@Optional()
city?: string;
@Optional({ nullable: true, emptyToNull: true })
city?: string | null;
@IsString()
@Optional({ nullable: true, emptyToNull: true })
state?: string | null;
@IsString()
@IsNotEmpty()
@Optional()
state?: string;
@Optional({ nullable: true, emptyToNull: true })
country?: string | null;
@IsString()
@IsNotEmpty()
@Optional()
country?: string;
@IsString()
@IsNotEmpty()
@Optional()
@Optional({ nullable: true, emptyToNull: true })
make?: string;
@IsString()
@IsNotEmpty()
@Optional()
model?: string;
@Optional({ nullable: true, emptyToNull: true })
model?: string | null;
@IsString()
@IsNotEmpty()
@Optional()
lensModel?: string;
@Optional({ nullable: true, emptyToNull: true })
lensModel?: string | null;
@IsInt()
@Min(1)
@@ -242,6 +238,10 @@ export class SearchSuggestionRequestDto {
@IsString()
@Optional()
model?: string;
@ValidateBoolean({ optional: true })
@PropertyLifecycle({ addedAt: 'v111.0.0' })
includeNull?: boolean;
}
class SearchFacetCountResponseDto {

View File

@@ -376,7 +376,8 @@ class SystemConfigReverseGeocodingDto {
}
class SystemConfigServerDto {
@IsString()
@ValidateIf((_, value: string) => value !== '')
@IsUrl({ require_tld: false, require_protocol: true, protocols: ['http', 'https'] })
externalDomain!: string;
@IsString()

View File

@@ -26,9 +26,9 @@ export interface IMetadataRepository {
readTags(path: string): Promise<ImmichTags | null>;
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
getCountries(userId: string): Promise<string[]>;
getStates(userId: string, country?: string): Promise<string[]>;
getCities(userId: string, country?: string, state?: string): Promise<string[]>;
getCameraMakes(userId: string, model?: string): Promise<string[]>;
getCameraModels(userId: string, make?: string): Promise<string[]>;
getCountries(userId: string): Promise<Array<string | null>>;
getStates(userId: string, country?: string): Promise<Array<string | null>>;
getCities(userId: string, country?: string, state?: string): Promise<Array<string | null>>;
getCameraMakes(userId: string, model?: string): Promise<Array<string | null>>;
getCameraModels(userId: string, make?: string): Promise<Array<string | null>>;
}

View File

@@ -95,12 +95,12 @@ export interface SearchPathOptions {
}
export interface SearchExifOptions {
city?: string;
country?: string;
lensModel?: string;
make?: string;
model?: string;
state?: string;
city?: string | null;
country?: string | null;
lensModel?: string | null;
make?: string | null;
model?: string | null;
state?: string | null;
}
export interface SearchEmbeddingOptions {

View File

@@ -2,65 +2,55 @@
-- MetadataRepository.getCountries
SELECT DISTINCT
ON ("exif"."country") "exif"."country" AS "exif_country",
"exif"."assetId" AS "exif_assetId"
ON ("exif"."country") "exif"."country" AS "country"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" = $1
AND "exif"."country" IS NOT NULL
-- MetadataRepository.getStates
SELECT DISTINCT
ON ("exif"."state") "exif"."state" AS "exif_state",
"exif"."assetId" AS "exif_assetId"
ON ("exif"."state") "exif"."state" AS "state"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" = $1
AND "exif"."state" IS NOT NULL
AND "exif"."country" = $2
-- MetadataRepository.getCities
SELECT DISTINCT
ON ("exif"."city") "exif"."city" AS "exif_city",
"exif"."assetId" AS "exif_assetId"
ON ("exif"."city") "exif"."city" AS "city"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" = $1
AND "exif"."city" IS NOT NULL
AND "exif"."country" = $2
AND "exif"."state" = $3
-- MetadataRepository.getCameraMakes
SELECT DISTINCT
ON ("exif"."make") "exif"."make" AS "exif_make",
"exif"."assetId" AS "exif_assetId"
ON ("exif"."make") "exif"."make" AS "make"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" = $1
AND "exif"."make" IS NOT NULL
AND "exif"."model" = $2
-- MetadataRepository.getCameraModels
SELECT DISTINCT
ON ("exif"."model") "exif"."model" AS "exif_model",
"exif"."assetId" AS "exif_assetId"
ON ("exif"."model") "exif"."model" AS "model"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" = $1
AND "exif"."model" IS NOT NULL
AND "exif"."make" = $2

View File

@@ -57,49 +57,42 @@ export class MetadataRepository implements IMetadataRepository {
@GenerateSql({ params: [DummyValue.UUID] })
async getCountries(userId: string): Promise<string[]> {
const entity = await this.exifRepository
const results = await this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.country IS NOT NULL')
.select('exif.country')
.select('exif.country', 'country')
.distinctOn(['exif.country'])
.getMany();
.getRawMany<{ country: string }>();
return entity.map((e) => e.country ?? '').filter((c) => c !== '');
return results.map(({ country }) => country).filter((item) => item !== '');
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getStates(userId: string, country: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.state IS NOT NULL')
.select('exif.state')
.select('exif.state', 'state')
.distinctOn(['exif.state']);
if (country) {
query.andWhere('exif.country = :country', { country });
}
result = await query.getMany();
const result = await query.getRawMany<{ state: string }>();
return result.map((entity) => entity.state ?? '').filter((s) => s !== '');
return result.map(({ state }) => state).filter((item) => item !== '');
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, DummyValue.STRING] })
async getCities(userId: string, country: string | undefined, state: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.city IS NOT NULL')
.select('exif.city')
.select('exif.city', 'city')
.distinctOn(['exif.city']);
if (country) {
@@ -110,50 +103,42 @@ export class MetadataRepository implements IMetadataRepository {
query.andWhere('exif.state = :state', { state });
}
result = await query.getMany();
const results = await query.getRawMany<{ city: string }>();
return result.map((entity) => entity.city ?? '').filter((c) => c !== '');
return results.map(({ city }) => city).filter((item) => item !== '');
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getCameraMakes(userId: string, model: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.make IS NOT NULL')
.select('exif.make')
.select('exif.make', 'make')
.distinctOn(['exif.make']);
if (model) {
query.andWhere('exif.model = :model', { model });
}
result = await query.getMany();
return result.map((entity) => entity.make ?? '').filter((m) => m !== '');
const results = await query.getRawMany<{ make: string }>();
return results.map(({ make }) => make).filter((item) => item !== '');
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getCameraModels(userId: string, make: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.model IS NOT NULL')
.select('exif.model')
.select('exif.model', 'model')
.distinctOn(['exif.model']);
if (make) {
query.andWhere('exif.make = :make', { make });
}
result = await query.getMany();
return result.map((entity) => entity.model ?? '').filter((m) => m !== '');
const results = await query.getRawMany<{ model: string }>();
return results.map(({ model }) => model).filter((item) => item !== '');
}
}

View File

@@ -1,4 +1,5 @@
import { mapAsset } from 'src/dtos/asset-response.dto';
import { SearchSuggestionType } from 'src/dtos/search.dto';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
@@ -95,4 +96,22 @@ describe(SearchService.name, () => {
expect(result).toEqual(expectedResponse);
});
});
describe('getSearchSuggestions', () => {
it('should return search suggestions (including null)', async () => {
metadataMock.getCountries.mockResolvedValue(['USA', null]);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.COUNTRY }),
).resolves.toEqual(['USA', null]);
expect(metadataMock.getCountries).toHaveBeenCalledWith(authStub.user1.user.id);
});
it('should return search suggestions (without null)', async () => {
metadataMock.getCountries.mockResolvedValue(['USA', null]);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }),
).resolves.toEqual(['USA']);
expect(metadataMock.getCountries).toHaveBeenCalledWith(authStub.user1.user.id);
});
});
});

View File

@@ -120,22 +120,30 @@ export class SearchService {
return assets.map((asset) => mapAsset(asset));
}
getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise<string[]> {
async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto) {
const results = await this.getSuggestions(auth.user.id, dto);
return results.filter((result) => (dto.includeNull ? true : result !== null));
}
private getSuggestions(userId: string, dto: SearchSuggestionRequestDto) {
switch (dto.type) {
case SearchSuggestionType.COUNTRY: {
return this.metadataRepository.getCountries(auth.user.id);
return this.metadataRepository.getCountries(userId);
}
case SearchSuggestionType.STATE: {
return this.metadataRepository.getStates(auth.user.id, dto.country);
return this.metadataRepository.getStates(userId, dto.country);
}
case SearchSuggestionType.CITY: {
return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
return this.metadataRepository.getCities(userId, dto.country, dto.state);
}
case SearchSuggestionType.CAMERA_MAKE: {
return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
return this.metadataRepository.getCameraMakes(userId, dto.model);
}
case SearchSuggestionType.CAMERA_MODEL: {
return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
return this.metadataRepository.getCameraModels(userId, dto.make);
}
default: {
return [];
}
}
}

View File

@@ -93,7 +93,7 @@ export class SharedLinkService {
password: dto.password,
expiresAt: dto.expiresAt || null,
allowUpload: dto.allowUpload ?? true,
allowDownload: dto.showMetadata === false ? false : dto.allowDownload ?? true,
allowDownload: dto.showMetadata === false ? false : (dto.allowDownload ?? true),
showExif: dto.showMetadata ?? true,
});

View File

@@ -48,7 +48,13 @@ export function searchAssetBuilder(
? builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo')
: builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo');
builder.andWhere({ exifInfo });
for (const [key, value] of Object.entries(exifInfo)) {
if (value === null) {
builder.andWhere(`exifInfo.${key} IS NULL`);
} else {
builder.andWhere(`exifInfo.${key} = :${key}`, { [key]: value });
}
}
}
const id = _.pick(options, ['checksum', 'deviceAssetId', 'deviceId', 'id', 'libraryId']);

View File

@@ -167,7 +167,7 @@ export class BaseConfig implements VideoCodecSWConfig {
return [
`-${this.useCQP() ? 'q:v' : 'crf'} ${this.config.crf}`,
`-maxrate ${bitrates.max}${bitrates.unit}`,
`-bufsize ${bitrates.max * 2}${bitrates.unit}`,
`-bufsize ${bitrates.max * 4}${bitrates.unit}`,
];
} else {
return [`-${this.useCQP() ? 'q:v' : 'crf'} ${this.config.crf}`];
@@ -255,7 +255,7 @@ export class BaseConfig implements VideoCodecSWConfig {
getBitrateUnit() {
const maxBitrate = this.getMaxBitrateValue();
return this.config.maxBitrate.trim().slice(maxBitrate.toString().length); // use inputted unit if provided
return this.config.maxBitrate.trim().slice(maxBitrate.toString().length) || 'k'; // use inputted unit if provided
}
getMaxBitrateValue() {
@@ -575,14 +575,14 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
return [
`-b:v ${bitrates.target}${bitrates.unit}`,
`-maxrate ${bitrates.max}${bitrates.unit}`,
`-bufsize ${bitrates.target}${bitrates.unit}`,
`-bufsize ${bitrates.max * 4}${bitrates.unit}`,
'-multipass 2',
];
} else if (bitrates.max > 0) {
return [
`-cq:v ${this.config.crf}`,
`-maxrate ${bitrates.max}${bitrates.unit}`,
`-bufsize ${bitrates.target}${bitrates.unit}`,
`-bufsize ${bitrates.max * 4}${bitrates.unit}`,
];
} else {
return [`-cq:v ${this.config.crf}`];
@@ -689,13 +689,16 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
}
getBitrateOptions() {
const options = [];
options.push(`-${this.useCQP() ? 'q:v' : 'global_quality:v'} ${this.config.crf}`);
const bitrates = this.getBitrateDistribution();
if (bitrates.max > 0) {
options.push(`-maxrate ${bitrates.max}${bitrates.unit}`, `-bufsize ${bitrates.max * 2}${bitrates.unit}`);
}
return options;
const { max, min, unit, target } = this.getBitrateDistribution();
return max > 0
? [
`-b:v ${target}${unit}`,
`-maxrate ${max}${unit}`,
`-minrate ${min}${unit}`,
`-bufsize ${max * 4}${unit}`,
'-rc_mode 3',
] // QVBR is buggy, so use VBR instead
: [`-${this.useCQP() ? 'q:v' : 'global_quality:v'} ${this.config.crf}`];
}
getSupportedCodecs() {
@@ -823,7 +826,7 @@ export class VAAPIConfig extends BaseHWConfig {
}
getBitrateOptions() {
const bitrates = this.getBitrateDistribution();
const { max, min, unit, target } = this.getBitrateDistribution();
const options = [];
if (this.config.targetVideoCodec === VideoCodec.VP9) {
@@ -831,11 +834,12 @@ export class VAAPIConfig extends BaseHWConfig {
}
// VAAPI doesn't allow setting both quality and max bitrate
if (bitrates.max > 0) {
if (max > 0) {
options.push(
`-b:v ${bitrates.target}${bitrates.unit}`,
`-maxrate ${bitrates.max}${bitrates.unit}`,
`-minrate ${bitrates.min}${bitrates.unit}`,
`-b:v ${target}${unit}`,
`-maxrate ${max}${unit}`,
`-minrate ${min}${unit}`,
`-bufsize ${max * 4}${unit}`,
'-rc_mode 3',
); // variable bitrate
} else if (this.useCQP()) {

View File

@@ -66,6 +66,8 @@ export class UUIDParamDto {
export interface OptionalOptions extends ValidationOptions {
nullable?: boolean;
/** convert empty strings to null */
emptyToNull?: boolean;
}
/**
@@ -76,12 +78,20 @@ export interface OptionalOptions extends ValidationOptions {
* @see IsOptional exported from `class-validator.
*/
// https://stackoverflow.com/a/71353929
export function Optional({ nullable, ...validationOptions }: OptionalOptions = {}) {
export function Optional({ nullable, emptyToNull, ...validationOptions }: OptionalOptions = {}) {
const decorators: PropertyDecorator[] = [];
if (nullable === true) {
return IsOptional(validationOptions);
decorators.push(IsOptional(validationOptions));
} else {
decorators.push(ValidateIf((object: any, v: any) => v !== undefined, validationOptions));
}
return ValidateIf((object: any, v: any) => v !== undefined, validationOptions);
if (emptyToNull) {
decorators.push(Transform(({ value }) => (value === '' ? null : value)));
}
return applyDecorators(...decorators);
}
type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean };

1202
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -38,13 +38,13 @@
"@types/luxon": "^3.4.2",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"@vitest/coverage-v8": "^1.3.1",
"@vitest/coverage-v8": "^2.0.5",
"autoprefixer": "^10.4.17",
"dotenv": "^16.4.5",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1",
"eslint-plugin-unicorn": "^54.0.0",
"eslint-plugin-unicorn": "^55.0.0",
"factory.ts": "^1.4.1",
"postcss": "^8.4.35",
"prettier": "^3.2.5",
@@ -58,7 +58,7 @@
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "^5.1.4",
"vitest": "^1.6.0"
"vitest": "^2.0.5"
},
"type": "module",
"dependencies": {

View File

@@ -41,30 +41,27 @@
{#each groupedAlbums as albumGroup (albumGroup.id)}
{@const isCollapsed = isAlbumGroupCollapsed($albumViewSettings, albumGroup.id)}
{@const iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90'}
<button
type="button"
on:click={() => toggleAlbumGroupCollapsing(albumGroup.id)}
class="flex w-full mt-4 rounded-md"
aria-expanded={!isCollapsed}
<tbody
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg"
>
<tbody
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg"
<tr
class="flex w-full place-items-center p-2 md:pl-5 md:pr-5 md:pt-3 md:pb-3"
on:click={() => toggleAlbumGroupCollapsing(albumGroup.id)}
aria-expanded={!isCollapsed}
>
<tr class="flex w-full place-items-center p-2 md:pl-5 md:pr-5 md:pt-3 md:pb-3">
<td class="text-md text-left -mb-1">
<Icon
path={mdiChevronRight}
size="20"
class="inline-block -mt-2 transition-all duration-[250ms] {iconRotation}"
/>
<span class="font-bold text-2xl">{albumGroup.name}</span>
<span class="ml-1.5">
({$t('albums_count', { values: { count: albumGroup.albums.length } })})
</span>
</td>
</tr>
</tbody>
</button>
<td class="text-md text-left -mb-1">
<Icon
path={mdiChevronRight}
size="20"
class="inline-block -mt-2 transition-all duration-[250ms] {iconRotation}"
/>
<span class="font-bold text-2xl">{albumGroup.name}</span>
<span class="ml-1.5">
({$t('albums_count', { values: { count: albumGroup.albums.length } })})
</span>
</td>
</tr>
</tbody>
{#if !isCollapsed}
<tbody
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg mt-4"

View File

@@ -0,0 +1,20 @@
import type { AssetAction } from '$lib/constants';
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
type ActionMap = {
[AssetAction.ARCHIVE]: { asset: AssetResponseDto };
[AssetAction.UNARCHIVE]: { asset: AssetResponseDto };
[AssetAction.FAVORITE]: { asset: AssetResponseDto };
[AssetAction.UNFAVORITE]: { asset: AssetResponseDto };
[AssetAction.TRASH]: { asset: AssetResponseDto };
[AssetAction.DELETE]: { asset: AssetResponseDto };
[AssetAction.RESTORE]: { asset: AssetResponseDto };
[AssetAction.ADD]: { asset: AssetResponseDto };
[AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto };
[AssetAction.UNSTACK]: { assets: AssetResponseDto[] };
};
export type Action = {
[K in AssetAction]: { type: K } & ActionMap[K];
}[AssetAction];
export type OnAction = (action: Action) => void;

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import type { OnAction } from '$lib/components/asset-viewer/actions/action';
import AlbumSelectionModal from '$lib/components/shared-components/album-selection-modal.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { AssetAction } from '$lib/constants';
import { addAssetsToAlbum, addAssetsToNewAlbum } from '$lib/utils/asset-utils';
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
export let asset: AssetResponseDto;
export let onAction: OnAction;
export let shared = false;
let showSelectionModal = false;
const handleAddToNewAlbum = async (albumName: string) => {
showSelectionModal = false;
const album = await addAssetsToNewAlbum(albumName, [asset.id]);
if (album) {
onAction({ type: AssetAction.ADD_TO_ALBUM, asset, album });
}
};
const handleAddToAlbum = async (album: AlbumResponseDto) => {
showSelectionModal = false;
await addAssetsToAlbum(album.id, [asset.id]);
onAction({ type: AssetAction.ADD_TO_ALBUM, asset, album });
};
</script>
<MenuOption
icon={shared ? mdiShareVariantOutline : mdiImageAlbum}
text={shared ? $t('add_to_shared_album') : $t('add_to_album')}
onClick={() => (showSelectionModal = true)}
/>
{#if showSelectionModal}
<Portal target="body">
<AlbumSelectionModal
{shared}
on:newAlbum={({ detail }) => handleAddToNewAlbum(detail)}
on:album={({ detail }) => handleAddToAlbum(detail)}
onClose={() => (showSelectionModal = false)}
/>
</Portal>
{/if}

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import type { OnAction } from '$lib/components/asset-viewer/actions/action';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AssetAction } from '$lib/constants';
import { toggleArchive } from '$lib/utils/asset-utils';
import type { AssetResponseDto } from '@immich/sdk';
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
export let asset: AssetResponseDto;
export let onAction: OnAction;
const onArchive = async () => {
const updatedAsset = await toggleArchive(asset);
if (updatedAsset) {
onAction({ type: asset.isArchived ? AssetAction.ARCHIVE : AssetAction.UNARCHIVE, asset });
}
};
</script>
<svelte:window use:shortcut={{ shortcut: { key: 'a', shift: true }, onShortcut: onArchive }} />
<MenuOption
icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline}
text={asset.isArchived ? $t('unarchive') : $t('to_archive')}
onClick={onArchive}
/>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { mdiArrowLeft } from '@mdi/js';
import { t } from 'svelte-i18n';
export let onClose: () => void;
</script>
<svelte:window use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
<CircleIconButton color="opaque" icon={mdiArrowLeft} title={$t('go_back')} on:click={onClose} />

View File

@@ -1,20 +1,19 @@
import { type AssetResponseDto } from '@immich/sdk';
import type { AssetResponseDto } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
import '@testing-library/jest-dom';
import { render } from '@testing-library/svelte';
import DeleteButton from './delete-button.svelte';
import DeleteAction from './delete-action.svelte';
let asset: AssetResponseDto;
describe('DeleteButton component', () => {
describe('DeleteAction component', () => {
describe('given an asset which is not trashed yet', () => {
beforeEach(() => {
asset = assetFactory.build({ isTrashed: false });
});
it('displays a button to move the asset to the trash bin', () => {
const { getByTitle, queryByTitle } = render(DeleteButton, { asset });
const { getByTitle, queryByTitle } = render(DeleteAction, { asset, onAction: vi.fn() });
expect(getByTitle('delete')).toBeInTheDocument();
expect(queryByTitle('deletePermanently')).toBeNull();
});
@@ -26,7 +25,7 @@ describe('DeleteButton component', () => {
});
it('displays a button to permanently delete the asset', () => {
const { getByTitle, queryByTitle } = render(DeleteButton, { asset });
const { getByTitle, queryByTitle } = render(DeleteAction, { asset, onAction: vi.fn() });
expect(getByTitle('permanently_delete')).toBeInTheDocument();
expect(queryByTitle('delete')).toBeNull();
});

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { AssetAction } from '$lib/constants';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error';
import { deleteAssets, type AssetResponseDto } from '@immich/sdk';
import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
export let asset: AssetResponseDto;
export let onAction: OnAction;
let showConfirmModal = false;
const trashOrDelete = async (force = false) => {
if (force || !$featureFlags.trash) {
if ($showDeleteModal) {
showConfirmModal = true;
return;
}
await deleteAsset();
return;
}
await trashAsset();
return;
};
const trashAsset = async () => {
try {
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id] } });
onAction({ type: AssetAction.TRASH, asset });
notificationController.show({
message: $t('moved_to_trash'),
type: NotificationType.Info,
});
} catch (error) {
handleError(error, $t('errors.unable_to_trash_asset'));
}
};
const deleteAsset = async () => {
try {
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
onAction({ type: AssetAction.DELETE, asset });
notificationController.show({
message: $t('permanently_deleted_asset'),
type: NotificationType.Info,
});
} catch (error) {
handleError(error, $t('errors.unable_to_delete_asset'));
} finally {
showConfirmModal = false;
}
};
</script>
<svelte:window
use:shortcuts={[
{ shortcut: { key: 'Delete' }, onShortcut: () => trashOrDelete(asset.isTrashed) },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
]}
/>
<CircleIconButton
color="opaque"
icon={asset.isTrashed ? mdiDeleteForeverOutline : mdiDeleteOutline}
title={asset.isTrashed ? $t('permanently_delete') : $t('delete')}
on:click={() => trashOrDelete(asset.isTrashed)}
/>
{#if showConfirmModal}
<Portal target="body">
<DeleteAssetDialog size={1} on:cancel={() => (showConfirmModal = false)} on:confirm={() => deleteAsset()} />
</Portal>
{/if}

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { downloadFile } from '$lib/utils/asset-utils';
import type { AssetResponseDto } from '@immich/sdk';
import { mdiFolderDownloadOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
export let asset: AssetResponseDto;
export let menuItem = false;
const onDownloadFile = () => downloadFile(asset);
</script>
<svelte:window use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: onDownloadFile }} />
{#if !menuItem}
<CircleIconButton color="opaque" icon={mdiFolderDownloadOutline} title={$t('download')} on:click={onDownloadFile} />
{:else}
<MenuOption icon={mdiFolderDownloadOutline} text={$t('download')} onClick={onDownloadFile} />
{/if}

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { mdiHeart, mdiHeartOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
export let asset: AssetResponseDto;
export let onAction: OnAction;
const toggleFavorite = async () => {
try {
const data = await updateAsset({
id: asset.id,
updateAssetDto: {
isFavorite: !asset.isFavorite,
},
});
asset.isFavorite = data.isFavorite;
onAction({ type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE, asset });
notificationController.show({
type: NotificationType.Info,
message: asset.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'),
});
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } }));
}
};
</script>
<svelte:window use:shortcut={{ shortcut: { key: 'f' }, onShortcut: toggleFavorite }} />
<CircleIconButton
color="opaque"
icon={asset.isFavorite ? mdiHeart : mdiHeartOutline}
title={asset.isFavorite ? $t('unfavorite') : $t('to_favorite')}
on:click={toggleFavorite}
/>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { mdiMotionPauseOutline, mdiPlaySpeed } from '@mdi/js';
import { t } from 'svelte-i18n';
export let isPlaying: boolean;
export let onClick: (shouldPlay: boolean) => void;
</script>
<CircleIconButton
color="opaque"
icon={isPlaying ? mdiMotionPauseOutline : mdiPlaySpeed}
title={isPlaying ? $t('stop_motion_photo') : $t('play_motion_photo')}
on:click={() => onClick(!isPlaying)}
/>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiChevronRight } from '@mdi/js';
import { t } from 'svelte-i18n';
import NavigationArea from '../navigation-area.svelte';
export let onNextAsset: () => void;
</script>
<svelte:window use:shortcut={{ shortcut: { key: 'ArrowRight' }, onShortcut: onNextAsset }} />
<NavigationArea onClick={onNextAsset} label={$t('view_next_asset')}>
<Icon path={mdiChevronRight} size="36" ariaHidden />
</NavigationArea>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiChevronLeft } from '@mdi/js';
import { t } from 'svelte-i18n';
import NavigationArea from '../navigation-area.svelte';
export let onPreviousAsset: () => void;
</script>
<svelte:window use:shortcut={{ shortcut: { key: 'ArrowLeft' }, onShortcut: onPreviousAsset }} />
<NavigationArea onClick={onPreviousAsset} label={$t('view_previous_asset')}>
<Icon path={mdiChevronLeft} size="36" ariaHidden />
</NavigationArea>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { restoreAssets, type AssetResponseDto } from '@immich/sdk';
import { mdiHistory } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
export let asset: AssetResponseDto;
export let onAction: OnAction;
const handleRestoreAsset = async () => {
try {
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
asset.isTrashed = false;
onAction({ type: AssetAction.RESTORE, asset });
notificationController.show({
type: NotificationType.Info,
message: $t('restored_asset'),
});
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));
}
};
</script>
<MenuOption icon={mdiHistory} onClick={handleRestoreAsset} text={$t('restore')} />

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { updateAlbumInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import { mdiImageOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
export let asset: AssetResponseDto;
export let album: AlbumResponseDto;
const handleUpdateThumbnail = async () => {
try {
await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
albumThumbnailAssetId: asset.id,
},
});
notificationController.show({
type: NotificationType.Info,
message: $t('album_cover_updated'),
timeout: 1500,
});
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_cover'));
}
};
</script>
<MenuOption text={$t('set_as_album_cover')} icon={mdiImageOutline} onClick={handleUpdateThumbnail} />

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import ProfileImageCropper from '$lib/components/shared-components/profile-image-cropper.svelte';
import type { AssetResponseDto } from '@immich/sdk';
import { mdiAccountCircleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
export let asset: AssetResponseDto;
let showProfileImageCrop = false;
</script>
<MenuOption
icon={mdiAccountCircleOutline}
onClick={() => (showProfileImageCrop = true)}
text={$t('set_as_profile_picture')}
/>
{#if showProfileImageCrop}
<Portal target="body">
<ProfileImageCropper {asset} onClose={() => (showProfileImageCrop = false)} />
</Portal>
{/if}

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import type { AssetResponseDto } from '@immich/sdk';
import { mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
export let asset: AssetResponseDto;
let showModal = false;
</script>
<CircleIconButton
color="opaque"
icon={mdiShareVariantOutline}
on:click={() => (showModal = true)}
title={$t('share')}
/>
{#if showModal}
<Portal target="body">
<CreateSharedLinkModal assetIds={[asset.id]} onClose={() => (showModal = false)} />
</Portal>
{/if}

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { mdiInformationOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
export let onShowDetail: () => void;
</script>
<svelte:window use:shortcut={{ shortcut: { key: 'i' }, onShortcut: onShowDetail }} />
<CircleIconButton color="opaque" icon={mdiInformationOutline} on:click={onShowDetail} title={$t('info')} />

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AssetAction } from '$lib/constants';
import { unstackAssets } from '$lib/utils/asset-utils';
import type { AssetResponseDto } from '@immich/sdk';
import { mdiImageMinusOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
export let stackedAssets: AssetResponseDto[];
export let onAction: OnAction;
const handleUnstack = async () => {
const unstackedAssets = await unstackAssets(stackedAssets);
if (unstackedAssets) {
onAction({ type: AssetAction.UNSTACK, assets: unstackedAssets });
}
};
</script>
<MenuOption icon={mdiImageMinusOutline} onClick={handleUnstack} text={$t('unstack')} />

View File

@@ -8,7 +8,6 @@
import { isTenMinutesApart } from '$lib/utils/timesince';
import {
ReactionType,
Type,
createActivity,
deleteActivity,
getActivities,
@@ -111,15 +110,15 @@
await deleteActivity({ id: reaction.id });
reactions.splice(index, 1);
reactions = reactions;
if (isLiked && reaction.type === 'like' && reaction.id == isLiked.id) {
if (isLiked && reaction.type === ReactionType.Like && reaction.id == isLiked.id) {
dispatch('deleteLike');
} else {
dispatch('deleteComment');
}
const deleteMessages: Record<Type, string> = {
[Type.Comment]: $t('comment_deleted'),
[Type.Like]: $t('like_deleted'),
const deleteMessages: Record<ReactionType, string> = {
[ReactionType.Comment]: $t('comment_deleted'),
[ReactionType.Like]: $t('like_deleted'),
};
notificationController.show({
message: deleteMessages[reaction.type],
@@ -172,7 +171,7 @@
style="height: {divHeight}px;padding-bottom: {chatHeight}px"
>
{#each reactions as reaction, index (reaction.id)}
{#if reaction.type === 'comment'}
{#if reaction.type === ReactionType.Comment}
<div class="flex dark:bg-gray-800 bg-gray-200 py-3 pl-3 mt-3 rounded-lg gap-4 justify-start">
<div class="flex items-center">
<UserAvatar user={reaction.user} size="sm" />
@@ -216,7 +215,7 @@
{timeSince(luxon.DateTime.fromISO(reaction.createdAt, { locale: $locale }))}
</div>
{/if}
{:else if reaction.type === 'like'}
{:else if reaction.type === ReactionType.Like}
<div class="relative">
<div class="flex py-3 pl-3 mt-3 gap-4 items-center text-sm">
<div class="text-red-600"><Icon path={mdiHeart} size={20} /></div>

View File

@@ -1,135 +1,80 @@
<script lang="ts">
import type { OnAction } from '$lib/components/asset-viewer/actions/action';
import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte';
import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte';
import CloseAction from '$lib/components/asset-viewer/actions/close-action.svelte';
import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte';
import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte';
import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte';
import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte';
import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte';
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte';
import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte';
import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import DeleteButton from './delete-button.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName } from '$lib/utils';
import { getAssetJobName, getSharedLink } from '$lib/utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetJobName, AssetTypeEnum, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import {
mdiAccountCircleOutline,
mdiAlertOutline,
mdiArchiveArrowDownOutline,
mdiArchiveArrowUpOutline,
mdiArrowLeft,
mdiCogRefreshOutline,
mdiContentCopy,
mdiDatabaseRefreshOutline,
mdiDotsVertical,
mdiFolderDownloadOutline,
mdiHeart,
mdiHeartOutline,
mdiHistory,
mdiImageAlbum,
mdiImageMinusOutline,
mdiImageOutline,
mdiImageRefreshOutline,
mdiInformationOutline,
mdiMagnifyMinusOutline,
mdiMagnifyPlusOutline,
mdiMotionPauseOutline,
mdiPlaySpeed,
mdiPresentationPlay,
mdiShareVariantOutline,
mdiUpload,
} from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import { canCopyImagesToClipboard } from 'copy-image-clipboard';
import { t } from 'svelte-i18n';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
export let asset: AssetResponseDto;
export let album: AlbumResponseDto | null = null;
export let showCopyButton: boolean;
export let showZoomButton: boolean;
export let showMotionPlayButton: boolean;
export let isMotionPhotoPlaying = false;
export let showDownloadButton: boolean;
export let stackedAssets: AssetResponseDto[];
export let showDetailButton: boolean;
export let showShareButton: boolean;
export let showSlideshow = false;
export let hasStackChildren = false;
export let onZoomImage: () => void;
export let onCopyImage: () => void;
export let onAction: OnAction;
export let onRunJob: (name: AssetJobName) => void;
export let onPlaySlideshow: () => void;
export let onShowDetail: () => void;
export let onClose: () => void;
const sharedLink = getSharedLink();
$: isOwner = $user && asset.ownerId === $user?.id;
type EventTypes = {
back: void;
stopMotionPhoto: void;
playMotionPhoto: void;
download: void;
showDetail: void;
favorite: void;
delete: void;
permanentlyDelete: void;
toggleArchive: void;
addToAlbum: void;
restoreAsset: void;
addToSharedAlbum: void;
asProfileImage: void;
setAsAlbumCover: void;
runJob: AssetJobName;
playSlideShow: void;
unstack: void;
showShareModal: void;
};
const dispatch = createEventDispatcher<EventTypes>();
const onJobClick = (name: AssetJobName) => {
dispatch('runJob', name);
};
const onMenuClick = (eventName: keyof EventTypes) => {
dispatch(eventName);
};
$: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
</script>
<div
class="z-[1001] flex h-16 place-items-center justify-between bg-gradient-to-b from-black/40 px-3 transition-transform duration-200"
>
<div class="text-white">
<CircleIconButton color="opaque" icon={mdiArrowLeft} title={$t('go_back')} on:click={() => dispatch('back')} />
<CloseAction {onClose} />
</div>
<div
class="flex w-[calc(100%-3rem)] justify-end gap-2 overflow-hidden text-white"
data-testid="asset-viewer-navbar-actions"
>
{#if showShareButton}
<CircleIconButton
color="opaque"
icon={mdiShareVariantOutline}
on:click={() => dispatch('showShareModal')}
title={$t('share')}
/>
{#if !asset.isTrashed && $user}
<ShareAction {asset} />
{/if}
{#if asset.isOffline}
<CircleIconButton
color="opaque"
icon={mdiAlertOutline}
on:click={() => dispatch('showDetail')}
title={$t('asset_offline')}
/>
<CircleIconButton color="opaque" icon={mdiAlertOutline} on:click={onShowDetail} title={$t('asset_offline')} />
{/if}
{#if showMotionPlayButton}
{#if isMotionPhotoPlaying}
<CircleIconButton
color="opaque"
icon={mdiMotionPauseOutline}
title={$t('stop_motion_photo')}
on:click={() => dispatch('stopMotionPhoto')}
/>
{:else}
<CircleIconButton
color="opaque"
icon={mdiPlaySpeed}
title={$t('play_motion_photo')}
on:click={() => dispatch('playMotionPhoto')}
/>
{/if}
{#if asset.livePhotoVideoId}
<slot name="motion-photo" />
{/if}
{#if showZoomButton}
{#if asset.type === AssetTypeEnum.Image}
<CircleIconButton
color="opaque"
hideMobile={true}
@@ -138,84 +83,50 @@
on:click={onZoomImage}
/>
{/if}
{#if showCopyButton}
{#if canCopyImagesToClipboard() && asset.type === AssetTypeEnum.Image}
<CircleIconButton color="opaque" icon={mdiContentCopy} title={$t('copy_image')} on:click={onCopyImage} />
{/if}
{#if !isOwner && showDownloadButton}
<CircleIconButton
color="opaque"
icon={mdiFolderDownloadOutline}
on:click={() => dispatch('download')}
title={$t('download')}
/>
<DownloadAction {asset} />
{/if}
{#if showDetailButton}
<CircleIconButton
color="opaque"
icon={mdiInformationOutline}
on:click={() => dispatch('showDetail')}
title={$t('info')}
/>
<ShowDetailAction {onShowDetail} />
{/if}
{#if isOwner}
<CircleIconButton
color="opaque"
icon={asset.isFavorite ? mdiHeart : mdiHeartOutline}
on:click={() => dispatch('favorite')}
title={asset.isFavorite ? $t('unfavorite') : $t('to_favorite')}
/>
<FavoriteAction {asset} {onAction} />
{/if}
{#if isOwner}
<DeleteButton
{asset}
on:delete={() => dispatch('delete')}
on:permanentlyDelete={() => dispatch('permanentlyDelete')}
/>
<DeleteAction {asset} {onAction} />
<ButtonContextMenu direction="left" align="top-right" color="opaque" title={$t('more')} icon={mdiDotsVertical}>
{#if showSlideshow}
<MenuOption icon={mdiPresentationPlay} onClick={() => onMenuClick('playSlideShow')} text={$t('slideshow')} />
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
{/if}
{#if showDownloadButton}
<MenuOption icon={mdiFolderDownloadOutline} onClick={() => onMenuClick('download')} text={$t('download')} />
<DownloadAction {asset} menuItem />
{/if}
{#if asset.isTrashed}
<MenuOption icon={mdiHistory} onClick={() => onMenuClick('restoreAsset')} text={$t('restore')} />
<RestoreAction {asset} {onAction} />
{:else}
<MenuOption icon={mdiImageAlbum} onClick={() => onMenuClick('addToAlbum')} text={$t('add_to_album')} />
<MenuOption
icon={mdiShareVariantOutline}
onClick={() => onMenuClick('addToSharedAlbum')}
text={$t('add_to_shared_album')}
/>
<AddToAlbumAction {asset} {onAction} />
<AddToAlbumAction {asset} {onAction} shared />
{/if}
{#if isOwner}
{#if hasStackChildren}
<MenuOption icon={mdiImageMinusOutline} onClick={() => onMenuClick('unstack')} text={$t('unstack')} />
<UnstackAction {stackedAssets} {onAction} />
{/if}
{#if album}
<MenuOption
text={$t('set_as_album_cover')}
icon={mdiImageOutline}
onClick={() => onMenuClick('setAsAlbumCover')}
/>
<SetAlbumCoverAction {asset} {album} />
{/if}
{#if asset.type === AssetTypeEnum.Image}
<MenuOption
icon={mdiAccountCircleOutline}
onClick={() => onMenuClick('asProfileImage')}
text={$t('set_as_profile_picture')}
/>
<SetProfilePictureAction {asset} />
{/if}
<MenuOption
onClick={() => onMenuClick('toggleArchive')}
icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline}
text={asset.isArchived ? $t('unarchive') : $t('to_archive')}
/>
<ArchiveAction {asset} {onAction} />
<MenuOption
icon={mdiUpload}
onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
@@ -224,18 +135,18 @@
<hr />
<MenuOption
icon={mdiDatabaseRefreshOutline}
onClick={() => onJobClick(AssetJobName.RefreshMetadata)}
onClick={() => onRunJob(AssetJobName.RefreshMetadata)}
text={$getAssetJobName(AssetJobName.RefreshMetadata)}
/>
<MenuOption
icon={mdiImageRefreshOutline}
onClick={() => onJobClick(AssetJobName.RegenerateThumbnail)}
onClick={() => onRunJob(AssetJobName.RegenerateThumbnail)}
text={$getAssetJobName(AssetJobName.RegenerateThumbnail)}
/>
{#if asset.type === AssetTypeEnum.Video}
<MenuOption
icon={mdiCogRefreshOutline}
onClick={() => onJobClick(AssetJobName.TranscodeVideo)}
onClick={() => onRunJob(AssetJobName.TranscodeVideo)}
text={$getAssetJobName(AssetJobName.TranscodeVideo)}
/>
{/if}

View File

@@ -1,25 +1,21 @@
<script lang="ts">
import { focusTrap } from '$lib/actions/focus-trap';
import type { Action, OnAction } from '$lib/components/asset-viewer/actions/action';
import MotionPhotoAction from '$lib/components/asset-viewer/actions/motion-photo-action.svelte';
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
import { AssetAction, ProjectionType } from '$lib/constants';
import { updateNumberOfComments } from '$lib/stores/activity.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { AssetStore } from '$lib/stores/assets.store';
import { isShowDetail, showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { isShowDetail } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { stackAssetsStore } from '$lib/stores/stacked-asset.store';
import { user } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
import { getAssetJobMessage, getSharedLink, handlePromiseError, isSharedLink } from '$lib/utils';
import {
addAssetsToAlbum,
addAssetsToNewAlbum,
downloadFile,
unstackAssets,
toggleArchive,
} from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { shortcuts } from '$lib/actions/shortcut';
import { navigate } from '$lib/utils/navigation';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import {
AssetJobName,
@@ -27,49 +23,37 @@
ReactionType,
createActivity,
deleteActivity,
deleteAssets,
getActivities,
getActivityStatistics,
getAllAlbums,
runAssetJobs,
restoreAssets,
updateAsset,
updateAlbumInfo,
type ActivityResponseDto,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { mdiChevronLeft, mdiChevronRight, mdiImageBrokenVariant } from '@mdi/js';
import { mdiImageBrokenVariant } from '@mdi/js';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import DeleteAssetDialog from '../photos-page/delete-asset-dialog.svelte';
import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import ProfileImageCropper from '../shared-components/profile-image-cropper.svelte';
import ActivityStatus from './activity-status.svelte';
import ActivityViewer from './activity-viewer.svelte';
import AssetViewerNavBar from './asset-viewer-nav-bar.svelte';
import DetailPanel from './detail-panel.svelte';
import NavigationArea from './navigation-area.svelte';
import PanoramaViewer from './panorama-viewer.svelte';
import PhotoViewer from './photo-viewer.svelte';
import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte';
import { navigate } from '$lib/utils/navigation';
import { websocketEvents } from '$lib/stores/websocket';
import { canCopyImagesToClipboard } from 'copy-image-clipboard';
import { t } from 'svelte-i18n';
import { focusTrap } from '$lib/actions/focus-trap';
export let assetStore: AssetStore | null = null;
export let asset: AssetResponseDto;
export let preloadAssets: AssetResponseDto[] = [];
export let showNavigation = true;
$: isTrashEnabled = $featureFlags.trash;
export let withStacked = false;
export let isShared = false;
export let album: AlbumResponseDto | null = null;
export let onAction: OnAction | undefined = undefined;
let reactions: ActivityResponseDto[] = [];
@@ -82,23 +66,16 @@
} = slideshowStore;
const dispatch = createEventDispatcher<{
action: { type: AssetAction; asset: AssetResponseDto };
close: void;
next: void;
previous: void;
}>();
let appearsInAlbums: AlbumResponseDto[] = [];
let isShowAlbumPicker = false;
let isShowDeleteConfirmation = false;
let isShowShareModal = false;
let addToSharedAlbum = true;
let stackedAssets: AssetResponseDto[] = [];
let shouldPlayMotionPhoto = false;
let isShowProfileImageCrop = false;
let sharedLink = getSharedLink();
let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
let enableDetailPanel = asset.hasMetadata;
let shouldShowShareModal = !asset.isTrashed;
let slideshowStateUnsubscribe: () => void;
let shuffleSlideshowUnsubscribe: () => void;
let previewStackedAsset: AssetResponseDto | undefined;
@@ -109,23 +86,24 @@
let unsubscribe: () => void;
let zoomToggle = () => void 0;
let copyImage: () => Promise<void>;
$: isFullScreen = fullscreenElement !== null;
$: {
if (asset.stackCount && asset.stack) {
$stackAssetsStore = asset.stack;
$stackAssetsStore = [...$stackAssetsStore, asset].sort(
stackedAssets = asset.stack;
stackedAssets = [...stackedAssets, asset].sort(
(a, b) => new Date(b.fileCreatedAt).getTime() - new Date(a.fileCreatedAt).getTime(),
);
// if its a stack, add the next stack image in addition to the next asset
if (asset.stackCount > 1) {
preloadAssets.push($stackAssetsStore[1]);
preloadAssets.push(stackedAssets[1]);
}
}
if (!$stackAssetsStore.map((a) => a.id).includes(asset.id)) {
$stackAssetsStore = [];
if (!stackedAssets.map((a) => a.id).includes(asset.id)) {
stackedAssets = [];
}
}
@@ -230,12 +208,12 @@
}
if (asset.stackCount && asset.stack) {
$stackAssetsStore = asset.stack;
$stackAssetsStore = [...$stackAssetsStore, asset].sort(
stackedAssets = asset.stack;
stackedAssets = [...stackedAssets, asset].sort(
(a, b) => new Date(a.fileCreatedAt).getTime() - new Date(b.fileCreatedAt).getTime(),
);
} else {
$stackAssetsStore = [];
stackedAssets = [];
}
});
@@ -277,12 +255,8 @@
};
const closeViewer = async () => {
if ($slideshowState === SlideshowState.None) {
dispatch('close');
await navigate({ targetRoute: 'current', assetId: null });
} else {
$slideshowState = SlideshowState.StopSlideshow;
}
dispatch('close');
await navigate({ targetRoute: 'current', assetId: null });
};
const navigateAssetRandom = async () => {
@@ -328,121 +302,6 @@
dispatch(order);
};
const showDetailInfoHandler = () => {
if (isShowActivity) {
isShowActivity = false;
}
$isShowDetail = !$isShowDetail;
};
const trashOrDelete = async (force: boolean = false) => {
if (force || !isTrashEnabled) {
if ($showDeleteModal) {
isShowDeleteConfirmation = true;
return;
}
await deleteAsset();
return;
}
await trashAsset();
return;
};
const trashAsset = async () => {
try {
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id] } });
dispatch('action', { type: AssetAction.TRASH, asset });
notificationController.show({
message: $t('moved_to_trash'),
type: NotificationType.Info,
});
} catch (error) {
handleError(error, $t('errors.unable_to_trash_asset'));
}
};
const deleteAsset = async () => {
try {
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
dispatch('action', { type: AssetAction.DELETE, asset });
notificationController.show({
message: $t('permanently_deleted_asset'),
type: NotificationType.Info,
});
} catch (error) {
handleError(error, $t('errors.unable_to_delete_asset'));
} finally {
isShowDeleteConfirmation = false;
}
};
const toggleFavorite = async () => {
try {
const data = await updateAsset({
id: asset.id,
updateAssetDto: {
isFavorite: !asset.isFavorite,
},
});
asset.isFavorite = data.isFavorite;
dispatch('action', { type: data.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE, asset: data });
notificationController.show({
type: NotificationType.Info,
message: asset.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'),
});
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } }));
}
};
const openAlbumPicker = (shared: boolean) => {
isShowAlbumPicker = true;
addToSharedAlbum = shared;
};
const handleAddToNewAlbum = async (albumName: string) => {
isShowAlbumPicker = false;
await addAssetsToNewAlbum(albumName, [asset.id]);
};
const handleAddToAlbum = async (album: AlbumResponseDto) => {
isShowAlbumPicker = false;
await addAssetsToAlbum(album.id, [asset.id]);
await handleGetAllAlbums();
};
const handleRestoreAsset = async () => {
try {
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
asset.isTrashed = false;
dispatch('action', { type: AssetAction.RESTORE, asset });
notificationController.show({
type: NotificationType.Info,
message: $t('restored_asset'),
});
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));
}
};
const toggleAssetArchive = async () => {
const updatedAsset = await toggleArchive(asset);
if (updatedAsset) {
dispatch('action', { type: asset.isArchived ? AssetAction.ARCHIVE : AssetAction.UNARCHIVE, asset: asset });
}
};
const handleRunJob = async (name: AssetJobName) => {
try {
await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
@@ -498,59 +357,21 @@
previewStackedAsset = isMouseOver ? asset : undefined;
};
const handleUnstack = async () => {
const unstackedAssets = await unstackAssets($stackAssetsStore);
if (unstackedAssets) {
for (const asset of unstackedAssets) {
dispatch('action', {
type: AssetAction.ADD,
asset,
});
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ADD_TO_ALBUM: {
await handleGetAllAlbums();
break;
}
case AssetAction.UNSTACK: {
await closeViewer();
}
await closeViewer();
}
};
const handleUpdateThumbnail = async () => {
if (!album) {
return;
}
try {
await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
albumThumbnailAssetId: asset.id,
},
});
notificationController.show({
type: NotificationType.Info,
message: $t('album_cover_updated'),
timeout: 1500,
});
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_cover'));
}
onAction?.(action);
};
$: if (!$user) {
shouldShowShareModal = false;
}
</script>
<svelte:window
use:shortcuts={[
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleAssetArchive },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => navigateAsset('previous') },
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => navigateAsset('next') },
{ shortcut: { key: 'd', shift: true }, onShortcut: () => downloadFile(asset) },
{ shortcut: { key: 'Delete' }, onShortcut: () => trashOrDelete(asset.isTrashed) },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'Escape' }, onShortcut: closeViewer },
{ shortcut: { key: 'f' }, onShortcut: toggleFavorite },
{ shortcut: { key: 'i' }, onShortcut: toggleDetailPanel },
]}
/>
<svelte:document bind:fullscreenElement />
<section
@@ -564,44 +385,30 @@
<AssetViewerNavBar
{asset}
{album}
isMotionPhotoPlaying={shouldPlayMotionPhoto}
showCopyButton={canCopyImagesToClipboard() && asset.type === AssetTypeEnum.Image}
showZoomButton={asset.type === AssetTypeEnum.Image}
showMotionPlayButton={!!asset.livePhotoVideoId}
showDownloadButton={shouldShowDownloadButton}
{stackedAssets}
showDetailButton={enableDetailPanel}
showSlideshow={!!assetStore}
hasStackChildren={$stackAssetsStore.length > 0}
showShareButton={shouldShowShareModal}
hasStackChildren={stackedAssets.length > 0}
onZoomImage={zoomToggle}
onCopyImage={copyImage}
on:back={closeViewer}
on:showDetail={showDetailInfoHandler}
on:download={() => downloadFile(asset)}
on:delete={() => trashOrDelete()}
on:permanentlyDelete={() => trashOrDelete(true)}
on:favorite={toggleFavorite}
on:addToAlbum={() => openAlbumPicker(false)}
on:restoreAsset={() => handleRestoreAsset()}
on:addToSharedAlbum={() => openAlbumPicker(true)}
on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)}
on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
on:toggleArchive={toggleAssetArchive}
on:asProfileImage={() => (isShowProfileImageCrop = true)}
on:setAsAlbumCover={handleUpdateThumbnail}
on:runJob={({ detail: job }) => handleRunJob(job)}
on:playSlideShow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
on:unstack={handleUnstack}
on:showShareModal={() => (isShowShareModal = true)}
/>
onAction={handleAction}
onRunJob={handleRunJob}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onShowDetail={toggleDetailPanel}
onClose={closeViewer}
>
<MotionPhotoAction
slot="motion-photo"
isPlaying={shouldPlayMotionPhoto}
onClick={(shouldPlay) => (shouldPlayMotionPhoto = shouldPlay)}
/>
</AssetViewerNavBar>
</div>
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation}
<div class="z-[1001] my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<NavigationArea onClick={(e) => navigateAsset('previous', e)} label={$t('view_previous_asset')}>
<Icon path={mdiChevronLeft} size="36" ariaHidden />
</NavigationArea>
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
</div>
{/if}
@@ -698,9 +505,7 @@
{#if $slideshowState === SlideshowState.None && showNavigation}
<div class="z-[1001] my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<NavigationArea onClick={(e) => navigateAsset('next', e)} label={$t('view_next_asset')}>
<Icon path={mdiChevronRight} size="36" ariaHidden />
</NavigationArea>
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div>
{/if}
@@ -715,13 +520,13 @@
</div>
{/if}
{#if $stackAssetsStore.length > 0 && withStacked}
{#if stackedAssets.length > 0 && withStacked}
<div
id="stack-slideshow"
class="z-[1002] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 overflow-x-auto horizontal-scrollbar"
>
<div class="relative w-full whitespace-nowrap transition-all">
{#each $stackAssetsStore as stackedAsset, index (stackedAsset.id)}
{#each stackedAssets as stackedAsset, index (stackedAsset.id)}
<div
class="{stackedAsset.id == asset.id
? '-translate-y-[1px]'
@@ -735,7 +540,7 @@
onClick={(stackedAsset, event) => {
event.preventDefault();
asset = stackedAsset;
preloadAssets = index + 1 >= $stackAssetsStore.length ? [] : [$stackAssetsStore[index + 1]];
preloadAssets = index + 1 >= stackedAssets.length ? [] : [stackedAssets[index + 1]];
}}
on:mouse-event={(e) => handleStackedAssetMouseEvent(e, stackedAsset)}
readonly
@@ -777,27 +582,6 @@
/>
</div>
{/if}
{#if isShowAlbumPicker}
<AlbumSelectionModal
shared={addToSharedAlbum}
on:newAlbum={({ detail }) => handleAddToNewAlbum(detail)}
on:album={({ detail }) => handleAddToAlbum(detail)}
onClose={() => (isShowAlbumPicker = false)}
/>
{/if}
{#if isShowDeleteConfirmation}
<DeleteAssetDialog size={1} on:cancel={() => (isShowDeleteConfirmation = false)} on:confirm={() => deleteAsset()} />
{/if}
{#if isShowProfileImageCrop}
<ProfileImageCropper {asset} onClose={() => (isShowProfileImageCrop = false)} />
{/if}
{#if isShowShareModal}
<CreateSharedLinkModal assetIds={[asset.id]} onClose={() => (isShowShareModal = false)} />
{/if}
</section>
<style>

View File

@@ -1,27 +0,0 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { createEventDispatcher } from 'svelte';
import { t } from 'svelte-i18n';
import { mdiDeleteOutline, mdiDeleteForeverOutline } from '@mdi/js';
import { type AssetResponseDto } from '@immich/sdk';
export let asset: AssetResponseDto;
type EventTypes = {
delete: void;
permanentlyDelete: void;
};
const dispatch = createEventDispatcher<EventTypes>();
</script>
{#if asset.isTrashed}
<CircleIconButton
color="opaque"
icon={mdiDeleteForeverOutline}
on:click={() => dispatch('permanentlyDelete')}
title={$t('permanently_delete')}
/>
{:else}
<CircleIconButton color="opaque" icon={mdiDeleteOutline} on:click={() => dispatch('delete')} title={$t('delete')} />
{/if}

View File

@@ -1,12 +1,13 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import ProgressBar, { ProgressBarStatus } from '$lib/components/shared-components/progress-bar/progress-bar.svelte';
import SlideshowSettings from '$lib/components/slideshow-settings.svelte';
import { SlideshowNavigation, slideshowStore } from '$lib/stores/slideshow.store';
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiCog, mdiFullscreen, mdiPause, mdiPlay } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import { fly } from 'svelte/transition';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
export let isFullScreen: boolean;
export let onNext = () => {};
@@ -85,7 +86,14 @@
};
</script>
<svelte:window on:mousemove={showControlBar} />
<svelte:window
on:mousemove={showControlBar}
use:shortcuts={[
{ shortcut: { key: 'Escape' }, onShortcut: onClose },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: onPrevious },
{ shortcut: { key: 'ArrowRight' }, onShortcut: onNext },
]}
/>
{#if showControls}
<div

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
@@ -7,8 +9,10 @@
import { locale, showDeleteModal } from '$lib/stores/preferences.store';
import { isSearchEnabled } from '$lib/stores/search.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions';
import { type ShortcutOptions, shortcuts } from '$lib/actions/shortcut';
import { archiveAssets, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
@@ -18,17 +22,18 @@
import Scrollbar from '../shared-components/scrollbar/scrollbar.svelte';
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
import AssetDateGroup from './asset-date-group.svelte';
import { archiveAssets, stackAssets } from '$lib/utils/asset-utils';
import DeleteAssetDialog from './delete-asset-dialog.svelte';
import { handlePromiseError } from '$lib/utils';
import { selectAllAssets } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
export let isSelectionMode = false;
export let singleSelect = false;
export let assetStore: AssetStore;
export let assetInteractionStore: AssetInteractionStore;
export let removeAction: AssetAction | null = null;
export let removeAction:
| AssetAction.UNARCHIVE
| AssetAction.ARCHIVE
| AssetAction.FAVORITE
| AssetAction.UNFAVORITE
| null = null;
export let withStacked = false;
export let showArchiveIcon = false;
export let isShared = false;
@@ -193,8 +198,8 @@
const handleClose = () => assetViewingStore.showAssetViewer(false);
const handleAction = async (action: AssetAction, asset: AssetResponseDto) => {
switch (action) {
const handleAction = async (action: Action) => {
switch (action.type) {
case removeAction:
case AssetAction.TRASH:
case AssetAction.RESTORE:
@@ -203,7 +208,7 @@
(await handleNext()) || (await handlePrevious()) || handleClose();
// delete after find the next one
assetStore.removeAssets([asset.id]);
assetStore.removeAssets([action.asset.id]);
break;
}
@@ -211,14 +216,18 @@
case AssetAction.UNARCHIVE:
case AssetAction.FAVORITE:
case AssetAction.UNFAVORITE: {
assetStore.updateAssets([asset]);
assetStore.updateAssets([action.asset]);
break;
}
case AssetAction.ADD: {
assetStore.addAssets([asset]);
assetStore.addAssets([action.asset]);
break;
}
case AssetAction.UNSTACK: {
assetStore.addAssets(action.assets);
}
}
};
@@ -501,10 +510,10 @@
preloadAssets={$preloadAssets}
{isShared}
{album}
onAction={handleAction}
on:previous={handlePrevious}
on:next={handleNext}
on:close={handleClose}
on:action={({ detail: action }) => handleAction(action.type, action.asset)}
/>
{/await}
{/if}

View File

@@ -69,15 +69,18 @@
};
const handleSearchPlaces = () => {
if (searchWord === '') {
return;
}
if (latestSearchTimeout) {
clearTimeout(latestSearchTimeout);
}
showLoadingSpinner = true;
const searchTimeout = window.setTimeout(() => {
if (searchWord === '') {
places = [];
showLoadingSpinner = false;
return;
}
searchPlaces({ name: searchWord })
.then((searchResult) => {
// skip result when a newer search is happening

View File

@@ -4,9 +4,16 @@
value: string;
};
export function toComboBoxOptions(items: string[]) {
return items.map<ComboBoxOption>((item) => ({ label: item, value: item }));
}
export const asComboboxOptions = (values: string[]) =>
values.map((value) => {
if (value === '') {
return { label: get(t)('unknown'), value: '' };
}
return { label: value, value };
});
export const asSelectedOption = (value?: string) => (value === undefined ? undefined : asComboboxOptions([value])[0]);
</script>
<script lang="ts">
@@ -21,6 +28,7 @@
import { generateId } from '$lib/utils/generate-id';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
export let label: string;
export let hideLabel = false;

View File

@@ -1,19 +1,20 @@
<script lang="ts">
import Portal from '../portal/portal.svelte';
import { goto } from '$app/navigation';
import type { Action } from '$lib/components/asset-viewer/actions/action';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { BucketPosition, Viewport } from '$lib/stores/assets.store';
import { handleError } from '$lib/utils/handle-error';
import { type AssetResponseDto } from '@immich/sdk';
import { createEventDispatcher, onDestroy } from 'svelte';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import justifiedLayout from 'justified-layout';
import { getAssetRatio } from '$lib/utils/asset-utils';
import { calculateWidth } from '$lib/utils/timeline-util';
import { handleError } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
import { AppRoute, AssetAction } from '$lib/constants';
import { goto } from '$app/navigation';
import { calculateWidth } from '$lib/utils/timeline-util';
import { type AssetResponseDto } from '@immich/sdk';
import justifiedLayout from 'justified-layout';
import { createEventDispatcher, onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import Portal from '../portal/portal.svelte';
const dispatch = createEventDispatcher<{ intersected: { container: HTMLDivElement; position: BucketPosition } }>();
@@ -68,13 +69,13 @@
}
};
const handleAction = async (action: AssetAction, asset: AssetResponseDto) => {
switch (action) {
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE:
case AssetAction.DELETE:
case AssetAction.TRASH: {
assets.splice(
assets.findIndex((a) => a.id === asset.id),
assets.findIndex((a) => a.id === action.asset.id),
1,
);
assets = assets;
@@ -149,11 +150,6 @@
<!-- Overlay Asset Viewer -->
{#if $isViewerOpen}
<Portal target="body">
<AssetViewer
asset={$viewingAsset}
on:action={({ detail: action }) => handleAction(action.type, action.asset)}
on:previous={handlePrevious}
on:next={handleNext}
/>
<AssetViewer asset={$viewingAsset} onAction={handleAction} on:previous={handlePrevious} on:next={handleNext} />
</Portal>
{/if}

View File

@@ -6,9 +6,9 @@
</script>
<script lang="ts">
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
import Combobox, { toComboBoxOptions } from '../combobox.svelte';
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import { handlePromiseError } from '$lib/utils';
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
import { t } from 'svelte-i18n';
export let filters: SearchCameraFilter;
@@ -22,17 +22,30 @@
$: handlePromiseError(updateModels(makeFilter));
async function updateMakes(model?: string) {
makes = await getSearchSuggestions({
const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.CameraMake,
model,
includeNull: true,
});
if (filters.make && !makes.includes(filters.make)) {
filters.make = undefined;
}
makes = results.map((result) => result ?? '');
}
async function updateModels(make?: string) {
models = await getSearchSuggestions({
const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.CameraModel,
make,
includeNull: true,
});
const models = results.map((result) => result ?? '');
if (filters.model && !models.includes(filters.model)) {
filters.model = undefined;
}
}
</script>
@@ -44,9 +57,9 @@
<Combobox
label={$t('make')}
on:select={({ detail }) => (filters.make = detail?.value)}
options={toComboBoxOptions(makes)}
options={asComboboxOptions(makes)}
placeholder={$t('search_camera_make')}
selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined}
selectedOption={asSelectedOption(makeFilter)}
/>
</div>
@@ -54,9 +67,9 @@
<Combobox
label={$t('model')}
on:select={({ detail }) => (filters.model = detail?.value)}
options={toComboBoxOptions(models)}
options={asComboboxOptions(models)}
placeholder={$t('search_camera_model')}
selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined}
selectedOption={asSelectedOption(modelFilter)}
/>
</div>
</div>

View File

@@ -42,18 +42,23 @@
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
const dispatch = createEventDispatcher<{ search: SmartSearchDto | MetadataSearchDto }>();
// combobox and all the search components have terrible support for value | null so we use empty string instead.
function withNullAsUndefined<T>(value: T | null) {
return value === null ? undefined : value;
}
let filter: SearchFilter = {
context: 'query' in searchQuery ? searchQuery.query : '',
filename: 'originalFileName' in searchQuery ? searchQuery.originalFileName : undefined,
personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []),
location: {
country: searchQuery.country,
state: searchQuery.state,
city: searchQuery.city,
country: withNullAsUndefined(searchQuery.country),
state: withNullAsUndefined(searchQuery.state),
city: withNullAsUndefined(searchQuery.city),
},
camera: {
make: searchQuery.make,
model: searchQuery.model,
make: withNullAsUndefined(searchQuery.make),
model: withNullAsUndefined(searchQuery.model),
},
date: {
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,

View File

@@ -7,9 +7,9 @@
</script>
<script lang="ts">
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
import Combobox, { toComboBoxOptions } from '../combobox.svelte';
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import { handlePromiseError } from '$lib/utils';
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
import { t } from 'svelte-i18n';
export let filters: SearchLocationFilter;
@@ -25,33 +25,41 @@
$: handlePromiseError(updateCities(countryFilter, stateFilter));
async function updateCountries() {
countries = await getSearchSuggestions({
const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.Country,
includeNull: true,
});
countries = results.map((result) => result ?? '');
if (filters.country && !countries.includes(filters.country)) {
filters.country = undefined;
}
}
async function updateStates(country?: string) {
states = await getSearchSuggestions({
const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.State,
country,
includeNull: true,
});
states = results.map((result) => result ?? '');
if (filters.state && !states.includes(filters.state)) {
filters.state = undefined;
}
}
async function updateCities(country?: string, state?: string) {
cities = await getSearchSuggestions({
const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.City,
country,
state,
});
cities = results.map((result) => result ?? '');
if (filters.city && !cities.includes(filters.city)) {
filters.city = undefined;
}
@@ -66,9 +74,9 @@
<Combobox
label={$t('country')}
on:select={({ detail }) => (filters.country = detail?.value)}
options={toComboBoxOptions(countries)}
options={asComboboxOptions(countries)}
placeholder={$t('search_country')}
selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined}
selectedOption={asSelectedOption(filters.country)}
/>
</div>
@@ -76,9 +84,9 @@
<Combobox
label={$t('state')}
on:select={({ detail }) => (filters.state = detail?.value)}
options={toComboBoxOptions(states)}
options={asComboboxOptions(states)}
placeholder={$t('search_state')}
selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined}
selectedOption={asSelectedOption(filters.state)}
/>
</div>
@@ -86,9 +94,9 @@
<Combobox
label={$t('city')}
on:select={({ detail }) => (filters.city = detail?.value)}
options={toComboBoxOptions(cities)}
options={asComboboxOptions(cities)}
placeholder={$t('search_city')}
selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined}
selectedOption={asSelectedOption(filters.city)}
/>
</div>
</div>

View File

@@ -7,6 +7,8 @@ export enum AssetAction {
DELETE = 'delete',
RESTORE = 'restore',
ADD = 'add',
ADD_TO_ALBUM = 'add-to-album',
UNSTACK = 'unstack',
}
export enum AppRoute {
@@ -248,8 +250,10 @@ export const locales = [
export const defaultLang = { name: 'English', code: 'en', loader: () => import('$lib/i18n/en.json') };
export const langs = [
{ name: 'Afrikaans', code: 'af', loader: () => import('$lib/i18n/af.json') },
{ name: 'Arabic', code: 'ar', loader: () => import('$lib/i18n/ar.json') },
{ name: 'Azerbaijani', code: 'az', loader: () => import('$lib/i18n/az.json') },
{ name: 'Belarusian', code: 'be', loader: () => import('$lib/i18n/be.json') },
{ name: 'Bulgarian', code: 'bg', loader: () => import('$lib/i18n/bg.json') },
{ name: 'Bislama', code: 'bi', loader: () => import('$lib/i18n/bi.json') },
{ name: 'Catalan', code: 'ca', loader: () => import('$lib/i18n/ca.json') },
@@ -257,7 +261,9 @@ export const langs = [
{ name: 'Danish', code: 'da', loader: () => import('$lib/i18n/da.json') },
{ name: 'German', code: 'de', loader: () => import('$lib/i18n/de.json') },
defaultLang,
{ name: 'Greek', code: 'el', loader: () => import('$lib/i18n/el.json') },
{ name: 'Spanish', code: 'es', loader: () => import('$lib/i18n/es.json') },
{ name: 'Estonian', code: 'et', loader: () => import('$lib/i18n/et.json') },
{ name: 'Persian', code: 'fa', loader: () => import('$lib/i18n/fa.json') },
{ name: 'Finnish', code: 'fi', loader: () => import('$lib/i18n/fi.json') },
{ name: 'French', code: 'fr', loader: () => import('$lib/i18n/fr.json') },
@@ -292,6 +298,7 @@ export const langs = [
{ name: 'Serbian (Latin)', code: 'sr-Latn', weblateCode: 'sr_Latn', loader: () => import('$lib/i18n/sr_Latn.json') },
{ name: 'Swedish', code: 'sv', loader: () => import('$lib/i18n/sv.json') },
{ name: 'Tamil', code: 'ta', loader: () => import('$lib/i18n/ta.json') },
{ name: 'Telugu', code: 'te', loader: () => import('$lib/i18n/te.json') },
{ name: 'Thai', code: 'th', loader: () => import('$lib/i18n/th.json') },
{ name: 'Turkish', code: 'tr', loader: () => import('$lib/i18n/tr.json') },
{ name: 'Ukrainian', code: 'uk', loader: () => import('$lib/i18n/uk.json') },

1
web/src/lib/i18n/af.json Normal file
View File

@@ -0,0 +1 @@
{}

1
web/src/lib/i18n/be.json Normal file
View File

@@ -0,0 +1 @@
{}

View File

@@ -25,7 +25,7 @@
"add_to_shared_album": "Добави към споделен албум",
"added_to_archive": "Добавено в архива",
"added_to_favorites": "Добавено към любими",
"added_to_favorites_count": "Добавени {count} към любими",
"added_to_favorites_count": "Добавени {count, number} към любими",
"admin": {
"add_exclusion_pattern_description": "Добави модели за изключване. Поддържа се \"globbing\" с помощта на *, ** и ?. За да игнорирате всички файлове в директория с име \"Raw\", използвайте \"**/Raw/**\". За да игнорирате всички файлове, завършващи на \".tif\", използвайте \"**/*.tif\". За да игнорирате абсолютен път, използвайте \"/path/to/ignore/**\".",
"authentication_settings": "Настройки за удостоверяване",
@@ -225,82 +225,84 @@
"storage_template_migration_info": "Промените в шаблоните ще се прилагат само за нови ресурси. За да приложите шаблона със задна дата към предварително качени активи, изпълнете <link>{job}</link>.",
"storage_template_migration_job": "Задача за миграция на шаблона за съхранение",
"storage_template_more_details": "За повече подробности относно тази функция се обърнете към шаблона <template-link>Storage Template</template-link> и неговите <implications-link> последствия </implications-link>",
"storage_template_onboarding_description": "",
"storage_template_onboarding_description": "Когато е активирана, тази функция ще организира автоматично файлове въз основа на дефиниран от потребителя шаблон. Поради проблеми със стабилността функцията е изключена по подразбиране. За повече информация, моля, вижте <link>документацията</link>.",
"storage_template_path_length": "Ограничение на дължината на пътя: <b>{length, number}</b>/{limit, number}",
"storage_template_settings": "Шаблон за съхранение",
"storage_template_settings_description": "Управление на структурата на папките и името на файла за качване",
"storage_template_user_label": "",
"storage_template_user_label": "<code>{label}</code> е етикетът за съхранение на потребителя",
"system_settings": "Системни настройки",
"theme_custom_css_settings": "Персонализиран CSS",
"theme_custom_css_settings_description": "",
"theme_custom_css_settings_description": "Каскадните стилови таблици позволяват персонализиране на дизайна на Immich.",
"theme_settings": "Настройки на темата",
"theme_settings_description": "Управление на персонализирането на уеб интерфейса на Immich",
"these_files_matched_by_checksum": "Тези файлове се сравняват по контролните им суми (checksums)",
"thumbnail_generation_job": "Генериране на миниатюри",
"thumbnail_generation_job_description": "",
"transcoding_acceleration_api": "",
"transcoding_acceleration_api_description": "",
"thumbnail_generation_job_description": "Генерирайте големи, малки и замъглени миниатюри за всеки актив, както и миниатюри за всеки човек",
"transcoding_acceleration_api": "API за ускоряване",
"transcoding_acceleration_api_description": "API, който ще взаимодейства с вашето устройство, за да ускори транскодирането. Тази настройка е „best effort“: тя ще се върне към софтуерно транскодиране при повреда. VP9 може или не може да работи в зависимост от вашия хардуер.",
"transcoding_acceleration_nvenc": "NVENC (необходим NVIDIA GPU)",
"transcoding_acceleration_qsv": "Quick Sync (необходим 7th поколение Intel CPU или по-ново)",
"transcoding_acceleration_rkmpp": "RKMPP (само на Rockchip SOCs)",
"transcoding_acceleration_vaapi": "VAAPI",
"transcoding_accepted_audio_codecs": "Допустими аудио кодеци",
"transcoding_accepted_audio_codecs_description": "",
"transcoding_accepted_audio_codecs_description": "Изберете кои аудио кодеци не са нужни за разкодиране. Използва се само за определени правила за разкодиране.",
"transcoding_accepted_containers": "Приети контейнери",
"transcoding_accepted_containers_description": "Изберете кои формати на контейнери не трябва да се пренасочват към MP4. Използва се само за определени правила за разкодиране.",
"transcoding_accepted_video_codecs": "Приети видео кодеци",
"transcoding_accepted_video_codecs_description": "",
"transcoding_accepted_video_codecs_description": "Изберете кои видео кодеци не трябва да се разкодиране. Използва се само за определени правила за разкодиране.",
"transcoding_advanced_options_description": "Опции, които повечето потребители не трябва да променят",
"transcoding_audio_codec": "Аудио кодек",
"transcoding_audio_codec_description": "Opus е опцията с най-високо качество, но има по-ниска съвместимост със стари устройства или софтуер.",
"transcoding_bitrate_description": "Видеоклипове с по-висок от максималния битрейт или не в приет формат",
"transcoding_codecs_learn_more": "",
"transcoding_codecs_learn_more": "За да научите повече за използваната терминология, вижте документацията на FFmpeg за <h264-link>кодек H.264</h264-link>, <hevc-link>кодек HEVC</hevc-link> и <vp9-link>VP9 кодек</vp9-link>.",
"transcoding_constant_quality_mode": "Режим на постоянно качество",
"transcoding_constant_quality_mode_description": "",
"transcoding_constant_rate_factor": "",
"transcoding_constant_rate_factor_description": "",
"transcoding_disabled_description": "",
"transcoding_constant_quality_mode_description": "ICQ е по-добър от CQP, но някои устройства за хардуерно ускоряване не поддържат този режим. С задаването на тази опция ще предпочете посочения режим при използване на базирано на качество кодиране. Игнорирано от NVENC, тъй като не поддържа ICQ.",
"transcoding_constant_rate_factor": "Коефициент на постоянна скорост (-crf)",
"transcoding_constant_rate_factor_description": "Ниво на качество на видеото. Типичните стойности са 23 за H.264, 28 за HEVC, 31 за VP9 и 35 за AV1. По-ниското е по-добро, но създава по-големи файлове.",
"transcoding_disabled_description": "Не разкодирай видеоклиповете, може да наруши възпроизвеждането на някои клиенти",
"transcoding_hardware_acceleration": "Хардуерно ускорение",
"transcoding_hardware_acceleration_description": "Експериментално; много по-бързо, но с по-ниско качество при същия битрейт",
"transcoding_hardware_decoding": "Хардуерно декодиране",
"transcoding_hardware_decoding_setting_description": "",
"transcoding_hardware_decoding_setting_description": "Прилага се само за NVENC, QSV и RKMPP. Активира ускорение от край до край, вместо само да ускорява кодирането. Може да не работи с всички видеоклипове.",
"transcoding_hevc_codec": "HEVC кодек",
"transcoding_max_b_frames": "",
"transcoding_max_b_frames_description": "",
"transcoding_max_b_frames": "Максимални B-фрейма",
"transcoding_max_b_frames_description": "По-високите стойности подобряват ефективността на компресията, но забавят разкодирането. Може да не е съвместим с хардуерното ускорение на по-стари устройства. 0 деактивира B-фрейма, докато -1 задава тази стойност автоматично.",
"transcoding_max_bitrate": "Максимален битрейт",
"transcoding_max_bitrate_description": "",
"transcoding_max_keyframe_interval": "",
"transcoding_max_keyframe_interval_description": "",
"transcoding_max_bitrate_description": "Задаването на максимален битрейт може да направи размерите на файловете по-предвидими при незначителни разлики за качеството. При 720p типичните стойности са 2600k за VP9 или HEVC или 4500k за H.264. Деактивирано, ако е зададено на 0.",
"transcoding_max_keyframe_interval": "Максимален интервал между ключовите кадри",
"transcoding_max_keyframe_interval_description": "Задава максималното разстояние между ключовите кадри. По-ниските стойности влошават ефективността на компресията, но подобряват времето за търсене и могат да подобрят качеството в сцени с бързо движение. 0 задава тази стойност автоматично.",
"transcoding_optimal_description": "Видеоклипове с по-висока от целевата разделителна способност или не в приетия формат",
"transcoding_preferred_hardware_device": "Предпочитано хардуерно устройство",
"transcoding_preferred_hardware_device_description": "",
"transcoding_preferred_hardware_device_description": "Прилага се само за VAAPI и QSV. Задава dri възела, използван за хардуерно транскодиране.",
"transcoding_preset_preset": "",
"transcoding_preset_preset_description": "",
"transcoding_reference_frames": "",
"transcoding_reference_frames_description": "",
"transcoding_preset_preset_description": "Скорост на компресия. По-бавните предварително зададени настройки създават по-малки файлове и повишават качеството при насочване към определен битрейт. VP9 игнорира скорости над „по-бързо“.",
"transcoding_reference_frames": "Референтни фреймове",
"transcoding_reference_frames_description": "Броят кадри за препратка при компресиране на даден кадър. По-високите стойности подобряват ефективността на компресията, но забавят кодирането. 0 задава тази стойност автоматично.",
"transcoding_required_description": "Само видеа, които не са в приет формат",
"transcoding_settings": "Настройки за транскодиране на видеоклипове",
"transcoding_settings_description": "Управление на информацията за разделителната способност и кодирането на видеофайловете",
"transcoding_target_resolution": "Целева резолюция",
"transcoding_target_resolution_description": "",
"transcoding_temporal_aq": "",
"transcoding_target_resolution_description": "По-високите разделителни способности могат да представят повече детайли, но отнемат повече време за разкодиране, имат по-големи размери на файловете и могат да намалят отзивчивостта на приложението.",
"transcoding_temporal_aq": "Темпорален AQ",
"transcoding_temporal_aq_description": "Само за NVENC. Повишава качеството на сцени с висока детайлност и ниско ниво на движение. Може да не е съвместимо с по-стари устройства.",
"transcoding_threads": "Нишки",
"transcoding_threads_description": "",
"transcoding_threads_description": "По-високите стойности водят до по-бързо разкодиране, но оставят по-малко място за сървъра да обработва други задачи, докато е активен. Тази стойност не трябва да надвишава броя на процесорните ядра. Увеличава максимално използването, ако е зададено на 0.",
"transcoding_tone_mapping": "",
"transcoding_tone_mapping_description": "",
"transcoding_tone_mapping_description": "Опитва се да запази външния вид на HDR видеоклипове, когато се преобразува в SDR. Всеки алгоритъм прави различни компромиси за цвят, детайлност и яркост. Hable запазва детайлите, Mobius запазва цвета, а Reinhard запазва яркостта.",
"transcoding_tone_mapping_npl": "",
"transcoding_tone_mapping_npl_description": "",
"transcoding_tone_mapping_npl_description": "Цветовете ще бъдат коригирани, за да изглеждат нормално за дисплей с тази яркост. Противоинтуитивно, по-ниските стойности увеличават яркостта на видеото и обратно, тъй като компенсират яркостта на дисплея. 0 задава тази стойност автоматично.",
"transcoding_transcode_policy": "",
"transcoding_transcode_policy_description": "",
"transcoding_transcode_policy_description": "Правила за това кога видеоклипът трябва да бъде транскодиран. HDR видеоклиповете винаги ще бъдат транскодирани (освен ако транскодирането е деактивирано).",
"transcoding_two_pass_encoding": "",
"transcoding_two_pass_encoding_setting_description": "",
"transcoding_video_codec": "Видеокодек",
"transcoding_video_codec_description": "",
"transcoding_video_codec_description": "VP9 има висока ефективност и уеб съвместимост, но отнема повече време за транскодиране. HEVC работи по подобен начин, но има по-ниска уеб съвместимост. H.264 е широко съвместим и бърз за разкодиране, но създава много по-големи файлове. AV1 е най-ефективният кодек, но му липсва поддръжка на по-стари устройства.",
"trash_enabled_description": "",
"trash_number_of_days": "Брой дни",
"trash_number_of_days_description": "Брой дни, в които файловете да се съхраняват на боклука, преди да бъдат окончателно премахнати",
"trash_settings": "",
"trash_settings_description": "",
"untracked_files": "",
"trash_settings": "Настройки на кошчето",
"trash_settings_description": "Управление на настройките на кошчето",
"untracked_files": "Непроследени файлове",
"untracked_files_description": "Тези файлове не се проследяват от приложението. Те могат да бъдат резултат от неуспешни премествания, прекъснати качвания или оставени поради грешка",
"user_delete_delay": "<b>{user}</b> aкаунтът и файловете на потребителя ще бъдат планирани за постоянно изтриване след {delay, plural, one {# ден} other {# дни}}.",
"user_delete_delay_settings": "Забавяне на изтриване",
@@ -310,31 +312,43 @@
"user_password_has_been_reset": "Паролата на потребителя е променена:",
"user_password_reset_description": "Моля, предоставете временната парола на потребителя и го информирайте, че ще трябва да я смени при следващото си влизане в системата.",
"user_restore_description": "<b>{user}</b> aкаунтът ще бъде възстановен.",
"user_restore_scheduled_removal": "Възстановяване на потребител с насрочено премахване на {date, date, long}",
"user_settings": "Настройки на потребителя",
"user_settings_description": "Управление на потребителските настройки",
"user_successfully_removed": "Потребителят {email} е успешно премахнат.",
"version_check_enabled_description": "",
"version_check_settings": "",
"version_check_settings_description": "",
"version_check_enabled_description": "Активирайте периодични заявки към GitHub, за да проверявате за нови версии",
"version_check_settings": "Проверка на версията",
"version_check_settings_description": "Активирайте/деактивирайте известието за нова версия",
"video_conversion_job": "Транскодиране на видеоклиповете",
"video_conversion_job_description": ""
"video_conversion_job_description": "Транскодирай видеоклипове за по-широка съвместимост с браузъри и устройства"
},
"admin_email": "Администраторски имейл адрес",
"admin_password": "Администраторска парола",
"administration": "Администрация",
"advanced": "Разширено",
"album_added": "Албумът е добавен",
"album_added_notification_setting_description": "",
"album_cover_updated": "",
"album_info_updated": "",
"album_added_notification_setting_description": "Получавайте известие по имейл, когато бъдете добавени към споделен албум",
"album_cover_updated": "Обложката на албума е актуализирана",
"album_delete_confirmation": "Сигурни ли сте, че искате да изтриете албума {album}?\nАко този албум е споделен, другите потребители вече няма да имат достъп до него.",
"album_info_updated": "Информацията за албума е актуализирана",
"album_leave": "Да напусна ли албума?",
"album_leave_confirmation": "Сигурни ли сте, че искате да напуснете {album}?",
"album_name": "Име на албума",
"album_options": "Настройки на албума",
"album_remove_user": "Премахване на потребител?",
"album_remove_user_confirmation": "Сигурни ли сте, че искате да премахнете {user}?",
"album_share_no_users": "Изглежда, че сте споделили този албум с всички потребители или нямате друг потребител, с когото да го споделите.",
"album_updated": "Албумът е актуализиран",
"album_updated_setting_description": "",
"album_updated_setting_description": "Получавайте известие по имейл, когато споделен албум има нови файлове",
"album_user_left": "Напусна {album}",
"album_user_removed": "Премахнат {user}",
"album_with_link_access": "Нека всеки с линк вижда снимки и хора в този албум.",
"albums": "Албуми",
"albums_count": "",
"all": "Всички",
"all_albums": "Всички албуми",
"all_people": "Всички хора",
"all_videos": "Всички видеоклипове",
"allow_dark_mode": "",
"allow_edits": "Позволяване на редакции",
"api_key": "",
@@ -359,12 +373,14 @@
"bulk_delete_duplicates_confirmation": "",
"bulk_keep_duplicates_confirmation": "",
"bulk_trash_duplicates_confirmation": "",
"buy": "Купете Immich",
"camera": "Камера",
"camera_brand": "Марка на камерата",
"camera_model": "Модел на камерата",
"cancel": "Откажи",
"cancel_search": "Отмени търсенето",
"cannot_merge_people": "",
"cannot_merge_people": "Не може да обединява хора",
"cannot_undo_this_action": "Не можете да отмените това действие!",
"cannot_update_the_description": "Описанието не може да бъде актуализирано",
"cant_apply_changes": "",
"cant_get_faces": "",
@@ -376,6 +392,7 @@
"change_name": "Промени името",
"change_name_successfully": "Името е успешно променено",
"change_password": "Промени паролата",
"change_password_description": "Това е или първият път, когато влизате в системата, или е направена заявка за промяна на паролата ви. Моля, въведете новата парола по-долу.",
"change_your_password": "Променете паролата си",
"changed_visibility_successfully": "Видимостта е променена успешно",
"check_all": "Провери всичко",
@@ -384,6 +401,7 @@
"city": "Град",
"clear": "Изчисти",
"clear_all": "Изчисти всичко",
"clear_all_recent_searches": "Изчистете всички скорошни търсения",
"clear_message": "Изчисти съобщението",
"clear_value": "Изчисти стойността",
"close": "Затвори",
@@ -391,7 +409,7 @@
"collapse_all": "Свиване на всичко",
"color_theme": "Цветова тема",
"comment_deleted": "Коментарът е изтрит",
"comment_options": "",
"comment_options": "Опции за коментар",
"comments_and_likes": "Коментари и харесвания",
"comments_are_disabled": "Коментарите са деактивирани",
"confirm": "Потвърди",
@@ -404,12 +422,12 @@
"copied_image_to_clipboard": "Изображението е копирано в клипборда.",
"copied_to_clipboard": "Копирано в клипборда!",
"copy_error": "Грешка при копирането",
"copy_file_path": "",
"copy_file_path": "Копирай пътя на файла",
"copy_image": "Копиране на изображението",
"copy_link": "Копиране на линк",
"copy_link_to_clipboard": "",
"copy_link_to_clipboard": "Копиране на връзката в клипборда",
"copy_password": "Копиране на парола",
"copy_to_clipboard": "",
"copy_to_clipboard": "Копиране в клипборда",
"country": "Държава",
"cover": "",
"covers": "",
@@ -433,7 +451,7 @@
"date_of_birth_saved": "Дата на раждане е записана успешно",
"date_range": "Период от време",
"day": "Ден",
"deduplicate_all": "",
"deduplicate_all": "Дедупликиране на всички",
"default_locale": "",
"default_locale_description": "Форматиране на дати и числа в зависимост от местоположението на браузъра",
"delete": "Изтрий",
@@ -457,7 +475,7 @@
"display_options": "Опции за показване",
"display_order": "Ред на показване",
"display_original_photos": "Показване на оригинални снимки",
"display_original_photos_setting_description": "",
"display_original_photos_setting_description": "Показване на оригиналната снимка вместо миниатюри, когато оригиналният актив е съвместим с мрежата. Това може да доведе до по-бавни скорости на показване на снимки.",
"do_not_show_again": "Не показвайте това съобщение отново",
"done": "Готово",
"download": "Изтегли",
@@ -474,11 +492,11 @@
"edit_avatar": "Редактиране на аватар",
"edit_date": "Редактиране на дата",
"edit_date_and_time": "Редактиране на дата и час",
"edit_exclusion_pattern": "",
"edit_exclusion_pattern": "Редактиране на шаблон за изключване",
"edit_faces": "Редактиране на лица",
"edit_import_path": "",
"edit_import_paths": "",
"edit_key": "",
"edit_import_path": "Редактиране на пътя за импортиране",
"edit_import_paths": "Редактиране на пътища за импортиране",
"edit_key": "Редактиране на ключ",
"edit_link": "Редактиране на линк",
"edit_location": "Редактиране на местоположението",
"edit_name": "Редактиране на име",
@@ -489,6 +507,7 @@
"editor": "",
"email": "Имейл",
"empty_trash": "Изпразване на кош",
"empty_trash_confirmation": "Сигурни ли сте, че искате да изпразните кошчето? Това ще премахне всичко в кошчето за постоянно от Immich.\nНе можете да отмените това действие!",
"enable": "Включване",
"enabled": "Включено",
"end_date": "Крайна дата",
@@ -498,16 +517,22 @@
"errors": {
"cannot_navigate_next_asset": "Не можете да преминете към следващия файл",
"cannot_navigate_previous_asset": "Не можете да преминете към предишния актив",
"cant_apply_changes": "Не могат да се приложат промение",
"cant_change_asset_favorite": "Не може да промени любими за файл",
"cant_get_faces": "Не мога да намеря лица",
"cant_get_number_of_comments": "Не може да получи броя на коментарите",
"cant_search_people": "Не може да търси хора",
"cant_search_places": "Не може да търси места",
"cleared_jobs": "Изчистени задачи за: {job}",
"error_adding_assets_to_album": "Грешка при добавянето на файловете в албума",
"error_adding_users_to_album": "Грешка при добавяне на потребители в албум",
"error_deleting_shared_user": "Грешка при изтриване на споделен потребител",
"error_downloading": "Грешка при изтегляне на {filename}",
"error_hiding_buy_button": "Грешка при скриването на бутона за купуване",
"error_removing_assets_from_album": "Грешка при премахването на файловете от албума, проверете конзолата за повече информация",
"error_selecting_all_assets": "Грешка при избора на всички файлове",
"exclusion_pattern_already_exists": "",
"exclusion_pattern_already_exists": "Този модел за изключване вече съществува.",
"failed_job_command": "Командата {command} е неуспешна за задача: {job}",
"failed_to_create_album": "Неуспешно създаване на албум",
"failed_to_create_shared_link": "Неуспешно създаване на споделена връзка",
"failed_to_edit_shared_link": "Неуспешно редактиране на споделена връзка",
@@ -525,28 +550,36 @@
"unable_to_add_exclusion_pattern": "",
"unable_to_add_import_path": "",
"unable_to_add_partners": "",
"unable_to_change_album_user_role": "",
"unable_to_change_date": "",
"unable_to_change_location": "",
"unable_to_change_password": "",
"unable_to_copy_to_clipboard": "",
"unable_to_create_api_key": "",
"unable_to_create_library": "",
"unable_to_create_user": "",
"unable_to_delete_album": "",
"unable_to_delete_asset": "",
"unable_to_change_album_user_role": "Не може да се промени ролята на потребителя на албума",
"unable_to_change_date": "Не може да се промени датата",
"unable_to_change_favorite": "Не може да промени фаворит за актив",
"unable_to_change_location": "Не може да се промени местоположението",
"unable_to_change_password": "Не може да се промени паролата",
"unable_to_change_visibility": "Не може да се промени видимостта за {count, plural, one {# person} other {# people}}",
"unable_to_complete_oauth_login": "Не може да се завърши OAuth влизане",
"unable_to_connect": "Не може да се свърже",
"unable_to_connect_to_server": "Не може да се свърже със сървъра",
"unable_to_copy_to_clipboard": "Не може да се копира в клипборда, уверете се, че имате достъп до страницата през https",
"unable_to_create_admin_account": "Не може да създаде администраторски акаунт",
"unable_to_create_api_key": "Не може да се създаде нов API ключ",
"unable_to_create_library": "Не може да се създаде библиотека",
"unable_to_create_user": "Не може да се създаде потребител",
"unable_to_delete_album": "Не може да изтрие албума",
"unable_to_delete_asset": "Не може да изтрие файла",
"unable_to_delete_assets": "Грешка при изтриване на файлове",
"unable_to_delete_exclusion_pattern": "",
"unable_to_delete_import_path": "",
"unable_to_delete_shared_link": "",
"unable_to_delete_user": "",
"unable_to_edit_exclusion_pattern": "",
"unable_to_edit_import_path": "",
"unable_to_empty_trash": "",
"unable_to_enter_fullscreen": "",
"unable_to_exit_fullscreen": "",
"unable_to_delete_exclusion_pattern": "Не може да изтрие шаблон за изключване",
"unable_to_delete_import_path": "Пътят за импортиране не може да се изтрие",
"unable_to_delete_shared_link": "Споделената връзка не може да се изтрие",
"unable_to_delete_user": "Не може да изтрие потребител",
"unable_to_download_files": "Не могат да се изтеглят файловете",
"unable_to_edit_exclusion_pattern": "Не може да се редактира шаблон за изключване",
"unable_to_edit_import_path": "Пътят за импортиране не може да се редактира",
"unable_to_empty_trash": "Не може да изпразни кошчето",
"unable_to_enter_fullscreen": "Не може да се отвори в цял екран",
"unable_to_exit_fullscreen": "Не може да излезе от цял екран",
"unable_to_get_comments_number": "Не може да получи брой коментари",
"unable_to_get_shared_link": "Неуспешно създаване на споделена връзка",
"unable_to_hide_person": "",
"unable_to_hide_person": "Не може да скрие човек",
"unable_to_link_oauth_account": "",
"unable_to_load_album": "",
"unable_to_load_asset_activity": "",
@@ -998,27 +1031,34 @@
"unfavorite": "Премахване от любимите",
"unhide_person": "",
"unknown": "Неизвестно",
"unknown_year": "",
"unknown_year": "Неизвестна година",
"unlimited": "Неограничено",
"unlink_oauth": "",
"unlinked_oauth_account": "",
"unnamed_album": "",
"unnamed_share": "",
"unselect_all": "",
"unnamed_album": "Албум без име",
"unnamed_share": "Споделяне без име",
"unsaved_change": "Незапазена промяна",
"unselect_all": "Деселектирайте всички",
"unselect_all_duplicates": "От маркирай всички дубликати",
"unstack": "",
"untracked_files": "",
"untracked_files_decription": "",
"up_next": "",
"updated_password": "",
"untracked_files": "Непознати файлове",
"untracked_files_decription": "Тези файлове са не разпознати от приложението. Те могат да бъдат резултат от неуспешни прехвърля ния, прекъснати качвания или незавършени поради грешка",
"up_next": "Следващ",
"updated_password": "Паролата е актуализирана",
"upload": "Качване",
"upload_concurrency": "",
"upload_progress": "Остават {remaining, number} - Обработени {processed, number}/{total, number}",
"upload_status_duplicates": "Дубликати",
"upload_status_errors": "Грешки",
"upload_status_uploaded": "Качено",
"url": "",
"upload_success": "Качването е успешно, опреснете страницата, за да видите новите файлове.",
"url": "URL",
"usage": "Потребление",
"user": "Потребител",
"user_id": "",
"user_id": "Потребител ИД",
"user_purchase_settings": "Покупка",
"user_purchase_settings_description": "Управлявай покупката си",
"user_role_set": "Задай {user} като {role}",
"user_usage_detail": "",
"username": "Потребителско име",
"users": "Потребители",
@@ -1026,10 +1066,11 @@
"validate": "Валидиране",
"variables": "Променливи",
"version": "Версия",
"version_announcement_message": "",
"version_announcement_closing": "Твой приятел, Алекс",
"version_announcement_message": "Здравей, има нова версия на приложението. Моля, отдели малко време, за да разгледаш <link>новости те за версията</link> и да се увериш, че <code>docker-compose.yml</code> и <code>.env</code> е актуална, за да се предотвратят неправилни конфигурации, особено ако използвате WatchTower или друг механизъм, който управлява автоматичното актуализиране на вашето приложение.",
"video": "Видеоклип",
"video_hover_setting": "Възпроизвеждане на видеоклип при посочване с мишката",
"video_hover_setting_description": "",
"video_hover_setting_description": "Възпроизвеждане на видеоклипа, когато мишката се движи над елемента. Дори когато е деактивирано, възпроизвеждането може да бъде стартирано чрез задържане на курсора на мишката върху иконата за възпроизвеждане.",
"videos": "Видеоклипове",
"videos_count": "",
"view": "Преглед",

View File

@@ -7,7 +7,7 @@
"actions": "Accions",
"active": "Actiu",
"activity": "Activitat",
"activity_changed": "L'activitat està {enabled, select, true {enabled} other {disabled}}",
"activity_changed": "L'activitat està {enabled, select, true {activada} other {desactivada}}",
"add": "Agregar",
"add_a_description": "Afegir una descripció",
"add_a_location": "Afegir una ubicació",
@@ -199,8 +199,8 @@
"registration_description": "Com que ets el primer usuari del sistema, seràs designat com a administrador i seràs responsable de les tasques administratives. També seràs l'encarregat de crear usuaris addicionals.",
"removing_offline_files": "Eliminant fitxers fora de línia",
"repair_all": "Reparar tot",
"repair_matched_items": "Coincidència {count, plural, one {# item} other {# items}}",
"repaired_items": "Corregit {count, plural, one {# item} other {# items}}",
"repair_matched_items": "Coincidència {count, plural, one {# element} other {# elements}}",
"repaired_items": "Corregit {count, plural, one {# element} other {# elements}}",
"require_password_change_on_login": "Requerir que l'usuari canviï la contrasenya en el primer inici de sessió",
"reset_settings_to_default": "Restablir configuracions per defecte",
"reset_settings_to_recent_saved": "Restablir la configuració guardada més recent",
@@ -307,7 +307,7 @@
"trash_settings_description": "Gestiona la configuració de la paperera",
"untracked_files": "Fitxers sense seguiment",
"untracked_files_description": "L'aplicació no fa un seguiment d'aquests fitxers. Poden ser el resultat de moviments fallits, càrregues interrompudes o deixades enrere a causa d'un error",
"user_delete_delay": "El compte i els recursos de <b>{user}</b> es programaran per a la supressió permanent en {delay, plural, one {# day} other {# days}}.",
"user_delete_delay": "El compte i els recursos de <b>{user}</b> es programaran per a la supressió permanent en {delay, plural, one {# dia} other {# dies}}.",
"user_delete_delay_settings": "Retard de la supressió",
"user_delete_delay_settings_description": "Nombre de dies després de la supressió per eliminar permanentment el compte i els elements d'un usuari. El treball de supressió d'usuaris s'executa a mitjanit per comprovar si hi ha usuaris preparats per eliminar. Els canvis en aquesta configuració s'avaluaran en la propera execució.",
"user_delete_immediately": "El compte i els recursos de <b>{user}</b> es posaran a la cua per suprimir-los permanentment <b>immediatament</b>.",
@@ -351,7 +351,7 @@
"album_user_removed": "{user} eliminat",
"album_with_link_access": "Permet que qualsevol persona que tingui l'enllaç vegi fotos i persones d'aquest àlbum.",
"albums": "Àlbums",
"albums_count": "{count, plural, one {{count, number} àlbum} other {{count, number} àlbums}}",
"albums_count": "{count, plural, one {{count, number} Àlbum} other {{count, number} Àlbums}}",
"all": "Tots",
"all_albums": "Tots els àlbum",
"all_people": "Tota la gent",
@@ -388,14 +388,14 @@
"assets": "Elements",
"assets_added_count": "{count, plural, one {Afegit un element} other {Afegits # elements}}",
"assets_added_to_album_count": "{count, plural, one {Afegit un element} other {Afegits # elements}} a l'àlbum",
"assets_added_to_name_count": "S'ha afegit {count, plural, one {# asset} other {# assets}} a {hasName, select, true {<b>{name}</b>} other {new album}}",
"assets_count": "{count, plural, one {Un element} other {# elements}}",
"assets_moved_to_trash_count": "{count, plural, one {Un element mogut} other {# elements moguts}} a la paperera",
"assets_permanently_deleted_count": "{count, plural, one {Un element esborrat} other {# elements esborrats}} permanentment",
"assets_removed_count": "{count, plural, one {Un element eliminat} other {# elements eliminats}}",
"assets_added_to_name_count": "{count, plural, one {S'ha afegit # recurs} other {S'han afegit # recursos}} a {hasName, select, true {<b>{name}</b>} other {new album}}",
"assets_count": "{count, plural, one {# recurs} other {# recursos}}",
"assets_moved_to_trash_count": "{count, plural, one {# recurs mogut} other {# recursos moguts}} a la paperera",
"assets_permanently_deleted_count": "{count, plural, one {# recurs esborrat} other {# recursos esborrats}} permanentment",
"assets_removed_count": "{count, plural, one {# element eliminat} other {# elements eliminats}}",
"assets_restore_confirmation": "Esteu segurs que voleu restaurar tots els teus actius? Aquesta acció no es pot desfer!",
"assets_restored_count": "{count, plural, one {Un element restaurat} other {# elements restaurats}}",
"assets_trashed_count": "{count, plural, one {Un element enviat} other {# elements enviats}} a la paperera",
"assets_restored_count": "{count, plural, one {# element restaurat} other {# elements restaurats}}",
"assets_trashed_count": "{count, plural, one {# element enviat} other {# elements enviats}} a la paperera",
"assets_were_part_of_album_count": "{count, plural, one {L'element ja és} other {Els elements ja són}} part de l'àlbum",
"authorized_devices": "Dispositius autoritzats",
"back": "Enrere",
@@ -406,9 +406,9 @@
"blurred_background": "Fons difuminat",
"build": "Construeix",
"build_image": "Construeix la imatge",
"bulk_delete_duplicates_confirmation": "Esteu segur que voleu suprimir de manera massiva {count, plural, one {# duplicate asset} other {# duplicate asset}}? Això mantindrà el recurs més gran de cada grup i esborrarà permanentment tots els altres duplicats. No podeu desfer aquesta acció!",
"bulk_keep_duplicates_confirmation": "Esteu segur que voleu mantenir {count, plural, one {# duplicate asset} other {# duplicate asset}}? Això resoldrà tots els grups duplicats sense eliminar res.",
"bulk_trash_duplicates_confirmation": "Esteu segur que voleu enviar a les escombraries {count, plural, one {# duplicate asset} other {# duplicate asset}}? Això mantindrà el recurs més gran de cada grup i eliminarà la resta de duplicats.",
"bulk_delete_duplicates_confirmation": "Esteu segur que voleu suprimir de manera massiva {count, plural, one {# recurs duplicat} other {# recursos duplicats}}? Això mantindrà el recurs més gran de cada grup i esborrarà permanentment tots els altres duplicats. No podeu desfer aquesta acció!",
"bulk_keep_duplicates_confirmation": "Esteu segur que voleu mantenir {count, plural, one {# recurs duplicat} other {# recursos duplicats}}? Això resoldrà tots els grups duplicats sense eliminar res.",
"bulk_trash_duplicates_confirmation": "Esteu segur que voleu enviar a les escombraries {count, plural, one {# recurs duplicat} other {# recursos duplicats}}? Això mantindrà el recurs més gran de cada grup i eliminarà la resta de duplicats.",
"buy": "Comprar Immich",
"camera": "Càmera",
"camera_brand": "Marca de la càmera",
@@ -441,6 +441,7 @@
"clear_message": "Neteja el missatge",
"clear_value": "Neteja el valor",
"close": "Tanca",
"collapse": "Tanca",
"collapse_all": "Redueix-ho tot",
"color_theme": "Tema de color",
"comment_deleted": "Comentari esborrat",
@@ -464,8 +465,8 @@
"copy_password": "Còpia la contrasenya",
"copy_to_clipboard": "Copiar al porta-retalls",
"country": "País",
"cover": "",
"covers": "",
"cover": "Portada",
"covers": "Portades",
"create": "Crea",
"create_album": "Crear un àlbum",
"create_library": "Crea una llibreria",
@@ -514,8 +515,8 @@
"display_original_photos_setting_description": "Preferiu mostrar la foto original quan visualitzeu un recurs en lloc de miniatures quan el recurs original és compatible amb el web. Això pot provocar una velocitat de visualització de fotos més lenta.",
"do_not_show_again": "No tornis a mostrar aquest missatge",
"done": "Fet",
"download": "Baixar",
"download_settings": "Baixar",
"download": "Descarregar",
"download_settings": "Descarregar",
"download_settings_description": "Gestioneu la configuració relacionada amb la descàrrega de recursos",
"downloading": "Baixant",
"downloading_asset_filename": "Descarregant l'element {filename}",
@@ -592,10 +593,10 @@
"failed_to_unstack_assets": "No s'han pogut desapilar els elements",
"import_path_already_exists": "Aquest camí d'importació ja existeix.",
"incorrect_email_or_password": "Correu electrònic o contrasenya incorrectes",
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} no ha pogut validar",
"paths_validation_failed": "{paths, plural, one {# ruta} other {# rutes}} no ha pogut validar",
"profile_picture_transparent_pixels": "Les fotos de perfil no poden tenir píxels transparents. Per favor, feu zoom in, mogueu la imatge o ambdues.",
"quota_higher_than_disk_size": "Heu establert una quota més gran que la mida de disc",
"repair_unable_to_check_items": "No es pot comprovar {count, select, one {item} other {items}}",
"repair_unable_to_check_items": "No es pot comprovar {count, select, one {l'element} other {els elements}}",
"unable_to_add_album_users": "No es poden afegir usuaris a l'àlbum",
"unable_to_add_assets_to_shared_link": "No s'han pogut afegir els elements a l'enllaç compartit",
"unable_to_add_comment": "No es pot afegir el comentari",
@@ -604,13 +605,13 @@
"unable_to_add_partners": "No es poden afegir companys",
"unable_to_add_remove_archive": "No s'ha pogut {archived, select, true {eliminar l'element de} other {afegir l'element a}} l'arxiu",
"unable_to_add_remove_favorites": "No s'ha pogut {favorite, select, true {afegir l'element als} other {eliminar l'element dels}} preferits",
"unable_to_archive_unarchive": "No es pot {archived, select, true {archive} other {unarchive}}",
"unable_to_archive_unarchive": "No es pot {archived, select, true {arxivar} other {desarxivar}}",
"unable_to_change_album_user_role": "No es pot canviar el rol d'usuari de l'àlbum",
"unable_to_change_date": "No es pot canviar la data",
"unable_to_change_favorite": "No es pot canviar el favorit per a aquest recurs",
"unable_to_change_location": "No es pot canviar la ubicació",
"unable_to_change_password": "No es pot canviar la contrasenya",
"unable_to_change_visibility": "No es pot canviar la visibilitat de {count, plural, one {# person} other {# people}}",
"unable_to_change_visibility": "No es pot canviar la visibilitat de {count, plural, one {# persona} other {# persones}}",
"unable_to_check_item": "",
"unable_to_check_items": "",
"unable_to_complete_oauth_login": "No es pot completar l'inici de sessió OAuth",
@@ -646,7 +647,7 @@
"unable_to_log_out_device": "No es pot tancar la sessió del dispositiu",
"unable_to_login_with_oauth": "No es pot iniciar sessió amb OAuth",
"unable_to_play_video": "No es pot reproduir el vídeo",
"unable_to_reassign_assets_existing_person": "No es poden reassignar recursos a {name, select, null {an existing person} other {{name}}}",
"unable_to_reassign_assets_existing_person": "No es poden reassignar recursos a {name, select, null {una persona existent} other {{name}}}",
"unable_to_reassign_assets_new_person": "No es poden reassignar recursos a una persona nova",
"unable_to_refresh_user": "No es pot actualitzar l'usuari",
"unable_to_remove_album_users": "No es poden eliminar usuaris de l'àlbum",
@@ -690,6 +691,7 @@
"every_night_at_midnight": "",
"every_night_at_twoam": "",
"every_six_hours": "",
"exif": "Exif",
"exit_slideshow": "Surt de la presentació de diapositives",
"expand_all": "Ampliar-ho tot",
"expire_after": "Caduca després de",
@@ -711,7 +713,7 @@
"featurecollection": "",
"file_name": "Nom de l'arxiu",
"file_name_or_extension": "Nom de l'arxiu o extensió",
"filename": "Nom de l'arxiu",
"filename": "Nom del fitxer",
"files": "",
"filetype": "Tipus d'arxiu",
"filter_people": "Filtra persones",
@@ -751,11 +753,11 @@
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} pres/a a {city}, {country} amb {person1}, {person2}, i {person3} el {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} pres/a a {city}, {country} amb {person1}, {person2}, i {additionalCount, number} altres el {date}",
"img": "",
"immich_logo": "",
"immich_logo": "Logotip d'Immich",
"immich_web_interface": "Interfície web Immich",
"import_from_json": "Importar des de JSON",
"import_path": "Ruta d'importació",
"in_albums": "A {count, plural, one {# album} other {# albums}}",
"in_albums": "A {count, plural, one {# àlbum} other {# àlbums}}",
"in_archive": "En arxiu",
"include_archived": "Incloure arxivats",
"include_shared_albums": "Inclou àlbums compartits",
@@ -774,6 +776,7 @@
"job_settings_description": "",
"jobs": "Tasques",
"keep": "Mantenir",
"keep_all": "Mantenir-ho tot",
"keyboard_shortcuts": "Dreceres de teclat",
"language": "Idioma",
"language_setting_description": "Seleccioneu el vostre idioma",
@@ -842,7 +845,7 @@
"merge_people_limit": "Només pots combinar fins a 5 cares alhora",
"merge_people_prompt": "Vols combinar aquestes persones? Aquesta acció és irreversible.",
"merge_people_successfully": "Persones combinades amb èxit",
"merged_people_count": "Combinades {count, plural, one {# person} other {# people}}",
"merged_people_count": "Combinades {count, plural, one {# persona} other {# persones}}",
"minimize": "Minimitza",
"minute": "Minut",
"missing": "Restants",
@@ -892,6 +895,7 @@
"offline_paths_description": "Aquests resultats poden ser deguts a la supressió manual de fitxers que no formen part d'una biblioteca externa.",
"ok": "D'acord",
"oldest_first": "El més vell primer",
"onboarding": "Onboarding",
"onboarding_theme_description": "Trieu un tema de color per a la vostra instància. Podeu canviar-ho més endavant a la vostra configuració.",
"onboarding_welcome_description": "Configurem la vostra instància amb alguns paràmetres habituals.",
"onboarding_welcome_user": "Benvingut, {user}",
@@ -931,7 +935,7 @@
"paused": "En pausa",
"pending": "Pendent",
"people": "Persones",
"people_edits_count": "{count, plural, one {Una persona editada} other {# persones editades}}",
"people_edits_count": "{count, plural, one {# persona editada} other {# persones editades}}",
"people_sidebar_description": "Mostrar un enllaç a Persones a la barra lateral",
"perform_library_tasks": "",
"permanent_deletion_warning": "Avís d'eliminació permanent",
@@ -940,13 +944,13 @@
"permanently_delete_assets_count": "Eliminar permanentment {count, plural, one {l'element} other {els elements}}",
"permanently_delete_assets_prompt": "Esteu segur que voleu suprimir permanentment {count, plural, one {aquest recurs?} other {aquests <b>#</b> recursos?}} Això també {count, plural, one {el} other {els}} suprimirà del seu àlbum.",
"permanently_deleted_asset": "Element eliminat permanentment",
"permanently_deleted_assets_count": "{count, plural, one {S'ha eliminat un element} other {S'han eliminat # elements}} permanentment",
"permanently_deleted_assets_count": "{count, plural, one {S'ha eliminat # element} other {S'han eliminat # elements}} permanentment",
"person": "Persona",
"person_hidden": "{name}{hidden, select, true { (ocultat)} other {}}",
"photo_shared_all_users": "Sembla que has compartit les teves fotos amb tots els usuaris o no tens cap usuari amb qui compartir-les.",
"photos": "Fotos",
"photos_and_videos": "Fotos i vídeos",
"photos_count": "{count, plural, one {{count, number} foto} other {{count, number} fotos}}",
"photos_count": "{count, plural, one {{count, number} Foto} other {{count, number} Fotos}}",
"photos_from_previous_years": "Fotos d'anys anteriors",
"pick_a_location": "Triar una ubicació",
"place": "Lloc",
@@ -967,6 +971,7 @@
"profile_picture_set": "Imatge de perfil configurada.",
"public_album": "Àlbum públic",
"public_share": "Compartit públicament",
"purchase_account_info": "Contribuent",
"purchase_activated_subtitle": "Gràcies per donar suport a Immich i al programari de codi obert",
"purchase_activated_time": "Activat el {date, date}",
"purchase_activated_title": "La teva clau s'ha activat correctament",
@@ -979,6 +984,8 @@
"purchase_button_select": "Seleccioneu",
"purchase_failed_activation": "No s'ha pogut activar! Si us plau, comproveu el vostre correu electrònic per trobar la clau de producte correcta!",
"purchase_individual_description_1": "Per a un particular",
"purchase_individual_description_2": "Estat de la contribució",
"purchase_individual_title": "Individual",
"purchase_input_suggestion": "Tens una clau de producte? Introduïu la clau a continuació",
"purchase_license_subtitle": "Compra Immich per donar suport al desenvolupament continuat del servei",
"purchase_lifetime_description": "Compra de per vida",
@@ -993,6 +1000,7 @@
"purchase_remove_server_product_key": "Elimina la clau de producte del servidor",
"purchase_remove_server_product_key_prompt": "Esteu segur que voleu eliminar la clau de producte del servidor?",
"purchase_server_description_1": "Per a tot el servidor",
"purchase_server_description_2": "Estat del contribuent",
"purchase_server_title": "Servidor",
"purchase_settings_server_activated": "La clau de producte del servidor la gestiona l'administrador",
"range": "",
@@ -1000,8 +1008,8 @@
"reaction_options": "Opcions de reacció",
"read_changelog": "Llegeix el registre de canvis",
"reassign": "Reassignar",
"reassigned_assets_to_existing_person": "S'ha reassignat {count, plural, one {# recurs} other {# recursos}} a {name, select, null {una persona existent} other {{name}}}",
"reassigned_assets_to_new_person": "S'ha reassignat {count, plural, one {# recurs} other {# recursos}} a una persona nova",
"reassigned_assets_to_existing_person": "{count, plural, one {S'ha reassignat # recurs} other {S'han reassignat # recursos}} a {name, select, null {una persona existent} other {{name}}}",
"reassigned_assets_to_new_person": "{count, plural, one {S'ha reassignat # recurs} other {S'han reassignat # recursos}} a una persona nova",
"reassing_hint": "Assignar els elements seleccionats a una persona existent",
"recent": "Recent",
"recent_searches": "Cerques recents",
@@ -1027,8 +1035,8 @@
"removed_api_key": "Eliminada la clau d'API: {name}",
"removed_from_archive": "Eliminat de l'arxiu",
"removed_from_favorites": "Eliminat dels preferits",
"removed_from_favorites_count": "{count, plural, other {Removed #}} dels preferits",
"rename": "Canvia el nom",
"removed_from_favorites_count": "{count, plural, other {# eliminats}} dels preferits",
"rename": "Canviar nom",
"repair": "Reparació",
"repair_no_results_message": "Els fitxers sense seguiment i que falten es mostraran aquí",
"replace_with_upload": "Substituir amb una pujada",
@@ -1050,6 +1058,7 @@
"retry_upload": "Torna a provar de pujar",
"review_duplicates": "Revisar duplicats",
"role": "Rol",
"role_editor": "Editor",
"role_viewer": "Visor",
"save": "Desa",
"saved_api_key": "Clau d'API guardada",
@@ -1065,6 +1074,7 @@
"search_albums": "Buscar àlbums",
"search_by_context": "Buscar per context",
"search_by_filename": "Cerca per nom de fitxer o extensió",
"search_by_filename_example": "i.e. IMG_1234.JPG or PNG",
"search_camera_make": "Buscar per fabricant de càmara...",
"search_camera_model": "Buscar per model de càmera...",
"search_city": "Buscar per ciutat...",
@@ -1138,6 +1148,8 @@
"show_person_options": "Mostra opcions de la persona",
"show_progress_bar": "Mostra barra de progrés",
"show_search_options": "Mostra opcions de cerca",
"show_supporter_badge": "Insígnia de contribuent",
"show_supporter_badge_description": "Mostra una insígnia de contributor",
"shuffle": "Mescla",
"sign_out": "Tanca sessió",
"sign_up": "Registrar-se",
@@ -1152,6 +1164,7 @@
"sort_oldest": "Foto més antiga",
"sort_recent": "Foto més recent",
"sort_title": "Títol",
"source": "Font",
"stack": "Apila",
"stack_selected_photos": "Apila les fotos seleccionades",
"stacked_assets_count": "Apilats {count, plural, one {# element} other {# elements}}",
@@ -1219,7 +1232,7 @@
"updated_password": "Contrasenya actualitzada",
"upload": "Pujar",
"upload_concurrency": "Concurrència de pujades",
"upload_errors": "Càrrega completada amb {count, plural, one {un error} other {# errors}}, actualitzeu la pàgina per veure els nous elements carregats.",
"upload_errors": "Càrrega completada amb {count, plural, one {# error} other {# errors}}, actualitzeu la pàgina per veure els nous elements carregats.",
"upload_progress": "Restant {remaining, number} - Processat {processed, number}/{total, number}",
"upload_skipped_duplicates": "{count, plural, one {S'ha omès # recurs duplicat} other {S'han omès # recursos duplicats}}",
"upload_status_duplicates": "Duplicats",
@@ -1266,7 +1279,7 @@
"welcome": "Benvingut",
"welcome_to_immich": "Benvingut a immich",
"year": "Any",
"years_ago": "Fa {years, plural, one {un any} other {# anys}}",
"years_ago": "Fa {years, plural, one {# any} other {# anys}}",
"yes": "Sí",
"you_dont_have_any_shared_links": "No tens cap enllaç compartit",
"zoom_image": "Ampliar Imatge"

1
web/src/lib/i18n/el.json Normal file
View File

@@ -0,0 +1 @@
{}

1
web/src/lib/i18n/et.json Normal file
View File

@@ -0,0 +1 @@
{}

View File

@@ -25,7 +25,7 @@
"add_to_shared_album": "הוסף לאלבום משותף",
"added_to_archive": "נוסף לארכיון",
"added_to_favorites": "נוסף למועדפים",
"added_to_favorites_count": "{count} נוספו למועדפים",
"added_to_favorites_count": "{count, number} נוספו למועדפים",
"admin": {
"add_exclusion_pattern_description": "הוסף דפוסי החרגה. נתמכת התאמת דפוסים באמצעות *, ** ו-?. כדי להתעלם מכל הקבצים בתיקיה כלשהי בשם \"Raw\", השתמש ב \"**/Raw/**\". כדי להתעלם מכל הקבצים המסתיימים ב \"tif.\", השתמש ב \"tif.*/**\". כדי להתעלם מנתיב מוחלט, השתמש ב \"**/נתיב/להתעלמות\".",
"authentication_settings": "הגדרות אימות",
@@ -250,6 +250,7 @@
"transcoding_accepted_audio_codecs": "קודקים מקובלים של שמע",
"transcoding_accepted_audio_codecs_description": "בחר אילו קודקים של שמע אינם צריכים לעבור המרת קידוד. משמש רק עבור פוליסות המרת קידוד מסוימות.",
"transcoding_accepted_containers": "מכולות מקובלות",
"transcoding_accepted_containers_description": "בחר אילו פורמטי מכולות אינם צריכים לעבור עיבוד מחדש לפורמט MP4. משתמשים בכך רק עבור מדיניות קידוד מחדש מסוימות.",
"transcoding_accepted_video_codecs": "קודקים מקובלים של סרטונים",
"transcoding_accepted_video_codecs_description": "בחר אילו קודקים של סרטונים אינם צריכים לעבור המרת קידוד. משמש רק עבור פוליסות המרת קידוד מסוימות.",
"transcoding_advanced_options_description": "אפשרויות שרוב המשתמשים לא צריכים לשנות",
@@ -285,7 +286,7 @@
"transcoding_settings_description": "נהל את הרזולוציה ומידע הקידוד של קבצי הסרטונים",
"transcoding_target_resolution": "רזולוציה יעד",
"transcoding_target_resolution_description": "רזולוציות גבוהות יותר יכולות לשמר פרטים רבים יותר אך לוקחות זמן רב יותר לקידוד, יש להן גדלי קבצים גדולים יותר, ויכולות להפחית את תגובתיות היישום.",
"transcoding_temporal_aq": "AQ זמני",
"transcoding_temporal_aq": "Temporal AQ",
"transcoding_temporal_aq_description": "חל רק על NVENC. מגביר את האיכות של סצנות עם רמת פירוט גבוהה בהילוך איטי. ייתכן שלא יהיה תואם למכשירים ישנים יותר.",
"transcoding_threads": "תהליכונים",
"transcoding_threads_description": "ערכים גבוהים יותר מובילים לקידוד מהיר יותר, אך משאירים פחות מקום לשרת לעבד משימות אחרות בעודו פעיל. ערך זה לא אמור להיות יותר ממספר ליבות המעבד. ממקסם את הניצול אם מוגדר ל-0.",
@@ -405,7 +406,7 @@
"birthdate_set_description": "תאריך לידה משמש לחישוב הגיל של האדם הזה בזמן תצלום.",
"blurred_background": "רקע מטושטש",
"build": "Build",
"build_image": "Build Image",
"build_image": "בניית Image",
"bulk_delete_duplicates_confirmation": "האם את/ה בטוח/ה שברצונך למחוק בכמות גדולה {count, plural, one {נכס # כפול} other {# נכסים כפולים}}? זה ישמור על הנכס הכי גדול של כל קבוצה וימחק לצמיתות את כל שאר הכפילויות. את/ה לא יכול/ה לבטל את הפעולה הזו!",
"bulk_keep_duplicates_confirmation": "האם את/ה בטוח/ה שברצונך להשאיר {count, plural, one {נכס # כפול} other {# נכסים כפולים}}? זה יפתור את כל הקבוצות הכפולות מבלי למחוק דבר.",
"bulk_trash_duplicates_confirmation": "האם את/ה בטוח/ה שברצונך להעביר לאשפה בכמות גדולה {count, plural, one {נכס # כפול} other {# נכסים כפולים}}? זה ישמור על הנכס הגדול ביותר של כל קבוצה ויעביר לאשפה את כל שאר הכפילויות.",
@@ -872,6 +873,7 @@
"name": "שם",
"name_or_nickname": "שם או כינוי",
"never": "אף פעם",
"new_album": "אלבום חדש",
"new_api_key": "מפתח API חדש",
"new_password": "סיסמה חדשה",
"new_person": "אדם חדש",
@@ -1089,7 +1091,7 @@
"search_albums": "חפש אלבומים",
"search_by_context": "חפש לפי הקשר",
"search_by_filename": "חיפוש לפי שם קובץ או סיומת",
"search_by_filename_example": "לדוגמא IMG_1234.JPG/PNG",
"search_by_filename_example": "לדוגמא IMG_1234.JPG או PNG",
"search_camera_make": "חפש תוצרת מצלמה...",
"search_camera_model": "חפש דגם מצלמה...",
"search_city": "חפש עיר...",
@@ -1197,7 +1199,7 @@
"storage_usage": "{used} בשימוש מתוך {available}",
"submit": "שלח",
"suggestions": "הצעות",
"sunrise_on_the_beach": "שקיעה על החוף (מומלץ לחפש באנגלית לתוצאות טובות יותר)",
"sunrise_on_the_beach": "Sunrise on the beach (מומלץ לחפש באנגלית לתוצאות טובות יותר)",
"swap_merge_direction": "החלף כיוון מיזוג",
"sync": "סנכרן",
"template": "תבנית",
@@ -1218,7 +1220,7 @@
"total_usage": "שימוש כולל",
"trash": "אשפה",
"trash_all": "העבר הכל לאשפה",
"trash_count": "{count} לאשפה",
"trash_count": "{count, number} קבצים לאשפה",
"trash_delete_asset": "העבר לאשפה/מחק נכס",
"trash_no_results_message": "תמונות וסרטונים שהועברו לאשפה יופיעו כאן.",
"trashed_items_will_be_permanently_deleted_after": "פריטים באשפה ימחקו לצמיתות לאחר {days, plural, one {יום #} other {# ימים}}.",

View File

@@ -25,7 +25,7 @@
"add_to_shared_album": "Felvétel megosztott albumba",
"added_to_archive": "Hozzáadva az archívumhoz",
"added_to_favorites": "Hozzáadva a kedvencekhez",
"added_to_favorites_count": "{count} hozzáadva a kedvencekhez",
"added_to_favorites_count": "{count, number} hozzáadva a kedvencekhez",
"admin": {
"add_exclusion_pattern_description": "Kizáró minta megadása. Támogatja *, ** és ? dzsókerek használatát. Pl. a \"Raw\" könyvtárban tárolt összes fájl figyelmen kívül hagyásához használható a \"**/Raw/**\". Minden \".tif\" fájl figyelmen kívül hagyásához használható a \"**/*.tif\". Abszolut elérési útvonal figyelmen kívül hagyásához használható a \"/path/to/ignore/**\".",
"authentication_settings": "Hitelesítési beállítások",
@@ -227,6 +227,7 @@
"storage_template_migration_info": "A megváltozott sablon csak az újonnan feltöltött fájlokra lesz alkalmazva. A fájlok visszamenőleges megváltoztatásához futtatni kell a megfelelő munkát: <link>{job}</link>.",
"storage_template_migration_job": "Tárhely Sablon Migrációja",
"storage_template_more_details": "További információért erről a szolgáltatásról lásd <template-link>Tárolási Sablont</template-link> és az <implications-link>implikációkat</implications-link>",
"storage_template_onboarding_description": "Engedélyezve, ez a funkció automatikusan rendszerezi a fájlokat egy felhasználó által megadott sablon alapján. Stabilitási problémák miatt a funkció alapértelmezés szerint ki van kapcsolva. További információkért tekintse meg a <link>dokumentációt</link>.",
"storage_template_path_length": "Út hozzávetőleges maximális hossza: <b>{length, number}</b>{limit, number}",
"storage_template_settings": "Tárolási sablon",
"storage_template_settings_description": "Kezelje a feltöltött képi vagyontárgyak mappaszerkezetét és fájlnevét",
@@ -259,7 +260,7 @@
"transcoding_codecs_learn_more": "Hogy többet tudjon meg az itt felhasznált kifejezésekről, látogassa meg az FFmpeg dokumentációt a <h264-link>H.264 kodekhez</h264-link>, a <hevc-link>HEVC kodekhez</hevc-link> és a <vp9-link>VP9 kodekhez</vp9-link>.",
"transcoding_constant_quality_mode": "Állandó minőségi mód",
"transcoding_constant_quality_mode_description": "Az ICQ jobb, mint a CQP, viszont nem minden hardver támogatja. A rendszer az itt beállított módszert részesíti előnyben. A NVENC ignorálja a beállítást, mivel nem támogatja az ICQ-t.",
"transcoding_constant_rate_factor": "",
"transcoding_constant_rate_factor": "Állandó ráta tényező (árt)",
"transcoding_constant_rate_factor_description": "Videó minőségi szint. Jellemző értékek kodekenként: H.264: 23, HEVC: 28, VP9: 31, AV1: 35. Minél alacsonyabb, annál jobb minőséget eredményez, viszont nagyobb fájlmérettel is jár.",
"transcoding_disabled_description": "Ne transzkódoljon videót. Nem lejátszható videókhoz vezethet néhány kliensen",
"transcoding_hardware_acceleration": "Hardveres Gyorsítás",
@@ -347,13 +348,20 @@
"album_updated_setting_description": "Küldjön emailes értesítőt, amikor egy megosztott albumhoz új elemet adnak hozzá",
"album_user_left": "Elhagyta a {album} albumot",
"album_user_removed": "{user} eltávolítva",
"album_with_link_access": "Engedélyezze, hogy a link birtokában bárki láthatja a fotókat és a személyeket ebben az albumban.",
"albums": "Albumok",
"albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Album}}",
"all": "Összes",
"all_albums": "Összes album",
"all_people": "Minden személy",
"all_videos": "Összes videó",
"allow_dark_mode": "Sötét stílus engedélyezése",
"allow_edits": "Szerkesztések engedélyezése",
"allow_public_user_to_download": "Engedélyezze publikus felhasználónak, hogy letöltse",
"allow_public_user_to_upload": "Engedélyezze publikus felhasználónak, hogy feltöltsön",
"api_key": "API kulcs",
"api_key_description": "Ez az érték csak egyszer jelenik meg. Az ablak bezárása előtt feltétlenül másolja át.",
"api_key_empty": "A te API Kulcs neved nem kéne üres legyen",
"api_keys": "API Kulcsok",
"app_settings": "Alkalmazás Beállítások",
"appears_in": "Megjelenik itt",

View File

@@ -27,7 +27,7 @@
"added_to_favorites": "즐겨찾기에 추가되었습니다.",
"added_to_favorites_count": "즐겨찾기에 항목 {count, number}개 추가됨",
"admin": {
"add_exclusion_pattern_description": "규칙에 *, ** 및 ? 를 사용할 수 있습니다. \"Raw\" 디렉터리의 모든 파일을 제외하려면 **/Raw/**를, \".tif\"로 끝나는 파일을 제외하려면 **/*.tif를 사용합니다. 절대 경로는 /path/to/ignore/**처럼 사용하세요.",
"add_exclusion_pattern_description": "규칙에 *, ** 및 ? 를 사용할 수 있습니다. \"Raw\" 디렉터리의 모든 파일을 제외하려면 **/Raw/**를, \".tif\"로 끝나는 파일을 제외하려면 **/*.tif를 사용합니다. 절대 경로는 /path/to/ignore/** 와 같은 방식으로 사용하세요.",
"authentication_settings": "인증 설정",
"authentication_settings_description": "비밀번호, OAuth 및 기타 인증 설정 관리",
"authentication_settings_disable_all": "로그인 기능을 모두 비활성화하시겠습니까? 로그인하지 않아도 서버에 접근할 수 있습니다.",
@@ -61,14 +61,14 @@
"image_prefer_wide_gamut_setting_description": "섬네일 이미지에 Display P3을 사용합니다. 많은 색상을 표현할 수 있어 더 정확한 표현이 가능하지만, 오래된 브라우저를 사용하는 경우 이미지가 다르게 보일 수 있습니다. 색상 왜곡을 방지하기 위해 sRGB 이미지는 이 설정이 적용되지 않습니다.",
"image_preview_format": "미리 보기 형식",
"image_preview_resolution": "미리 보기 해상도",
"image_preview_resolution_description": "각 항목을 보거나 기계 학습 사용되는 사진의 해상도를 설정합니다. 해상도가 높으면 세부 묘사의 손실을 최소화할 수 있지만, 인코딩 시간과 파일 크기가 증가하여 앱의 반응 속도가 느려질 수 있습니다.",
"image_preview_resolution_description": "사진을 보거나 기계 학습을 실행할 때 사용되는 사진의 해상도를 설정합니다. 높은 해상도를 선택하면 세부 묘사의 손실을 최소화할 수 있지만, 인코딩 시간과 파일 크기가 증가하여 앱의 반응 속도가 느려질 수 있습니다.",
"image_quality": "품질",
"image_quality_description": "이미지 품질을 1에서 100 사이로 설정합니다. 품질이 높으면 파일 크기가 증가하지만 생성된 이미지의 품질이 향상됩니다. 이 옵션은 미리 보기 및 섬네일 이미지에 영향을 미칩니다.",
"image_quality_description": "이미지 품질을 1에서 100 사이로 설정합니다. 높은 품질을 선택하면 파일 크기가 증가하지만 생성된 이미지의 품질이 향상됩니다. 이 옵션은 미리 보기 및 섬네일 이미지에 영향을 미칩니다.",
"image_settings": "이미지 설정",
"image_settings_description": "생성된 이미지의 품질 및 해상도 관리",
"image_thumbnail_format": "섬네일 형식",
"image_thumbnail_resolution": "섬네일 해상도",
"image_thumbnail_resolution_description": "여러 항목을 표시할 때 사용되는 사진의 해상도를 설정합니다. (메인 타임라인, 앨범 보기 등) 해상도가 높으면 세부 묘사의 손실을 최소화할 수 있지만, 인코딩 시간과 파일 크기가 증가하여 앱의 반응 속도가 느려질 수 있습니다.",
"image_thumbnail_resolution_description": "여러 항목을 표시할 때 사용되는 사진의 해상도를 설정합니다. (메인 타임라인, 앨범 보기 등) 높은 해상도를 선택하면 세부 묘사의 손실을 최소화할 수 있지만, 인코딩 시간과 파일 크기가 증가하여 앱의 반응 속도가 느려질 수 있습니다.",
"job_concurrency": "{job} 동시성",
"job_not_concurrency_safe": "이 작업은 동시 실행이 제한됩니다.",
"job_settings": "작업 설정",
@@ -261,7 +261,7 @@
"transcoding_constant_quality_mode": "Constant quality mode",
"transcoding_constant_quality_mode_description": "ICQ는 CQP보다 나은 성능을 보이나 일부 기기의 하드웨어 가속에서 지원되지 않을 수 있습니다. 이 옵션을 설정하면 품질 기반 인코딩 시 지정된 모드를 우선적으로 사용합니다. NVENC에서는 ICQ를 지원하지 않아 이 설정이 적용되지 않습니다.",
"transcoding_constant_rate_factor": "Constant rate factor (-crf)",
"transcoding_constant_rate_factor_description": "일반적으로 H.264는 23, HEVC는 28, VP9는 31, AV1는 35를 사용합니다. 값이 낮으면 품질이 향상되 파일 크기가 증가합니다.",
"transcoding_constant_rate_factor_description": "일반적으로 H.264는 23, HEVC는 28, VP9는 31, AV1는 35를 사용합니다. 값이 낮으면 품질이 향상되지만 파일 크기가 증가합니다.",
"transcoding_disabled_description": "동영상을 트랜스코딩하지 않음. 일부 기기에서 재생이 불가능할 수 있습니다.",
"transcoding_hardware_acceleration": "하드웨어 가속",
"transcoding_hardware_acceleration_description": "실험적인 기능입니다. 속도가 향상되지만 동일 비트레이트에서 품질이 상대적으로 낮을 수 있습니다.",
@@ -271,21 +271,21 @@
"transcoding_max_b_frames": "최대 B 프레임",
"transcoding_max_b_frames_description": "값이 높으면 압축 효율이 향상되지만 인코딩 속도가 저하됩니다. 오래된 기기의 하드웨어 가속과 호환되지 않을 수 있습니다. 0을 입력한 경우 B 프레임을 비활성화하며, -1을 입력한 경우 자동으로 설정합니다.",
"transcoding_max_bitrate": "최대 비트레이트",
"transcoding_max_bitrate_description": "최대 비트레이트를 지정하면 약간의 품질 저하가 발생하지만 파일 크기가 예측 가능한 수준으로 일정하게 유지됩니다. 일반적으로 720p에서 VP9 및 HEVC는 2600k, H.264는 4500k를 사용합니다. 0을 입력한 경우 비활성화됩니다.",
"transcoding_max_bitrate_description": "최대 비트레이트를 지정하면 품질이 일부 저하되지만 파일 크기가 예측 가능한 수준으로 일정하게 유지됩니다. 일반적으로 720p 기준 VP9 및 HEVC는 2600k, H.264는 4500k를 사용합니다. 0을 입력한 경우 비활성화됩니다.",
"transcoding_max_keyframe_interval": "최대 키프레임 간격",
"transcoding_max_keyframe_interval_description": "키프레임 사이 최대 프레임 거리를 설정합니다. 값이 낮으면 압축 효율이 저하되지만 검색 시간이 개선되고 빠른 움직임이 있는 장면에서 품질이 향상됩니다. 0을 입력한 경우 자동으로 설정합니다.",
"transcoding_optimal_description": "목표 해상도보다 높은 동영상 또는 허용되지 않는 형식의 동영상",
"transcoding_preferred_hardware_device": "선호하는 하드웨어 기기",
"transcoding_preferred_hardware_device_description": "하드웨어 트랜스코딩에 사용할 dri 노드를 설정합니다. (VAAPI와 QSV만 해당)",
"transcoding_preset_preset": "프리셋 (-preset)",
"transcoding_preset_preset_description": "압축 속도를 설정합니다. 느린 프리셋을 선택하면 파일 크기가 감소하고 목표 비트레이트를 지정한 경우 품질이 향상됩니다. VP9의 경우 `faster` 이상의 속도가 적용되지 않습니다.",
"transcoding_preset_preset_description": "압축 속도를 설정합니다. 동일 비트레이트 기준 느린 속도를 선택한 경우 파일 크기가 감소하고 품질이 향상됩니다. VP9 `faster` 이상의 속도가 적용되지 않습니다.",
"transcoding_reference_frames": "참조 프레임",
"transcoding_reference_frames_description": "특정 프레임을 압축할 때 참조하는 프레임 수를 설정합니다. 값이 높으면 압축 효율이 향상되나 인코딩 속도가 저하됩니다. 0을 입력한 경우 자동으로 설정합니다.",
"transcoding_required_description": "허용된 형식이 아닌 동영상만",
"transcoding_settings": "동영상 트랜스코딩 설정",
"transcoding_settings_description": "동영상 파일의 해상도 및 인코딩 정보 관리",
"transcoding_target_resolution": "목표 해상도",
"transcoding_target_resolution_description": "해상도가 높으면 세부 묘사의 손실을 최소화할 수 있지만, 인코딩 시간과 파일 크기가 증가하여 앱의 반응 속도가 느려질 수 있습니다.",
"transcoding_target_resolution_description": "높은 해상도를 선택한 경우 세부 묘사의 손실을 최소화할 수 있지만, 인코딩 시간과 파일 크기가 증가하여 앱의 반응 속도가 느려질 수 있습니다.",
"transcoding_temporal_aq": "Temporal AQ",
"transcoding_temporal_aq_description": "세부 묘사가 많고 움직임이 적은 장면의 품질이 향상됩니다. 오래된 기기와 호환되지 않을 수 있습니다. (NVENC만 해당)",
"transcoding_threads": "스레드",
@@ -386,16 +386,16 @@
"asset_uploaded": "업로드 완료",
"asset_uploading": "업로드 중...",
"assets": "항목",
"assets_added_count": "항목 {count, plural, one {#개} other {#개}} 추가",
"assets_added_count": "항목 {count, plural, one {#개} other {#개}} 추가되었습니다.",
"assets_added_to_album_count": "앨범에 항목 {count, plural, one {#개} other {#개}} 추가됨",
"assets_added_to_name_count": "{hasName, select, true {<b>{name}</b>} other {새 앨범}}에 항목 {count, plural, one {#개} other {#개}} 추가됨",
"assets_count": "{count, plural, one {#개} other {#개}} 항목",
"assets_moved_to_trash": "항목 {count, plural, one {#개} other {#개}}를 휴지통으로 이동함",
"assets_moved_to_trash_count": "휴지통으로 항목 {count, plural, one {#개} other {#개}} 이동됨",
"assets_permanently_deleted_count": "항목 {count, plural, one {#개} other {#개}}가 영구적으로 삭제됨",
"assets_removed_count": "항목 {count, plural, one {#개} other {#개}} 제거",
"assets_removed_count": "항목 {count, plural, one {#개} other {#개}} 제거했습니다.",
"assets_restore_confirmation": "휴지통으로 이동된 항목을 모두 복원하시겠습니까? 이 작업은 되돌릴 수 없습니다!",
"assets_restored_count": "항목 {count, plural, one {#개} other {#개}} 복원",
"assets_restored_count": "항목 {count, plural, one {#개} other {#개}} 복원했습니다.",
"assets_trashed_count": "휴지통으로 항목 {count, plural, one {#개} other {#개}} 이동됨",
"assets_were_part_of_album_count": "앨범에 이미 존재하는 {count, plural, one {항목} other {항목}}입니다.",
"authorized_devices": "인증된 기기",
@@ -489,7 +489,7 @@
"date_of_birth_saved": "생년월일이 성공적으로 저장되었습니다.",
"date_range": "날짜 범위",
"day": "일",
"deduplicate_all": "비슷한 항목 모두 선택",
"deduplicate_all": "모두 삭제",
"default_locale": "기본 로케일",
"default_locale_description": "브라우저 로케일에 따른 날짜 및 숫자 형식 지정",
"delete": "삭제",
@@ -664,7 +664,7 @@
"unable_to_reset_password": "비밀번호를 초기화할 수 없습니다.",
"unable_to_resolve_duplicate": "비슷한 항목을 처리할 수 없습니다.",
"unable_to_restore_assets": "항목을 복원할 수 없습니다.",
"unable_to_restore_trash": "휴지통에서 항목을 복원할 수 없습니다.",
"unable_to_restore_trash": "휴지통을 복원할 수 없습니다.",
"unable_to_restore_user": "사용자 삭제를 취소할 수 없습니다.",
"unable_to_save_album": "앨범을 저장할 수 없습니다.",
"unable_to_save_api_key": "API 키를 수정할 수 없습니다.",
@@ -677,7 +677,7 @@
"unable_to_set_feature_photo": "대표 사진을 지정할 수 없습니다.",
"unable_to_set_profile_picture": "프로필 사진을 설정할 수 없습니다.",
"unable_to_submit_job": "작업을 수행할 수 없습니다.",
"unable_to_trash_asset": "휴지통으로 항목을 이동할 수 없습니다.",
"unable_to_trash_asset": "휴지통으로 이동할 수 없습니다.",
"unable_to_unlink_account": "계정 연결을 해제할 수 없습니다.",
"unable_to_update_album_cover": "앨범 커버를 변경할 수 없습니다.",
"unable_to_update_album_info": "앨범 정보를 변경할 수 없습니다.",
@@ -743,12 +743,16 @@
"host": "호스트",
"hour": "시간",
"image": "이미지",
"image_alt_text_date": "{date} 촬영 {isVideo, select, true {동영상} other {사진}}",
"image_alt_text_date_1_person": "{date} {person1}님과 함께한 {isVideo, select, true {동영상} other {사진}}",
"image_alt_text_date_2_people": "{date} {person1}, {person2}님과 함께한 {isVideo, select, true {동영상} other {사진}}",
"image_alt_text_date_3_people": "{date} {person1}, {person2}, {person3}님과 함께한 {isVideo, select, true {동영상} other {사진}}",
"image_alt_text_date_4_or_more_people": "{date} {person1}, {person2}, 그 외 {additionalCount, number}명과 함께한 {isVideo, select, true {동영상} other {사진}}",
"image_alt_text_date_place": "{city}, {country}에서 {date}에 촬영한 {isVideo, select, true {동영상} other {사진}}",
"image_alt_text_date": "{date} 촬영 {isVideo, select, true {동영상} other {사진}}",
"image_alt_text_date_1_person": "{date} {person1}님과 함께한 {isVideo, select, true {동영상} other {사진}}",
"image_alt_text_date_2_people": "{date} {person1}, {person2}님과 함께한 {isVideo, select, true {동영상} other {사진}}",
"image_alt_text_date_3_people": "{date} {person1}, {person2}, {person3}님과 함께한 {isVideo, select, true {동영상} other {사진}}",
"image_alt_text_date_4_or_more_people": "{date} {person1}, {person2}님 및 {additionalCount, number}명과 함께한 {isVideo, select, true {동영상} other {사진}}",
"image_alt_text_date_place": "{date} {country}, {city}에 촬영한 {isVideo, select, true {동영상} other {사진}}",
"image_alt_text_date_place_1_person": "{date} {country}, {city}에서 {person1}님과 함께한 {isVideo, select, true {동영상} other {사진}}",
"image_alt_text_date_place_2_people": "{date} {country}, {city}에서 {person1}, {person2}님과 함께한 {isVideo, select, true {동영상} other {사진}}",
"image_alt_text_date_place_3_people": "{date} {country}, {city}에서 {person1}, {person2}님 및 {person3}님과 함께한 {isVideo, select, true {동영상} other {사진}}",
"image_alt_text_date_place_4_or_more_people": "{date} {country}, {city}에서 {person1}, {person2}님 및 {additionalCount, number}명과 함께한 {isVideo, select, true {동영상} other {사진}}",
"image_alt_text_people": "{count, plural, =1 {{person1}님과 함께,} =2 {{person1} 및 {person2}님과 함께,} =3 {{person1}, {person2} 및 {person3}님과 함께,} other {{person1}, {person2}, 및 {others, number}명과 함께,}}",
"image_alt_text_place": "{country}, {city}에서",
"image_taken": "{isVideo, select, true {동영상} other {사진}},",
@@ -963,32 +967,32 @@
"previous": "이전",
"previous_memory": "이전 추억",
"previous_or_next_photo": "이전 또는 다음 이미지로",
"primary": "",
"primary": "주요",
"profile_image_of_user": "{user}님의 프로필 이미지",
"profile_picture_set": "프로필 사진이 설정되었습니다.",
"public_album": "공개 앨범",
"public_share": "모든 사용자와 공유",
"purchase_account_info": "서포터",
"purchase_activated_subtitle": "Immich와 오픈 소스 소프트웨어를 지원해주셔서 감사합니다.",
"purchase_activated_time": "{date, date}에 활성화됨",
"purchase_activated_title": "제품 키가 성공적으로 활성화되었습니다.",
"purchase_button_activate": "활성화",
"purchase_activated_time": "{date, date} 등록됨",
"purchase_activated_title": "제품 키가 성공적으로 등록되었습니다.",
"purchase_button_activate": "등록",
"purchase_button_buy": "구매",
"purchase_button_buy_immich": "Immich 구매",
"purchase_button_never_show_again": "다시 보지 않기",
"purchase_button_reminder": "30일 후에 다시 알림",
"purchase_button_remove_key": "제품 키 제거",
"purchase_button_select": "선택",
"purchase_failed_activation": "활성화하지 못했습니다. 이메일로 전송된 키를 정확히 입력했는지 확인하세요!",
"purchase_failed_activation": "등록하지 못했습니다. 이메일로 전송된 키를 정확히 입력했는지 확인하세요!",
"purchase_individual_description_1": "개인 사용자용",
"purchase_individual_description_2": "서포터 현황",
"purchase_individual_description_2": "서포터 배지 및 표시",
"purchase_individual_title": "개인",
"purchase_input_suggestion": "제품 키를 보유 중인가요? 아래에 제품 키를 입력하세요.",
"purchase_input_suggestion": "제품 키를 보유하고 있나요? 아래에 제품 키를 입력하세요.",
"purchase_license_subtitle": "Immich를 구매하여 지속적인 개발에 도움을 주세요.",
"purchase_lifetime_description": "일회성 구매",
"purchase_option_title": "구매 옵션",
"purchase_panel_info_1": "Immich를 개발하는 데는 많은 시간과 노력이 필요합니다. 우리는 좋은 앱을 만들기 위해 풀 타임 개발자와 함께하고 있으며, 최종적으로 오픈 소스 소프트웨어와 비즈니스 행동 윤리가 개발자에게 지속 가능한 수입원을 제공하고 착취적인 클라우드 서비스를 대체할 수 있는 개인 정보 보호 생태계를 구축하는 것을 원합니다.",
"purchase_panel_info_2": "유료 기능을 추가하지 않기로 약속했기에, 이 구매는 어떠한 추가 기능도 제공하지 않습니다. 우리는 Immich의 지속적인 개발을 지원하는 사용자 여러분에게 의존하고 있습니다.",
"purchase_panel_info_2": "유료 기능을 추가하지 않기로 약속했기에 이 구매는 어떠한 추가 기능도 제공하지 않습니다. 우리는 Immich의 지속적인 개발을 지원하는 사용자 여러분에게 의존하고 있습니다.",
"purchase_panel_title": "프로젝트 지원",
"purchase_per_server": "서버당",
"purchase_per_user": "사용자당",
@@ -996,8 +1000,8 @@
"purchase_remove_product_key_prompt": "제품 키를 제거하시겠습니까?",
"purchase_remove_server_product_key": "서버 제품 키 제거",
"purchase_remove_server_product_key_prompt": "서버 제품 키를 제거하시겠습니까?",
"purchase_server_description_1": "전체 서버용",
"purchase_server_description_2": "서포터 현황",
"purchase_server_description_1": "서버 전체에 적용",
"purchase_server_description_2": "서포터 배지 및 표시",
"purchase_server_title": "서버",
"purchase_settings_server_activated": "서버 제품 키는 관리자가 관리합니다.",
"range": "",
@@ -1032,7 +1036,7 @@
"removed_api_key": "API 키 삭제: {name}",
"removed_from_archive": "보관함에서 제거되었습니다.",
"removed_from_favorites": "즐겨찾기에서 제거되었습니다.",
"removed_from_favorites_count": "즐겨찾기에서 항목 {count, plural, other {#개}} 제거되었습니다.",
"removed_from_favorites_count": "즐겨찾기에서 항목 {count, plural, other {#개}} 제거",
"rename": "이름 바꾸기",
"repair": "수리",
"repair_no_results_message": "추적되지 않거나 누락된 파일이 이곳에 표시됩니다.",
@@ -1045,7 +1049,7 @@
"reset_people_visibility": "인물 숨김 여부 초기화",
"reset_settings_to_default": "",
"reset_to_default": "기본값으로 복원",
"resolve_duplicates": "중복 해결",
"resolve_duplicates": "비슷한 항목 확인",
"resolved_all_duplicates": "비슷한 항목을 모두 확인했습니다.",
"restore": "복원",
"restore_all": "모두 복원",
@@ -1090,7 +1094,7 @@
"see_all_people": "모든 인물 보기",
"select_album_cover": "앨범 커버 변경",
"select_all": "모두 선택",
"select_all_duplicates": "중복 모두 선택",
"select_all_duplicates": "모두 선택",
"select_avatar_color": "프로필 색상 변경",
"select_face": "얼굴 선택",
"select_featured_photo": "대표 사진 선택",
@@ -1101,7 +1105,7 @@
"select_photos": "사진 선택",
"select_trash_all": "모두 삭제",
"selected": "선택됨",
"selected_count": "{count, plural, other {#개}} 선택됨",
"selected_count": "{count, plural, other {#개}} 항목 선택됨",
"send_message": "메시지 전송",
"send_welcome_email": "환영 이메일 전송",
"server": "서버",
@@ -1193,14 +1197,14 @@
"to_change_password": "비밀번호 변경",
"to_favorite": "즐겨찾기",
"to_login": "로그인",
"to_trash": "휴지통",
"to_trash": "삭제",
"toggle_settings": "설정 변경",
"toggle_theme": "테마 변경",
"toggle_visibility": "숨김 여부 변경",
"total_usage": "총 사용량",
"trash": "휴지통",
"trash_all": "모두 삭제",
"trash_count": "휴지통으로 이동 ({count, number}개)",
"trash_count": "{count, number}개 삭제",
"trash_delete_asset": "휴지통 이동/삭제",
"trash_no_results_message": "휴지통으로 이동된 항목이 이곳에 표시됩니다.",
"trashed_items_will_be_permanently_deleted_after": "휴지통으로 이동된 항목은 {days, plural, one {#일} other {#일}} 후 영구적으로 삭제됩니다.",
@@ -1220,7 +1224,7 @@
"unnamed_share": "이름 없는 공유",
"unsaved_change": "저장되지 않은 변경 사항",
"unselect_all": "모두 선택 해제",
"unselect_all_duplicates": "모든 중복 선택 해제",
"unselect_all_duplicates": "모 선택 해제",
"unstack": "스택 해제",
"unstacked_assets_count": "항목 {count, plural, one {#개} other {#개}}의 스택을 해제했습니다.",
"untracked_files": "추적되지 않는 파일",
@@ -1242,8 +1246,8 @@
"user": "사용자",
"user_id": "사용자 ID",
"user_liked": "{user}님이 {type, select, photo {이 사진을} video {이 동영상을} asset {이 항목을} other {이 항목을}} 좋아합니다.",
"user_purchase_settings": "결제",
"user_purchase_settings_description": "구매한 항목 관리",
"user_purchase_settings": "구매",
"user_purchase_settings_description": "구매 및 제품 키 관리",
"user_role_set": "{user}님에게 {role} 역할을 설정했습니다.",
"user_usage_detail": "사용자 사용량 상세",
"username": "계정명",

View File

@@ -988,7 +988,7 @@
"profile_image_of_user": "Profielfoto van {user}",
"profile_picture_set": "Profielfoto ingesteld.",
"public_album": "Openbaar album",
"public_share": "Publieke deellink",
"public_share": "Openbare deellink",
"purchase_account_info": "Supporter",
"purchase_activated_subtitle": "Bedankt voor het ondersteunen van Immich en open-source software",
"purchase_activated_time": "Geactiveerd op {date, date}",

View File

@@ -25,7 +25,7 @@
"add_to_shared_album": "Dodaj do udostępnionego albumu",
"added_to_archive": "Dodano do archiwum",
"added_to_favorites": "Dodano do ulubionych",
"added_to_favorites_count": "Dodano {count} do ulubionych",
"added_to_favorites_count": "Dodano {count, number} do ulubionych",
"admin": {
"add_exclusion_pattern_description": "Dodaj wzorce wykluczające. Wspierane są specjalne sekwencje (glob) *, ** oraz ?. Aby ignorować całą zawartość wszystkich folderów nazwanych \"Raw\", użyj \"**/Raw/**\". Aby ignorować wszystkie pliki kończące się na \".tif\", użyj \"**/*.tif\". Aby ignorować ścieżkę absolutną, użyj \"/ścieżka/do/ignorowania/**\".",
"authentication_settings": "Ustawienia Uwierzytelnienia",
@@ -250,7 +250,7 @@
"transcoding_accepted_audio_codecs": "Akceptowane kodeki audio",
"transcoding_accepted_audio_codecs_description": "Wybierz, które kodeki audio nie muszą być transkodowane. Używane tylko w przypadku niektórych zasad transkodowania.",
"transcoding_accepted_containers": "Akceptowalne kontenery",
"transcoding_accepted_containers_description": "Wybierz które formaty kontenera nie muszą zostać przerobione na MP4. Użyte tylko w wybranych zasadach transkodowania",
"transcoding_accepted_containers_description": "Wybierz które formaty kontenera nie muszą zostać przerobione na MP4. Użyte tylko w wybranych zasadach transkodowania.",
"transcoding_accepted_video_codecs": "Akceptowane kodeki wideo",
"transcoding_accepted_video_codecs_description": "Wybierz, które kodeki wideo nie muszą być transkodowane. Używane tylko w przypadku niektórych zasad transkodowania.",
"transcoding_advanced_options_description": "Opcje, których większość użytkowników nie powinna zmieniać",
@@ -743,7 +743,16 @@
"host": "Host",
"hour": "Godzina",
"image": "Zdjęcie",
"image_alt_text_date": "dnia {date}",
"image_alt_text_date": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione dnia {date}",
"image_alt_text_date_1_person": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione z {person1} dnia {date}",
"image_alt_text_date_2_people": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione z {person1} i {person2} dnia {date}",
"image_alt_text_date_3_people": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione z {person1}, {person2} i {person3} dnia {date}",
"image_alt_text_date_4_or_more_people": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione z {person1}, {person2} i {additionalCount, number} innymi dnia {date}",
"image_alt_text_date_place": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione w {city}, {country} dnia {date}",
"image_alt_text_date_place_1_person": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione w {city}, {country} z {person1} dnia {date}",
"image_alt_text_date_place_2_people": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione w {city}, {country} z {person1} i {person2} dnia {date}",
"image_alt_text_date_place_3_people": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione w {city}, {country} z {person1}, {person2} i {person3} dnia {date}",
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Wideo} other {Zdjęcie}} zrobione w {city}, {country} z {person1}, {person2} i {additionalCount, number} innymi dnia {date}",
"image_alt_text_people": "{count, plural, =1 {z {person1}} =2 {z {person1} i {person2}} =3 {z {person1}, {person2} i {person3}} other {z {person1}, {person2} i {others, number} innymi}}",
"image_alt_text_place": "w {city}, {country}",
"image_taken": "{isVideo, select, true {nagrany film} other {zrobione zdjęcie}}",
@@ -943,7 +952,7 @@
"play_or_pause_video": "Odtwórz lub wstrzymaj wideo",
"point": "",
"port": "Port",
"preset": "Preset",
"preset": "Ustawienie",
"preview": "Podgląd",
"previous": "Poprzedni",
"previous_memory": "Poprzednie wspomnienie",
@@ -955,7 +964,7 @@
"public_share": "Udostępnienie publiczne",
"purchase_account_info": "Wspierający",
"purchase_activated_subtitle": "Dziękuję za wspieranie Immich i oprogramowania open-source",
"purchase_activated_time": "Aktywowane dnia {date}",
"purchase_activated_time": "Aktywowane dnia {date, date}",
"purchase_activated_title": "Twój klucz został pomyślnie aktywowany",
"purchase_button_activate": "Aktywuj",
"purchase_button_buy": "Kup",
@@ -1185,7 +1194,7 @@
"total_usage": "Całkowite wykorzystanie",
"trash": "Kosz",
"trash_all": "Usuń wszystko",
"trash_count": "Kosz {count}",
"trash_count": "Kosz {count, number}",
"trash_delete_asset": "Kosz/Usuń zasób",
"trash_no_results_message": "Tu znajdziesz wyrzucone zdjęcia i filmy.",
"trashed_items_will_be_permanently_deleted_after": "Wyrzucone zasoby zostaną trwale usunięte po {days, plural, one {jednym dniu} other {{days, number} dniach}}.",
@@ -1215,7 +1224,7 @@
"upload": "Prześlij",
"upload_concurrency": "Współbieżność wysyłania",
"upload_errors": "Przesyłanie zakończone z {count, plural, one {# błąd} other {# błędy}}. Odśwież stronę, aby zobaczyć nowe przesłane zasoby.",
"upload_progress": "Pozostałe {remaining} - Przetworzone {processed}/{total}",
"upload_progress": "Pozostałe {remaining, number} - Przetworzone {processed, number}/{total, number}",
"upload_skipped_duplicates": "Pominięte {count, plural, one {# zduplikowany zasób} other {# zduplikowane zasoby}}",
"upload_status_duplicates": "Duplikaty",
"upload_status_errors": "Błędy",

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