mirror of
https://github.com/immich-app/immich.git
synced 2025-12-10 14:51:07 -08:00
Compare commits
89 Commits
v2.2.0
...
feat/serve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ba458668b | ||
|
|
ea034f21bc | ||
|
|
a68513247d | ||
|
|
59f7f3c23e | ||
|
|
c88bde3cab | ||
|
|
818bd51036 | ||
|
|
3c72409712 | ||
|
|
8d1a8b9465 | ||
|
|
d880e7baed | ||
|
|
42801ace35 | ||
|
|
838b8e9126 | ||
|
|
9da5a48bdd | ||
|
|
27f126bd58 | ||
|
|
a238c6a70d | ||
|
|
7222d7af30 | ||
|
|
d660ab2218 | ||
|
|
69ffbcd5cf | ||
|
|
bc84486668 | ||
|
|
2666ee2b4f | ||
|
|
72ea7799c0 | ||
|
|
98c8c28b62 | ||
|
|
6b1d26d3a2 | ||
|
|
5e07976288 | ||
|
|
3f1133f9b7 | ||
|
|
3a087ed2cd | ||
|
|
c723a9ac78 | ||
|
|
550460891d | ||
|
|
e3e8da168f | ||
|
|
de117ebe7a | ||
|
|
3d507015e0 | ||
|
|
fe71662d24 | ||
|
|
81a66350f6 | ||
|
|
c33e65362a | ||
|
|
bb5519036a | ||
|
|
177c997d96 | ||
|
|
2d6a2dc77b | ||
|
|
e193cb3a5b | ||
|
|
4b63d3d055 | ||
|
|
4ed92f5df5 | ||
|
|
6f61bf04e4 | ||
|
|
b21d0a1c53 | ||
|
|
f80326872e | ||
|
|
7561c5e1c4 | ||
|
|
2c50f2e244 | ||
|
|
365abd8906 | ||
|
|
25fb43bbe3 | ||
|
|
125e8cee01 | ||
|
|
c15e9bfa72 | ||
|
|
35e188e6e7 | ||
|
|
3cc9dd126c | ||
|
|
aa69d89b9f | ||
|
|
29c14a3f58 | ||
|
|
0df70365d7 | ||
|
|
c34be73d81 | ||
|
|
f396e9e374 | ||
|
|
821a9d4691 | ||
|
|
cad654586f | ||
|
|
28eb1bc13c | ||
|
|
1e4779cf48 | ||
|
|
0647c22956 | ||
|
|
b8087b4fa2 | ||
|
|
d94cb9641b | ||
|
|
517c3e1d4c | ||
|
|
619de2a5e4 | ||
|
|
79d0e3e1ed | ||
|
|
f5ff36a1f8 | ||
|
|
b5efc9c16e | ||
|
|
1036076b0d | ||
|
|
c76324c611 | ||
|
|
0ddb92e1ec | ||
|
|
d08a520aa2 | ||
|
|
7bdf0f6c50 | ||
|
|
2b33a58448 | ||
|
|
b35f00f768 | ||
|
|
86cc7c3c73 | ||
|
|
5854cbbe97 | ||
|
|
ceb36a304d | ||
|
|
f5d7e5acca | ||
|
|
be15a84f9b | ||
|
|
32791e98c2 | ||
|
|
7ea443b3a9 | ||
|
|
c69786b039 | ||
|
|
5c7d5539ea | ||
|
|
3531856d1c | ||
|
|
4abaad548a | ||
|
|
61a2c3ace3 | ||
|
|
e9038193db | ||
|
|
3f5cd48a59 | ||
|
|
4cb094e7ae |
95
.github/workflows/build-mobile.yml
vendored
95
.github/workflows/build-mobile.yml
vendored
@@ -1,12 +1,16 @@
|
||||
name: Build Mobile
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
required: false
|
||||
type: string
|
||||
environment:
|
||||
description: 'Target environment'
|
||||
required: true
|
||||
default: 'development'
|
||||
type: string
|
||||
secrets:
|
||||
KEY_JKS:
|
||||
required: true
|
||||
@@ -16,6 +20,30 @@ on:
|
||||
required: true
|
||||
ANDROID_STORE_PASSWORD:
|
||||
required: true
|
||||
APP_STORE_CONNECT_API_KEY_ID:
|
||||
required: true
|
||||
APP_STORE_CONNECT_API_KEY_ISSUER_ID:
|
||||
required: true
|
||||
APP_STORE_CONNECT_API_KEY:
|
||||
required: true
|
||||
IOS_CERTIFICATE_P12:
|
||||
required: true
|
||||
IOS_CERTIFICATE_PASSWORD:
|
||||
required: true
|
||||
IOS_PROVISIONING_PROFILE:
|
||||
required: true
|
||||
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION:
|
||||
required: true
|
||||
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION:
|
||||
required: true
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE:
|
||||
required: true
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION:
|
||||
required: true
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION:
|
||||
required: true
|
||||
FASTLANE_TEAM_ID:
|
||||
required: true
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -193,17 +221,22 @@ jobs:
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.4.7'
|
||||
ruby-version: '3.3'
|
||||
working-directory: ./mobile/ios
|
||||
|
||||
- name: Install Fastlane
|
||||
- name: Install CocoaPods dependencies
|
||||
working-directory: ./mobile/ios
|
||||
run: |
|
||||
pod install
|
||||
|
||||
- name: Install Fastlane
|
||||
working-directory: ./mobile/ios
|
||||
run: |
|
||||
cd mobile/ios
|
||||
gem install bundler
|
||||
bundle config set --local path 'vendor/bundle'
|
||||
bundle install
|
||||
|
||||
- name: Create API Key JSON
|
||||
- name: Create API Key
|
||||
env:
|
||||
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||
API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||
@@ -212,35 +245,55 @@ jobs:
|
||||
run: |
|
||||
mkdir -p ~/.appstoreconnect/private_keys
|
||||
echo "$API_KEY_CONTENT" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8
|
||||
cat > api_key.json << EOF
|
||||
{
|
||||
"key_id": "${API_KEY_ID}",
|
||||
"issuer_id": "${API_KEY_ISSUER_ID}",
|
||||
"key": "$(cat ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8)",
|
||||
"duration": 1200,
|
||||
"in_house": false
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Import Certificate and Provisioning Profile
|
||||
- name: Import Certificate and Provisioning Profiles
|
||||
env:
|
||||
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
||||
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
||||
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||
ENVIRONMENT: ${{ inputs.environment || 'development' }}
|
||||
working-directory: ./mobile/ios
|
||||
run: |
|
||||
# Decode certificate
|
||||
echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12
|
||||
echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision
|
||||
|
||||
- name: Create keychain
|
||||
# Decode provisioning profiles based on environment
|
||||
if [[ "$ENVIRONMENT" == "development" ]]; then
|
||||
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE" | base64 --decode > profile_dev.mobileprovision
|
||||
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_dev_share.mobileprovision
|
||||
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_dev_widget.mobileprovision
|
||||
ls -lh profile_dev*.mobileprovision
|
||||
else
|
||||
echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision
|
||||
echo "$IOS_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_share.mobileprovision
|
||||
echo "$IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_widget.mobileprovision
|
||||
ls -lh profile*.mobileprovision
|
||||
fi
|
||||
|
||||
- name: Create keychain and import certificate
|
||||
env:
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
working-directory: ./mobile/ios
|
||||
run: |
|
||||
# Create keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security set-keychain-settings -t 3600 -u build.keychain
|
||||
|
||||
# Import certificate
|
||||
security import certificate.p12 -k build.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
|
||||
|
||||
# Verify certificate was imported
|
||||
security find-identity -v -p codesigning build.keychain
|
||||
|
||||
- name: Build and deploy to TestFlight
|
||||
env:
|
||||
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
||||
@@ -249,8 +302,14 @@ jobs:
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||
ENVIRONMENT: ${{ inputs.environment || 'development' }}
|
||||
working-directory: ./mobile/ios
|
||||
run: bundle exec fastlane release_ci
|
||||
run: |
|
||||
if [[ "$ENVIRONMENT" == "development" ]]; then
|
||||
bundle exec fastlane gha_testflight_dev
|
||||
else
|
||||
bundle exec fastlane gha_release_prod
|
||||
fi
|
||||
|
||||
- name: Clean up keychain
|
||||
if: always()
|
||||
|
||||
3
.github/workflows/cli.yml
vendored
3
.github/workflows/cli.yml
vendored
@@ -95,7 +95,7 @@ jobs:
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ steps.token.outputs.token }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get package version
|
||||
id: package-version
|
||||
@@ -125,4 +125,3 @@ jobs:
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
|
||||
2
.github/workflows/fix-format.yml
vendored
2
.github/workflows/fix-format.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
|
||||
- name: Fix formatting
|
||||
run: make install-all && make format-all
|
||||
run: pnpm --recursive install && pnpm run --recursive --parallel fix:format
|
||||
|
||||
- name: Commit and push
|
||||
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
|
||||
|
||||
15
.github/workflows/prepare-release.yml
vendored
15
.github/workflows/prepare-release.yml
vendored
@@ -99,8 +99,23 @@ jobs:
|
||||
ALIAS: ${{ secrets.ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||
# iOS secrets
|
||||
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
|
||||
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
||||
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
||||
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
||||
|
||||
with:
|
||||
ref: ${{ needs.bump_version.outputs.ref }}
|
||||
environment: production
|
||||
|
||||
prepare_release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
1
.github/workflows/test.yml
vendored
1
.github/workflows/test.yml
vendored
@@ -382,6 +382,7 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.98",
|
||||
"version": "2.2.101",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
@@ -20,7 +20,7 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^22.18.12",
|
||||
"@types/node": "^22.18.13",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
|
||||
@@ -83,7 +83,7 @@ services:
|
||||
container_name: immich_prometheus
|
||||
ports:
|
||||
- 9090:9090
|
||||
image: prom/prometheus@sha256:23031bfe0e74a13004252caaa74eccd0d62b6c6e7a04711d5b8bf5b7e113adc7
|
||||
image: prom/prometheus@sha256:49214755b6153f90a597adcbff0252cc61069f8ab69ce8411285cd4a560e8038
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus-data:/prometheus
|
||||
|
||||
@@ -10,6 +10,16 @@ import MobileAppBackup from '/docs/partials/_mobile-app-backup.md';
|
||||
|
||||
<MobileAppDownload />
|
||||
|
||||
:::info Android verification
|
||||
Below are the SHA-256 fingerprints for the certificates signing the android applications.
|
||||
|
||||
- Playstore / Github releases:
|
||||
`86:C5:C4:55:DF:AF:49:85:92:3A:8F:35:AD:B3:1D:0C:9E:0B:95:7D:7F:94:C2:D2:AF:6A:24:38:AA:96:00:20`
|
||||
- F-Droid releases:
|
||||
`FA:8B:43:95:F4:A6:47:71:A0:53:D1:C7:57:73:5F:A2:30:13:74:F5:3D:58:0D:D1:75:AA:F7:A1:35:72:9C:BF`
|
||||
|
||||
:::
|
||||
|
||||
:::info Beta Program
|
||||
The beta release channel allows users to test upcoming changes before they are officially released. To join the channel use the links below.
|
||||
|
||||
|
||||
@@ -106,14 +106,14 @@ SELECT "user"."email", "asset"."type", COUNT(*) FROM "asset"
|
||||
|
||||
```sql title="Count by tag"
|
||||
SELECT "t"."value" AS "tag_name", COUNT(*) AS "number_assets" FROM "tag" "t"
|
||||
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagsId" JOIN "asset" "a" ON "ta"."assetsId" = "a"."id"
|
||||
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagId" JOIN "asset" "a" ON "ta"."assetId" = "a"."id"
|
||||
WHERE "a"."visibility" != 'hidden'
|
||||
GROUP BY "t"."value" ORDER BY "number_assets" DESC;
|
||||
```
|
||||
|
||||
```sql title="Count by tag (per user)"
|
||||
SELECT "t"."value" AS "tag_name", "u"."email" as "user_email", COUNT(*) AS "number_assets" FROM "tag" "t"
|
||||
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagsId" JOIN "asset" "a" ON "ta"."assetsId" = "a"."id" JOIN "user" "u" ON "a"."ownerId" = "u"."id"
|
||||
JOIN "tag_asset" "ta" ON "t"."id" = "ta"."tagId" JOIN "asset" "a" ON "ta"."assetId" = "a"."id" JOIN "user" "u" ON "a"."ownerId" = "u"."id"
|
||||
WHERE "a"."visibility" != 'hidden'
|
||||
GROUP BY "t"."value", "u"."email" ORDER BY "number_assets" DESC;
|
||||
```
|
||||
|
||||
@@ -16,48 +16,76 @@ The default configuration looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"ffmpeg": {
|
||||
"crf": 23,
|
||||
"threads": 0,
|
||||
"preset": "ultrafast",
|
||||
"targetVideoCodec": "h264",
|
||||
"acceptedVideoCodecs": ["h264"],
|
||||
"targetAudioCodec": "aac",
|
||||
"acceptedAudioCodecs": ["aac", "mp3", "libopus", "pcm_s16le"],
|
||||
"acceptedContainers": ["mov", "ogg", "webm"],
|
||||
"targetResolution": "720",
|
||||
"maxBitrate": "0",
|
||||
"bframes": -1,
|
||||
"refs": 0,
|
||||
"gopSize": 0,
|
||||
"temporalAQ": false,
|
||||
"cqMode": "auto",
|
||||
"twoPass": false,
|
||||
"preferredHwDevice": "auto",
|
||||
"transcode": "required",
|
||||
"tonemap": "hable",
|
||||
"accel": "disabled",
|
||||
"accelDecode": false
|
||||
},
|
||||
"backup": {
|
||||
"database": {
|
||||
"enabled": true,
|
||||
"cronExpression": "0 02 * * *",
|
||||
"enabled": true,
|
||||
"keepLastAmount": 14
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"accel": "disabled",
|
||||
"accelDecode": false,
|
||||
"acceptedAudioCodecs": ["aac", "mp3", "libopus"],
|
||||
"acceptedContainers": ["mov", "ogg", "webm"],
|
||||
"acceptedVideoCodecs": ["h264"],
|
||||
"bframes": -1,
|
||||
"cqMode": "auto",
|
||||
"crf": 23,
|
||||
"gopSize": 0,
|
||||
"maxBitrate": "0",
|
||||
"preferredHwDevice": "auto",
|
||||
"preset": "ultrafast",
|
||||
"refs": 0,
|
||||
"targetAudioCodec": "aac",
|
||||
"targetResolution": "720",
|
||||
"targetVideoCodec": "h264",
|
||||
"temporalAQ": false,
|
||||
"threads": 0,
|
||||
"tonemap": "hable",
|
||||
"transcode": "required",
|
||||
"twoPass": false
|
||||
},
|
||||
"image": {
|
||||
"colorspace": "p3",
|
||||
"extractEmbedded": false,
|
||||
"fullsize": {
|
||||
"enabled": false,
|
||||
"format": "jpeg",
|
||||
"quality": 80
|
||||
},
|
||||
"preview": {
|
||||
"format": "jpeg",
|
||||
"quality": 80,
|
||||
"size": 1440
|
||||
},
|
||||
"thumbnail": {
|
||||
"format": "webp",
|
||||
"quality": 80,
|
||||
"size": 250
|
||||
}
|
||||
},
|
||||
"job": {
|
||||
"backgroundTask": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"smartSearch": {
|
||||
"faceDetection": {
|
||||
"concurrency": 2
|
||||
},
|
||||
"library": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"metadataExtraction": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"faceDetection": {
|
||||
"concurrency": 2
|
||||
"migration": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"notifications": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"ocr": {
|
||||
"concurrency": 1
|
||||
},
|
||||
"search": {
|
||||
"concurrency": 5
|
||||
@@ -65,20 +93,23 @@ The default configuration looks like this:
|
||||
"sidecar": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"library": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"migration": {
|
||||
"concurrency": 5
|
||||
"smartSearch": {
|
||||
"concurrency": 2
|
||||
},
|
||||
"thumbnailGeneration": {
|
||||
"concurrency": 3
|
||||
},
|
||||
"videoConversion": {
|
||||
"concurrency": 1
|
||||
}
|
||||
},
|
||||
"library": {
|
||||
"scan": {
|
||||
"cronExpression": "0 0 * * *",
|
||||
"enabled": true
|
||||
},
|
||||
"notifications": {
|
||||
"concurrency": 5
|
||||
"watch": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
@@ -86,8 +117,11 @@ The default configuration looks like this:
|
||||
"level": "log"
|
||||
},
|
||||
"machineLearning": {
|
||||
"enabled": true,
|
||||
"urls": ["http://immich-machine-learning:3003"],
|
||||
"availabilityChecks": {
|
||||
"enabled": true,
|
||||
"interval": 30000,
|
||||
"timeout": 2000
|
||||
},
|
||||
"clip": {
|
||||
"enabled": true,
|
||||
"modelName": "ViT-B-32__openai"
|
||||
@@ -96,27 +130,59 @@ The default configuration looks like this:
|
||||
"enabled": true,
|
||||
"maxDistance": 0.01
|
||||
},
|
||||
"enabled": true,
|
||||
"facialRecognition": {
|
||||
"enabled": true,
|
||||
"modelName": "buffalo_l",
|
||||
"minScore": 0.7,
|
||||
"maxDistance": 0.5,
|
||||
"minFaces": 3
|
||||
}
|
||||
"minFaces": 3,
|
||||
"minScore": 0.7,
|
||||
"modelName": "buffalo_l"
|
||||
},
|
||||
"ocr": {
|
||||
"enabled": true,
|
||||
"maxResolution": 736,
|
||||
"minDetectionScore": 0.5,
|
||||
"minRecognitionScore": 0.8,
|
||||
"modelName": "PP-OCRv5_mobile"
|
||||
},
|
||||
"urls": ["http://immich-machine-learning:3003"]
|
||||
},
|
||||
"map": {
|
||||
"darkStyle": "https://tiles.immich.cloud/v1/style/dark.json",
|
||||
"enabled": true,
|
||||
"lightStyle": "https://tiles.immich.cloud/v1/style/light.json",
|
||||
"darkStyle": "https://tiles.immich.cloud/v1/style/dark.json"
|
||||
},
|
||||
"reverseGeocoding": {
|
||||
"enabled": true
|
||||
"lightStyle": "https://tiles.immich.cloud/v1/style/light.json"
|
||||
},
|
||||
"metadata": {
|
||||
"faces": {
|
||||
"import": false
|
||||
}
|
||||
},
|
||||
"newVersionCheck": {
|
||||
"enabled": true
|
||||
},
|
||||
"nightlyTasks": {
|
||||
"clusterNewFaces": true,
|
||||
"databaseCleanup": true,
|
||||
"generateMemories": true,
|
||||
"missingThumbnails": true,
|
||||
"startTime": "00:00",
|
||||
"syncQuotaUsage": true
|
||||
},
|
||||
"notifications": {
|
||||
"smtp": {
|
||||
"enabled": false,
|
||||
"from": "",
|
||||
"replyTo": "",
|
||||
"transport": {
|
||||
"host": "",
|
||||
"ignoreCert": false,
|
||||
"password": "",
|
||||
"port": 587,
|
||||
"secure": false,
|
||||
"username": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"autoLaunch": false,
|
||||
"autoRegister": true,
|
||||
@@ -128,70 +194,44 @@ The default configuration looks like this:
|
||||
"issuerUrl": "",
|
||||
"mobileOverrideEnabled": false,
|
||||
"mobileRedirectUri": "",
|
||||
"profileSigningAlgorithm": "none",
|
||||
"roleClaim": "immich_role",
|
||||
"scope": "openid email profile",
|
||||
"signingAlgorithm": "RS256",
|
||||
"profileSigningAlgorithm": "none",
|
||||
"storageLabelClaim": "preferred_username",
|
||||
"storageQuotaClaim": "immich_quota"
|
||||
"storageQuotaClaim": "immich_quota",
|
||||
"timeout": 30000,
|
||||
"tokenEndpointAuthMethod": "client_secret_post"
|
||||
},
|
||||
"passwordLogin": {
|
||||
"enabled": true
|
||||
},
|
||||
"reverseGeocoding": {
|
||||
"enabled": true
|
||||
},
|
||||
"server": {
|
||||
"externalDomain": "",
|
||||
"loginPageMessage": "",
|
||||
"publicUsers": true
|
||||
},
|
||||
"storageTemplate": {
|
||||
"enabled": false,
|
||||
"hashVerificationEnabled": true,
|
||||
"template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}"
|
||||
},
|
||||
"image": {
|
||||
"thumbnail": {
|
||||
"format": "webp",
|
||||
"size": 250,
|
||||
"quality": 80
|
||||
},
|
||||
"preview": {
|
||||
"format": "jpeg",
|
||||
"size": 1440,
|
||||
"quality": 80
|
||||
},
|
||||
"colorspace": "p3",
|
||||
"extractEmbedded": false
|
||||
},
|
||||
"newVersionCheck": {
|
||||
"enabled": true
|
||||
},
|
||||
"trash": {
|
||||
"enabled": true,
|
||||
"days": 30
|
||||
"templates": {
|
||||
"email": {
|
||||
"albumInviteTemplate": "",
|
||||
"albumUpdateTemplate": "",
|
||||
"welcomeTemplate": ""
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"customCss": ""
|
||||
},
|
||||
"library": {
|
||||
"scan": {
|
||||
"enabled": true,
|
||||
"cronExpression": "0 0 * * *"
|
||||
},
|
||||
"watch": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"externalDomain": "",
|
||||
"loginPageMessage": ""
|
||||
},
|
||||
"notifications": {
|
||||
"smtp": {
|
||||
"enabled": false,
|
||||
"from": "",
|
||||
"replyTo": "",
|
||||
"transport": {
|
||||
"ignoreCert": false,
|
||||
"host": "",
|
||||
"port": 587,
|
||||
"username": "",
|
||||
"password": ""
|
||||
}
|
||||
}
|
||||
"trash": {
|
||||
"days": 30,
|
||||
"enabled": true
|
||||
},
|
||||
"user": {
|
||||
"deleteDelay": 7
|
||||
|
||||
12
docs/static/archived-versions.json
vendored
12
docs/static/archived-versions.json
vendored
@@ -1,4 +1,16 @@
|
||||
[
|
||||
{
|
||||
"label": "v2.2.3",
|
||||
"url": "https://docs.v2.2.3.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.2.2",
|
||||
"url": "https://docs.v2.2.2.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.2.1",
|
||||
"url": "https://docs.v2.2.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.2.0",
|
||||
"url": "https://docs.v2.2.0.archive.immich.app"
|
||||
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
- 2285:2285
|
||||
|
||||
redis:
|
||||
image: redis:6.2-alpine@sha256:77697a75da9f94e9357b61fcaf8345f69e3d9d32e9d15032c8415c21263977dc
|
||||
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
|
||||
|
||||
database:
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.3",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
@@ -25,7 +25,7 @@
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^22.18.12",
|
||||
"@types/node": "^22.18.13",
|
||||
"@types/oidc-provider": "^9.0.0",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
@@ -53,5 +53,8 @@
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.11.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"structured-headers": "^2.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
1098
e2e/src/api/specs/asset-upload.e2e-spec.ts
Normal file
1098
e2e/src/api/specs/asset-upload.e2e-spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,6 @@ import { DateTime } from 'luxon';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { basename, join } from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { Socket } from 'socket.io-client';
|
||||
import { createUserDto, uuidDto } from 'src/fixtures';
|
||||
import { makeRandomImage } from 'src/generators';
|
||||
@@ -41,40 +40,6 @@ const today = DateTime.fromObject({
|
||||
}) as DateTime<true>;
|
||||
const yesterday = today.minus({ days: 1 });
|
||||
|
||||
const createTestImageWithExif = async (filename: string, exifData: Record<string, any>) => {
|
||||
// Generate unique color to ensure different checksums for each image
|
||||
const r = Math.floor(Math.random() * 256);
|
||||
const g = Math.floor(Math.random() * 256);
|
||||
const b = Math.floor(Math.random() * 256);
|
||||
|
||||
// Create a 100x100 solid color JPEG using Sharp
|
||||
const imageBytes = await sharp({
|
||||
create: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
channels: 3,
|
||||
background: { r, g, b },
|
||||
},
|
||||
})
|
||||
.jpeg({ quality: 90 })
|
||||
.toBuffer();
|
||||
|
||||
// Add random suffix to filename to avoid collisions
|
||||
const uniqueFilename = filename.replace('.jpg', `-${randomBytes(4).toString('hex')}.jpg`);
|
||||
const filepath = join(tempDir, uniqueFilename);
|
||||
await writeFile(filepath, imageBytes);
|
||||
|
||||
// Filter out undefined values before writing EXIF
|
||||
const cleanExifData = Object.fromEntries(Object.entries(exifData).filter(([, value]) => value !== undefined));
|
||||
|
||||
await exiftool.write(filepath, cleanExifData);
|
||||
|
||||
// Re-read the image bytes after EXIF has been written
|
||||
const finalImageBytes = await readFile(filepath);
|
||||
|
||||
return { filepath, imageBytes: finalImageBytes, filename: uniqueFilename };
|
||||
};
|
||||
|
||||
describe('/asset', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let websocket: Socket;
|
||||
@@ -1249,411 +1214,6 @@ describe('/asset', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('EXIF metadata extraction', () => {
|
||||
describe('Additional date tag extraction', () => {
|
||||
describe('Date-time vs time-only tag handling', () => {
|
||||
it('should fall back to file timestamps when only time-only tags are available', async () => {
|
||||
const { imageBytes, filename } = await createTestImageWithExif('time-only-fallback.jpg', {
|
||||
TimeCreated: '2023:11:15 14:30:00', // Time-only tag, should not be used for dateTimeOriginal
|
||||
// Exclude all date-time tags to force fallback to file timestamps
|
||||
SubSecDateTimeOriginal: undefined,
|
||||
DateTimeOriginal: undefined,
|
||||
SubSecCreateDate: undefined,
|
||||
SubSecMediaCreateDate: undefined,
|
||||
CreateDate: undefined,
|
||||
MediaCreateDate: undefined,
|
||||
CreationDate: undefined,
|
||||
DateTimeCreated: undefined,
|
||||
GPSDateTime: undefined,
|
||||
DateTimeUTC: undefined,
|
||||
SonyDateTime2: undefined,
|
||||
GPSDateStamp: undefined,
|
||||
});
|
||||
|
||||
const oldDate = new Date('2020-01-01T00:00:00.000Z');
|
||||
const asset = await utils.createAsset(admin.accessToken, {
|
||||
assetData: {
|
||||
filename,
|
||||
bytes: imageBytes,
|
||||
},
|
||||
fileCreatedAt: oldDate.toISOString(),
|
||||
fileModifiedAt: oldDate.toISOString(),
|
||||
});
|
||||
|
||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
||||
|
||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
||||
|
||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
||||
// Should fall back to file timestamps, which we set to 2020-01-01
|
||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
||||
new Date('2020-01-01T00:00:00.000Z').getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should prefer DateTimeOriginal over time-only tags', async () => {
|
||||
const { imageBytes, filename } = await createTestImageWithExif('datetime-over-time.jpg', {
|
||||
DateTimeOriginal: '2023:10:10 10:00:00', // Should be preferred
|
||||
TimeCreated: '2023:11:15 14:30:00', // Should be ignored (time-only)
|
||||
});
|
||||
|
||||
const asset = await utils.createAsset(admin.accessToken, {
|
||||
assetData: {
|
||||
filename,
|
||||
bytes: imageBytes,
|
||||
},
|
||||
});
|
||||
|
||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
||||
|
||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
||||
|
||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
||||
// Should use DateTimeOriginal, not TimeCreated
|
||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
||||
new Date('2023-10-10T10:00:00.000Z').getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GPSDateTime tag extraction', () => {
|
||||
it('should extract GPSDateTime with GPS coordinates', async () => {
|
||||
const { imageBytes, filename } = await createTestImageWithExif('gps-datetime.jpg', {
|
||||
GPSDateTime: '2023:11:15 12:30:00Z',
|
||||
GPSLatitude: 37.7749,
|
||||
GPSLongitude: -122.4194,
|
||||
// Exclude other date tags
|
||||
SubSecDateTimeOriginal: undefined,
|
||||
DateTimeOriginal: undefined,
|
||||
SubSecCreateDate: undefined,
|
||||
SubSecMediaCreateDate: undefined,
|
||||
CreateDate: undefined,
|
||||
MediaCreateDate: undefined,
|
||||
CreationDate: undefined,
|
||||
DateTimeCreated: undefined,
|
||||
TimeCreated: undefined,
|
||||
});
|
||||
|
||||
const asset = await utils.createAsset(admin.accessToken, {
|
||||
assetData: {
|
||||
filename,
|
||||
bytes: imageBytes,
|
||||
},
|
||||
});
|
||||
|
||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
||||
|
||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
||||
|
||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
||||
expect(assetInfo.exifInfo?.latitude).toBeCloseTo(37.7749, 4);
|
||||
expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-122.4194, 4);
|
||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
||||
new Date('2023-11-15T12:30:00.000Z').getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreateDate tag extraction', () => {
|
||||
it('should extract CreateDate when available', async () => {
|
||||
const { imageBytes, filename } = await createTestImageWithExif('create-date.jpg', {
|
||||
CreateDate: '2023:11:15 10:30:00',
|
||||
// Exclude other higher priority date tags
|
||||
SubSecDateTimeOriginal: undefined,
|
||||
DateTimeOriginal: undefined,
|
||||
SubSecCreateDate: undefined,
|
||||
SubSecMediaCreateDate: undefined,
|
||||
MediaCreateDate: undefined,
|
||||
CreationDate: undefined,
|
||||
DateTimeCreated: undefined,
|
||||
TimeCreated: undefined,
|
||||
GPSDateTime: undefined,
|
||||
});
|
||||
|
||||
const asset = await utils.createAsset(admin.accessToken, {
|
||||
assetData: {
|
||||
filename,
|
||||
bytes: imageBytes,
|
||||
},
|
||||
});
|
||||
|
||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
||||
|
||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
||||
|
||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
||||
new Date('2023-11-15T10:30:00.000Z').getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GPSDateStamp tag extraction', () => {
|
||||
it('should fall back to file timestamps when only date-only tags are available', async () => {
|
||||
const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp.jpg', {
|
||||
GPSDateStamp: '2023:11:15', // Date-only tag, should not be used for dateTimeOriginal
|
||||
// Note: NOT including GPSTimeStamp to avoid automatic GPSDateTime creation
|
||||
GPSLatitude: 51.5074,
|
||||
GPSLongitude: -0.1278,
|
||||
// Explicitly exclude all testable date-time tags to force fallback to file timestamps
|
||||
DateTimeOriginal: undefined,
|
||||
CreateDate: undefined,
|
||||
CreationDate: undefined,
|
||||
GPSDateTime: undefined,
|
||||
});
|
||||
|
||||
const oldDate = new Date('2020-01-01T00:00:00.000Z');
|
||||
const asset = await utils.createAsset(admin.accessToken, {
|
||||
assetData: {
|
||||
filename,
|
||||
bytes: imageBytes,
|
||||
},
|
||||
fileCreatedAt: oldDate.toISOString(),
|
||||
fileModifiedAt: oldDate.toISOString(),
|
||||
});
|
||||
|
||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
||||
|
||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
||||
|
||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
||||
expect(assetInfo.exifInfo?.latitude).toBeCloseTo(51.5074, 4);
|
||||
expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-0.1278, 4);
|
||||
// Should fall back to file timestamps, which we set to 2020-01-01
|
||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
||||
new Date('2020-01-01T00:00:00.000Z').getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* NOTE: The following EXIF date tags are NOT effectively usable with JPEG test files:
|
||||
*
|
||||
* NOT WRITABLE to JPEG:
|
||||
* - MediaCreateDate: Can be read from video files but not written to JPEG
|
||||
* - DateTimeCreated: Read-only tag in JPEG format
|
||||
* - DateTimeUTC: Cannot be written to JPEG files
|
||||
* - SonyDateTime2: Proprietary Sony tag, not writable to JPEG
|
||||
* - SubSecMediaCreateDate: Tag not defined for JPEG format
|
||||
* - SourceImageCreateTime: Non-standard insta360 tag, not writable to JPEG
|
||||
*
|
||||
* WRITABLE but NOT READABLE from JPEG:
|
||||
* - SubSecDateTimeOriginal: Can be written but not read back from JPEG
|
||||
* - SubSecCreateDate: Can be written but not read back from JPEG
|
||||
*
|
||||
* EFFECTIVELY TESTABLE TAGS (writable and readable):
|
||||
* - DateTimeOriginal ✓
|
||||
* - CreateDate ✓
|
||||
* - CreationDate ✓
|
||||
* - GPSDateTime ✓
|
||||
*
|
||||
* The metadata service correctly handles non-readable tags and will fall back to
|
||||
* file timestamps when only non-readable tags are present.
|
||||
*/
|
||||
|
||||
describe('Date tag priority order', () => {
|
||||
it('should respect the complete date tag priority order', async () => {
|
||||
// Test cases using only EFFECTIVELY TESTABLE tags (writable AND readable from JPEG)
|
||||
const testCases = [
|
||||
{
|
||||
name: 'DateTimeOriginal has highest priority among testable tags',
|
||||
exifData: {
|
||||
DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags
|
||||
CreateDate: '2023:05:05 05:00:00', // TESTABLE
|
||||
CreationDate: '2023:07:07 07:00:00', // TESTABLE
|
||||
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
|
||||
},
|
||||
expectedDate: '2023-04-04T04:00:00.000Z',
|
||||
},
|
||||
{
|
||||
name: 'CreationDate when DateTimeOriginal missing',
|
||||
exifData: {
|
||||
CreationDate: '2023:05:05 05:00:00', // TESTABLE
|
||||
CreateDate: '2023:07:07 07:00:00', // TESTABLE
|
||||
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
|
||||
},
|
||||
expectedDate: '2023-05-05T05:00:00.000Z',
|
||||
},
|
||||
{
|
||||
name: 'CreationDate when standard EXIF tags missing',
|
||||
exifData: {
|
||||
CreationDate: '2023:07:07 07:00:00', // TESTABLE
|
||||
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
|
||||
},
|
||||
expectedDate: '2023-07-07T07:00:00.000Z',
|
||||
},
|
||||
{
|
||||
name: 'GPSDateTime when no other testable date tags present',
|
||||
exifData: {
|
||||
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
|
||||
Make: 'SONY',
|
||||
},
|
||||
expectedDate: '2023-10-10T10:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const { imageBytes, filename } = await createTestImageWithExif(
|
||||
`${testCase.name.replaceAll(/\s+/g, '-').toLowerCase()}.jpg`,
|
||||
testCase.exifData,
|
||||
);
|
||||
|
||||
const asset = await utils.createAsset(admin.accessToken, {
|
||||
assetData: {
|
||||
filename,
|
||||
bytes: imageBytes,
|
||||
},
|
||||
});
|
||||
|
||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
||||
|
||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
||||
|
||||
expect(assetInfo.exifInfo?.dateTimeOriginal, `Failed for: ${testCase.name}`).toBeDefined();
|
||||
expect(
|
||||
new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime(),
|
||||
`Date mismatch for: ${testCase.name}`,
|
||||
).toBe(new Date(testCase.expectedDate).getTime());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases for date tag handling', () => {
|
||||
it('should fall back to file timestamps with GPSDateStamp alone', async () => {
|
||||
const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp-only.jpg', {
|
||||
GPSDateStamp: '2023:08:08', // Date-only tag, should not be used for dateTimeOriginal
|
||||
// Intentionally no GPSTimeStamp
|
||||
// Exclude all other date tags
|
||||
SubSecDateTimeOriginal: undefined,
|
||||
DateTimeOriginal: undefined,
|
||||
SubSecCreateDate: undefined,
|
||||
SubSecMediaCreateDate: undefined,
|
||||
CreateDate: undefined,
|
||||
MediaCreateDate: undefined,
|
||||
CreationDate: undefined,
|
||||
DateTimeCreated: undefined,
|
||||
TimeCreated: undefined,
|
||||
GPSDateTime: undefined,
|
||||
DateTimeUTC: undefined,
|
||||
});
|
||||
|
||||
const oldDate = new Date('2020-01-01T00:00:00.000Z');
|
||||
const asset = await utils.createAsset(admin.accessToken, {
|
||||
assetData: {
|
||||
filename,
|
||||
bytes: imageBytes,
|
||||
},
|
||||
fileCreatedAt: oldDate.toISOString(),
|
||||
fileModifiedAt: oldDate.toISOString(),
|
||||
});
|
||||
|
||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
||||
|
||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
||||
|
||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
||||
// Should fall back to file timestamps, which we set to 2020-01-01
|
||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
||||
new Date('2020-01-01T00:00:00.000Z').getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle all testable date tags present to verify complete priority order', async () => {
|
||||
const { imageBytes, filename } = await createTestImageWithExif('all-testable-date-tags.jpg', {
|
||||
// All TESTABLE date tags to JPEG format (writable AND readable)
|
||||
DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags
|
||||
CreateDate: '2023:05:05 05:00:00', // TESTABLE
|
||||
CreationDate: '2023:07:07 07:00:00', // TESTABLE
|
||||
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
|
||||
// Note: Excluded non-testable tags:
|
||||
// SubSec tags: writable but not readable from JPEG
|
||||
// Non-writable tags: MediaCreateDate, DateTimeCreated, DateTimeUTC, SonyDateTime2, etc.
|
||||
// Time-only/date-only tags: already excluded from EXIF_DATE_TAGS
|
||||
});
|
||||
|
||||
const asset = await utils.createAsset(admin.accessToken, {
|
||||
assetData: {
|
||||
filename,
|
||||
bytes: imageBytes,
|
||||
},
|
||||
});
|
||||
|
||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
||||
|
||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
||||
|
||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
||||
// Should use DateTimeOriginal as it has the highest priority among testable tags
|
||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
||||
new Date('2023-04-04T04:00:00.000Z').getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use CreationDate when SubSec tags are missing', async () => {
|
||||
const { imageBytes, filename } = await createTestImageWithExif('creation-date-priority.jpg', {
|
||||
CreationDate: '2023:07:07 07:00:00', // WRITABLE
|
||||
GPSDateTime: '2023:10:10 10:00:00', // WRITABLE
|
||||
// Note: DateTimeCreated, DateTimeUTC, SonyDateTime2 are NOT writable to JPEG
|
||||
// Note: TimeCreated and GPSDateStamp are excluded from EXIF_DATE_TAGS (time-only/date-only)
|
||||
// Exclude SubSec and standard EXIF tags
|
||||
SubSecDateTimeOriginal: undefined,
|
||||
DateTimeOriginal: undefined,
|
||||
SubSecCreateDate: undefined,
|
||||
CreateDate: undefined,
|
||||
});
|
||||
|
||||
const asset = await utils.createAsset(admin.accessToken, {
|
||||
assetData: {
|
||||
filename,
|
||||
bytes: imageBytes,
|
||||
},
|
||||
});
|
||||
|
||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
||||
|
||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
||||
|
||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
||||
// Should use CreationDate when available
|
||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
||||
new Date('2023-07-07T07:00:00.000Z').getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip invalid date formats and use next valid tag', async () => {
|
||||
const { imageBytes, filename } = await createTestImageWithExif('invalid-date-handling.jpg', {
|
||||
// Note: Testing invalid date handling with only WRITABLE tags
|
||||
GPSDateTime: '2023:10:10 10:00:00', // WRITABLE - Valid date
|
||||
CreationDate: '2023:13:13 13:00:00', // WRITABLE - Valid date
|
||||
// Note: TimeCreated excluded (time-only), DateTimeCreated not writable to JPEG
|
||||
// Exclude other date tags
|
||||
SubSecDateTimeOriginal: undefined,
|
||||
DateTimeOriginal: undefined,
|
||||
SubSecCreateDate: undefined,
|
||||
CreateDate: undefined,
|
||||
});
|
||||
|
||||
const asset = await utils.createAsset(admin.accessToken, {
|
||||
assetData: {
|
||||
filename,
|
||||
bytes: imageBytes,
|
||||
},
|
||||
});
|
||||
|
||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
|
||||
|
||||
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
|
||||
|
||||
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
|
||||
// Should skip invalid dates and use the first valid one (GPSDateTime)
|
||||
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
|
||||
new Date('2023-10-10T10:00:00.000Z').getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /assets/exist', () => {
|
||||
it('ignores invalid deviceAssetIds', async () => {
|
||||
const response = await utils.checkExistingAssets(user1.accessToken, {
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to generate test images with additional EXIF date tags
|
||||
* This creates actual JPEG images with embedded metadata for testing
|
||||
* Images are generated into e2e/test-assets/metadata/dates/
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import sharp from 'sharp';
|
||||
|
||||
interface TestImage {
|
||||
filename: string;
|
||||
description: string;
|
||||
exifTags: Record<string, string>;
|
||||
}
|
||||
|
||||
const testImages: TestImage[] = [
|
||||
{
|
||||
filename: 'time-created.jpg',
|
||||
description: 'Image with TimeCreated tag',
|
||||
exifTags: {
|
||||
TimeCreated: '2023:11:15 14:30:00',
|
||||
Make: 'Canon',
|
||||
Model: 'EOS R5',
|
||||
},
|
||||
},
|
||||
{
|
||||
filename: 'gps-datetime.jpg',
|
||||
description: 'Image with GPSDateTime and coordinates',
|
||||
exifTags: {
|
||||
GPSDateTime: '2023:11:15 12:30:00Z',
|
||||
GPSLatitude: '37.7749',
|
||||
GPSLongitude: '-122.4194',
|
||||
GPSLatitudeRef: 'N',
|
||||
GPSLongitudeRef: 'W',
|
||||
},
|
||||
},
|
||||
{
|
||||
filename: 'datetime-utc.jpg',
|
||||
description: 'Image with DateTimeUTC tag',
|
||||
exifTags: {
|
||||
DateTimeUTC: '2023:11:15 10:30:00',
|
||||
Make: 'Nikon',
|
||||
Model: 'D850',
|
||||
},
|
||||
},
|
||||
{
|
||||
filename: 'gps-datestamp.jpg',
|
||||
description: 'Image with GPSDateStamp and GPSTimeStamp',
|
||||
exifTags: {
|
||||
GPSDateStamp: '2023:11:15',
|
||||
GPSTimeStamp: '08:30:00',
|
||||
GPSLatitude: '51.5074',
|
||||
GPSLongitude: '-0.1278',
|
||||
GPSLatitudeRef: 'N',
|
||||
GPSLongitudeRef: 'W',
|
||||
},
|
||||
},
|
||||
{
|
||||
filename: 'sony-datetime2.jpg',
|
||||
description: 'Sony camera image with SonyDateTime2 tag',
|
||||
exifTags: {
|
||||
SonyDateTime2: '2023:11:15 06:30:00',
|
||||
Make: 'SONY',
|
||||
Model: 'ILCE-7RM5',
|
||||
},
|
||||
},
|
||||
{
|
||||
filename: 'date-priority-test.jpg',
|
||||
description: 'Image with multiple date tags to test priority',
|
||||
exifTags: {
|
||||
SubSecDateTimeOriginal: '2023:01:01 01:00:00',
|
||||
DateTimeOriginal: '2023:02:02 02:00:00',
|
||||
SubSecCreateDate: '2023:03:03 03:00:00',
|
||||
CreateDate: '2023:04:04 04:00:00',
|
||||
CreationDate: '2023:05:05 05:00:00',
|
||||
DateTimeCreated: '2023:06:06 06:00:00',
|
||||
TimeCreated: '2023:07:07 07:00:00',
|
||||
GPSDateTime: '2023:08:08 08:00:00',
|
||||
DateTimeUTC: '2023:09:09 09:00:00',
|
||||
GPSDateStamp: '2023:10:10',
|
||||
SonyDateTime2: '2023:11:11 11:00:00',
|
||||
},
|
||||
},
|
||||
{
|
||||
filename: 'new-tags-only.jpg',
|
||||
description: 'Image with only additional date tags (no standard tags)',
|
||||
exifTags: {
|
||||
TimeCreated: '2023:12:01 15:45:30',
|
||||
GPSDateTime: '2023:12:01 13:45:30Z',
|
||||
DateTimeUTC: '2023:12:01 13:45:30',
|
||||
GPSDateStamp: '2023:12:01',
|
||||
SonyDateTime2: '2023:12:01 08:45:30',
|
||||
GPSLatitude: '40.7128',
|
||||
GPSLongitude: '-74.0060',
|
||||
GPSLatitudeRef: 'N',
|
||||
GPSLongitudeRef: 'W',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const generateTestImages = async (): Promise<void> => {
|
||||
// Target directory: e2e/test-assets/metadata/dates/
|
||||
// Current file is in: e2e/src/
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const targetDir = join(__dirname, '..', 'test-assets', 'metadata', 'dates');
|
||||
|
||||
console.log('Generating test images with additional EXIF date tags...');
|
||||
console.log(`Target directory: ${targetDir}`);
|
||||
|
||||
for (const image of testImages) {
|
||||
try {
|
||||
const imagePath = join(targetDir, image.filename);
|
||||
|
||||
// Create unique JPEG file using Sharp
|
||||
const r = Math.floor(Math.random() * 256);
|
||||
const g = Math.floor(Math.random() * 256);
|
||||
const b = Math.floor(Math.random() * 256);
|
||||
|
||||
const jpegData = await sharp({
|
||||
create: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
channels: 3,
|
||||
background: { r, g, b },
|
||||
},
|
||||
})
|
||||
.jpeg({ quality: 90 })
|
||||
.toBuffer();
|
||||
|
||||
writeFileSync(imagePath, jpegData);
|
||||
|
||||
// Build exiftool command to add EXIF data
|
||||
const exifArgs = Object.entries(image.exifTags)
|
||||
.map(([tag, value]) => `-${tag}="${value}"`)
|
||||
.join(' ');
|
||||
|
||||
const command = `exiftool ${exifArgs} -overwrite_original "${imagePath}"`;
|
||||
|
||||
console.log(`Creating ${image.filename}: ${image.description}`);
|
||||
execSync(command, { stdio: 'pipe' });
|
||||
|
||||
// Verify the tags were written
|
||||
const verifyCommand = `exiftool -json "${imagePath}"`;
|
||||
const result = execSync(verifyCommand, { encoding: 'utf8' });
|
||||
const metadata = JSON.parse(result)[0];
|
||||
|
||||
console.log(` ✓ Created with ${Object.keys(image.exifTags).length} EXIF tags`);
|
||||
|
||||
// Log first date tag found for verification
|
||||
const firstDateTag = Object.keys(image.exifTags).find(
|
||||
(tag) => tag.includes('Date') || tag.includes('Time') || tag.includes('Created'),
|
||||
);
|
||||
if (firstDateTag && metadata[firstDateTag]) {
|
||||
console.log(` ✓ Verified ${firstDateTag}: ${metadata[firstDateTag]}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to create ${image.filename}:`, (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nTest image generation complete!');
|
||||
console.log('Files created in:', targetDir);
|
||||
console.log('\nTo test these images:');
|
||||
console.log(`cd ${targetDir} && exiftool -time:all -gps:all *.jpg`);
|
||||
};
|
||||
|
||||
export { generateTestImages };
|
||||
|
||||
// Run the generator if this file is executed directly
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
generateTestImages().catch(console.error);
|
||||
}
|
||||
@@ -561,6 +561,16 @@ export const utils = {
|
||||
await utils.waitForQueueFinish(accessToken, 'sidecar');
|
||||
await utils.waitForQueueFinish(accessToken, 'metadataExtraction');
|
||||
},
|
||||
|
||||
downloadAsset: async (accessToken: string, id: string) => {
|
||||
const downloadedRes = await fetch(`${baseUrl}/api/assets/${id}/original`, {
|
||||
headers: asBearerAuth(accessToken),
|
||||
});
|
||||
if (!downloadedRes.ok) {
|
||||
throw new Error(`Failed to download asset ${id}: ${downloadedRes.status} ${await downloadedRes.text()}`);
|
||||
}
|
||||
return await downloadedRes.blob();
|
||||
},
|
||||
};
|
||||
|
||||
utils.initSdk();
|
||||
|
||||
Submodule e2e/test-assets updated: 37f60ea537...163c251744
@@ -1716,6 +1716,7 @@
|
||||
"running": "Kører",
|
||||
"save": "Gem",
|
||||
"save_to_gallery": "Gem til galleri",
|
||||
"saved": "Gemt",
|
||||
"saved_api_key": "Gemt API-nøgle",
|
||||
"saved_profile": "Gemte profil",
|
||||
"saved_settings": "Gemte indstillinger",
|
||||
|
||||
10
i18n/de.json
10
i18n/de.json
@@ -155,12 +155,15 @@
|
||||
"machine_learning_min_recognized_faces": "Mindestens erkannte Gesichter",
|
||||
"machine_learning_min_recognized_faces_description": "Die Mindestanzahl von erkannten Gesichtern, damit eine Person erstellt werden kann. Eine Erhöhung dieses Wertes macht die Gesichtserkennung präziser, erhöht aber die Wahrscheinlichkeit, dass ein Gesicht nicht zu einer Person zugeordnet wird.",
|
||||
"machine_learning_ocr": "OCR",
|
||||
"machine_learning_ocr_description": "Maschinen lernen nutzen um Texte in Bildern zu erkennen",
|
||||
"machine_learning_ocr_description": "Maschinelles Lernen nutzen um Texte in Bildern zu erkennen",
|
||||
"machine_learning_ocr_enabled": "OCR aktivieren",
|
||||
"machine_learning_ocr_enabled_description": "Wenn deaktiviert, werden die Bilder nicht von der Texterkennung bearbeitet.",
|
||||
"machine_learning_ocr_max_resolution": "Maximale Auflösung",
|
||||
"machine_learning_ocr_max_resolution_description": "Vorschauen über dieser Auflösung werden unter Beibehaltung des Seitenverhältnisses verkleinert. Höhere Werte sind genauer, benötigen jedoch mehr Zeit für die Verarbeitung und verbrauchen mehr Speicher.",
|
||||
"machine_learning_ocr_min_detection_score": "Minimaler Erkennungswert",
|
||||
"machine_learning_ocr_min_detection_score_description": "Minimale Konfidenzrate für die Texterkennung von 0–1. Niedrigere Werte führen dazu, dass mehr Text erkannt wird, können jedoch zu falsch-positiven Ergebnissen führen.",
|
||||
"machine_learning_ocr_min_recognition_score": "Minimale Erkennungsrate",
|
||||
"machine_learning_ocr_min_score_recognition_description": "Minimale Konfidenzrate für die Erkennung von erkanntem Text von 0–1. Niedrigere Werte führen dazu, dass mehr Text erkannt wird, können jedoch zu falsch-positiven Ergebnissen führen.",
|
||||
"machine_learning_ocr_model": "OCR Modell",
|
||||
"machine_learning_ocr_model_description": "Server Modelle sind genauer als mobile Modelle, brauchen aber länger zur Verarbeitung und brauchen mehr Speicher.",
|
||||
"machine_learning_settings": "Einstellungen für maschinelles Lernen",
|
||||
@@ -254,7 +257,7 @@
|
||||
"oauth_storage_quota_default_description": "Kontingent in GiB, das verwendet werden soll, wenn keines übermittelt wird.",
|
||||
"oauth_timeout": "Zeitüberschreitung bei Anfrage",
|
||||
"oauth_timeout_description": "Zeitüberschreitung für Anfragen in Millisekunden",
|
||||
"ocr_job_description": "Verwende Machine Learning zur Ernennung von Text in Bildern",
|
||||
"ocr_job_description": "Verwende Machine Learning zur Erkennung von Text in Bildern",
|
||||
"password_enable_description": "Mit E-Mail und Passwort anmelden",
|
||||
"password_settings": "Passwort-Anmeldung",
|
||||
"password_settings_description": "Passwort-Anmeldeeinstellungen verwalten",
|
||||
@@ -1352,7 +1355,7 @@
|
||||
"memories_check_back_tomorrow": "Schau morgen wieder vorbei für weitere Erinnerungen",
|
||||
"memories_setting_description": "Verwalte, was du in deinen Erinnerungen siehst",
|
||||
"memories_start_over": "Erneut beginnen",
|
||||
"memories_swipe_to_close": "Nach oben Wischen zum schließen",
|
||||
"memories_swipe_to_close": "Nach oben Wischen zum Schließen",
|
||||
"memory": "Erinnerung",
|
||||
"memory_lane_title": "Foto-Erinnerungen {title}",
|
||||
"menu": "Menü",
|
||||
@@ -1713,6 +1716,7 @@
|
||||
"running": "Läuft",
|
||||
"save": "Speichern",
|
||||
"save_to_gallery": "In Galerie speichern",
|
||||
"saved": "Gespeichert",
|
||||
"saved_api_key": "API-Schlüssel wurde gespeichert",
|
||||
"saved_profile": "Profil gespeichert",
|
||||
"saved_settings": "Einstellungen gespeichert",
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
"machine_learning_ocr": "OCR",
|
||||
"machine_learning_ocr_description": "Utiliser l'apprentissage automatique pour reconnaître le texte dans les images",
|
||||
"machine_learning_ocr_enabled": "Activer la reconnaissance de caractères",
|
||||
"machine_learning_ocr_enabled_description": "Si désactivé, la reconnaissance de texte ne s'appliquera pas aux images",
|
||||
"machine_learning_ocr_enabled_description": "Si désactivé, la reconnaissance de texte ne s'appliquera pas aux images.",
|
||||
"machine_learning_ocr_max_resolution": "Résolution maximale",
|
||||
"machine_learning_ocr_max_resolution_description": "Les prévisualisations au-dessus de cette résolution seront retaillées en conservant leur ratio. Des valeurs plus grandes sont plus précises, mais sont plus lentes et utilisent plus de mémoire.",
|
||||
"machine_learning_ocr_min_detection_score": "Score minimum de détection",
|
||||
|
||||
96
i18n/hi.json
96
i18n/hi.json
@@ -33,6 +33,7 @@
|
||||
"add_to_albums": "एकाधिक एल्बम में डाले",
|
||||
"add_to_albums_count": "एल्बमों में डालें ({count})",
|
||||
"add_to_shared_album": "शेयर किए गए एल्बम में डालें",
|
||||
"add_upload_to_stack": "स्टैक में अपलोड करें",
|
||||
"add_url": "URL डालें",
|
||||
"added_to_archive": "संग्रहीत कर दिया गया है",
|
||||
"added_to_favorites": "पसंदीदा में डाला गया",
|
||||
@@ -124,6 +125,13 @@
|
||||
"logging_enable_description": "लॉगिंग करने देना",
|
||||
"logging_level_description": "सक्षम होने पर, किस लॉग स्तर का उपयोग करना है।",
|
||||
"logging_settings": "लॉगिंग",
|
||||
"machine_learning_availability_checks": "उपलब्धता जांच",
|
||||
"machine_learning_availability_checks_description": "उपलब्ध मशीन लर्निंग सर्वर का स्वचालित रूप से पता लगाएं और प्राथमिकता दें",
|
||||
"machine_learning_availability_checks_enabled": "उपलब्धता जांच सक्षम करें",
|
||||
"machine_learning_availability_checks_interval": "अंतराल की जाँच करें",
|
||||
"machine_learning_availability_checks_interval_description": "उपलब्धता जांच के बीच मिलीसेकेंड में अंतराल",
|
||||
"machine_learning_availability_checks_timeout": "अनुरोध समयबाह्य हुआ",
|
||||
"machine_learning_availability_checks_timeout_description": "उपलब्धता जांच के लिए मिलीसेकंड में समयबाह्य अंतराल",
|
||||
"machine_learning_clip_model": "क्लिप मॉडल",
|
||||
"machine_learning_clip_model_description": "CLIP मॉडल का नाम <link>यहां</link> सूचीबद्ध है। ध्यान दें कि मॉडल बदलने पर आपको सभी छवियों के लिए 'स्मार्ट सर्च' जोब फिर से चलाना होगा।",
|
||||
"machine_learning_duplicate_detection": "डुप्लिकेट का पता लगाना",
|
||||
@@ -146,6 +154,18 @@
|
||||
"machine_learning_min_detection_score_description": "किसी चेहरे का पता लगाने के लिए न्यूनतम आत्मविश्वास स्कोर 0-1 होना चाहिए।",
|
||||
"machine_learning_min_recognized_faces": "निम्नतम पहचाने चेहरे",
|
||||
"machine_learning_min_recognized_faces_description": "किसी व्यक्ति के लिए पहचाने जाने वाले चेहरों की न्यूनतम संख्या।",
|
||||
"machine_learning_ocr": "ओ.सी.आर",
|
||||
"machine_learning_ocr_description": "चित्रों में पाठ को पहचानने के लिए मशीन लर्निंग का उपयोग करें",
|
||||
"machine_learning_ocr_enabled": "ओ.सी.आर. सक्षम करें",
|
||||
"machine_learning_ocr_enabled_description": "यदि अक्षम किया गया है, तो चित्रों पर पाठ-पहचान नहीं होगा।",
|
||||
"machine_learning_ocr_max_resolution": "अधिकतम रिज़ॉल्यूशन",
|
||||
"machine_learning_ocr_max_resolution_description": "इस रिज़ॉल्यूशन से ऊपर के प्रदर्शन का आकार मूल अनुपात को संरक्षित करते हुए बदल दिया जाएगा। उच्च मान अधिक सटीक होते हैं, लेकिन संसाधित होने में अधिक मेमोरी और समय लगाते हैं।",
|
||||
"machine_learning_ocr_min_detection_score": "न्यूनतम खोज अंक",
|
||||
"machine_learning_ocr_min_detection_score_description": "पाठ का पता लगाने के लिए 0-1 के बीच न्यूनतम आत्मविश्वास अंक। कम अंक अधिक पाठ का पता लगाएंगे लेकिन परिणाम गलत हो सकते हैं।",
|
||||
"machine_learning_ocr_min_recognition_score": "न्यूनतम पहचान अंक",
|
||||
"machine_learning_ocr_min_score_recognition_description": "पाठ को पहचानने के लिए 0-1 के बीच न्यूनतम आत्मविश्वास अंक। कम अंक अधिक पाठ को पहचानेंगे लेकिन परिणाम गलत हो सकते हैं।",
|
||||
"machine_learning_ocr_model": "ओसीआर प्रतिमान",
|
||||
"machine_learning_ocr_model_description": "सर्वर प्रतिमान मोबाइल प्रतिमान की तुलना में अधिक सटीक होते हैं, लेकिन संसाधित होने में अधिक मेमोरी और समय लेते हैं।",
|
||||
"machine_learning_settings": "मशीन लर्निंग सेटिंग्स",
|
||||
"machine_learning_settings_description": "मशीन लर्निंग सुविधाओं और सेटिंग्स को प्रबंधित करें",
|
||||
"machine_learning_smart_search": "स्मार्ट खोज",
|
||||
@@ -203,6 +223,8 @@
|
||||
"notification_email_ignore_certificate_errors_description": "टीएलएस प्रमाणपत्र सत्यापन त्रुटियों पर ध्यान न दें (अनुशंसित नहीं)",
|
||||
"notification_email_password_description": "ईमेल सर्वर से प्रमाणीकरण करते समय उपयोग किया जाने वाला पासवर्ड",
|
||||
"notification_email_port_description": "ईमेल सर्वर का पोर्ट (जैसे 25, 465, या 587)",
|
||||
"notification_email_secure": "एस एम टी पी एस",
|
||||
"notification_email_secure_description": "एस.एम.टी.पी.एस. प्रयोग करें (टी.एल.एस पर एस.एम.टी.पी)",
|
||||
"notification_email_sent_test_email_button": "परीक्षण ईमेल भेजें और सहेजें",
|
||||
"notification_email_setting_description": "ईमेल सूचनाएं भेजने के लिए सेटिंग्स",
|
||||
"notification_email_test_email": "परीक्षण ईमेल भेजें",
|
||||
@@ -235,6 +257,7 @@
|
||||
"oauth_storage_quota_default_description": "GiB में कोटा का उपयोग तब किया जाएगा जब कोई दावा प्रदान नहीं किया गया हो ।",
|
||||
"oauth_timeout": "ब्रेक का अनुरोध",
|
||||
"oauth_timeout_description": "अनुरोधों के लिए समय-सीमा मिलीसेकंड में",
|
||||
"ocr_job_description": "चित्रों में पाठ को पहचानने के लिए मशीन लर्निंग का उपयोग करें",
|
||||
"password_enable_description": "ईमेल और पासवर्ड से लॉगिन करें",
|
||||
"password_settings": "पासवर्ड लॉग इन",
|
||||
"password_settings_description": "पासवर्ड लॉगिन सेटिंग प्रबंधित करें",
|
||||
@@ -325,7 +348,7 @@
|
||||
"transcoding_max_b_frames": "अधिकतम बी-फ्रेम",
|
||||
"transcoding_max_b_frames_description": "उच्च मान संपीड़न दक्षता में सुधार करते हैं, लेकिन एन्कोडिंग को धीमा कर देते हैं।",
|
||||
"transcoding_max_bitrate": "अधिकतम बिटरेट",
|
||||
"transcoding_max_bitrate_description": "अधिकतम बिटरेट सेट करने से फ़ाइल आकार को गुणवत्ता पर मामूली लागत के साथ अधिक पूर्वानुमानित किया जा सकता है। 720p पर, सामान्य मान VP9 या HEVC के लिए 2600k kbit/s या H.264 के लिए 4500k kbit/s हैं। 0 पर सेट होने पर अक्षम।",
|
||||
"transcoding_max_bitrate_description": "अधिकतम बिटरेट सेट करने से फ़ाइल आकार को गुणवत्ता पर मामूली लागत के साथ अधिक पूर्वानुमानित किया जा सकता है। 720p पर, सामान्य मान VP9 या HEVC के लिए 2600k kbit/s या H.264 के लिए 4500k kbit/s हैं। 0 पर सेट होने पर अक्षम। जब कोई इकाई निर्दिष्ट नहीं की जाती है, तो k (kbit/s के लिए) मान लिया जाता है; इसलिए 5000, 5000k, और 5M (Mbit/s के लिए) समतुल्य हैं।",
|
||||
"transcoding_max_keyframe_interval": "अधिकतम मुख्यफ़्रेम अंतराल",
|
||||
"transcoding_max_keyframe_interval_description": "मुख्यफ़्रेम के बीच अधिकतम फ़्रेम दूरी निर्धारित करता है।",
|
||||
"transcoding_optimal_description": "लक्ष्य रिज़ॉल्यूशन से अधिक ऊंचे वीडियो या स्वीकृत प्रारूप में नहीं",
|
||||
@@ -359,6 +382,9 @@
|
||||
"trash_number_of_days_description": "संपत्तियों को स्थायी रूप से हटाने से पहले उन्हें कूड़ेदान में रखने के लिए दिनों की संख्या",
|
||||
"trash_settings": "ट्रैश सेटिंग",
|
||||
"trash_settings_description": "ट्रैश सेटिंग प्रबंधित करें",
|
||||
"unlink_all_oauth_accounts": "सभी ओ.औथ खातों से संपर्क तोड़ दें",
|
||||
"unlink_all_oauth_accounts_description": "नए प्रदाता पर सतानांतरण करने से पहले सभी ओ.औथ खातों से संपर्क तोड़ना याद रखें।",
|
||||
"unlink_all_oauth_accounts_prompt": "क्या आप वाकई सभी ओ.औथ खातों से संपर्क तोड़ना चाहते हैं? इससे प्रत्येक उपयोगकर्ता के लिए ओ.औथ आई.डी रद्द हो जाएगी और इसे पूर्ववत नहीं किया जा सकेगा।",
|
||||
"user_cleanup_job": "उपयोगकर्ता सफ़ाई",
|
||||
"user_delete_delay": "<b>{user}</b> के खाते और परिसंपत्तियों को {delay, plural, one {# day} other {# days}} में स्थायी रूप से हटाने के लिए शेड्यूल किया जाएगा।",
|
||||
"user_delete_delay_settings": "हटाने में देरी",
|
||||
@@ -392,6 +418,8 @@
|
||||
"advanced_settings_prefer_remote_title": "दूरस्थ छवियों को प्राथमिकता दें",
|
||||
"advanced_settings_proxy_headers_subtitle": "प्रत्येक नेटवर्क अनुरोध के साथ इम्मिच द्वारा भेजे जाने वाले प्रॉक्सी हेडर को परिभाषित करें",
|
||||
"advanced_settings_proxy_headers_title": "प्रॉक्सी हेडर",
|
||||
"advanced_settings_readonly_mode_subtitle": "रीड-ओनली प्रणाली को सक्षम करता है जहां चित्र को केवल देखा जा सकता है, एकाधिक चित्रों का चयन करना, साझा करना, कास्टिंग करना, हटाना जैसी सभी चीज़ें अक्षम हैं। मुख्य स्क्रीन में उपयोगकर्ता- अवतार के माध्यम से रीड-ओनली प्रणाली को सक्षम/अक्षम करें",
|
||||
"advanced_settings_readonly_mode_title": "रीड-ओनली प्रणाली",
|
||||
"advanced_settings_self_signed_ssl_subtitle": "सर्वर एंडपॉइंट के लिए SSL प्रमाणपत्र सत्यापन को छोड़ देता है। स्व-हस्ताक्षरित प्रमाणपत्रों के लिए आवश्यक है।",
|
||||
"advanced_settings_self_signed_ssl_title": "स्व-हस्ताक्षरित SSL प्रमाणपत्रों की अनुमति दें",
|
||||
"advanced_settings_sync_remote_deletions_subtitle": "वेब पर कार्रवाई किए जाने पर इस डिवाइस पर किसी संपत्ति को स्वचालित रूप से हटाएँ या पुनर्स्थापित करें",
|
||||
@@ -419,6 +447,7 @@
|
||||
"album_remove_user_confirmation": "क्या आप वाकई {user} को हटाना चाहते हैं?",
|
||||
"album_search_not_found": "आपकी खोज से मेल खाता कोई एल्बम नहीं मिला",
|
||||
"album_share_no_users": "ऐसा लगता है कि आपने यह एल्बम सभी उपयोगकर्ताओं के साथ साझा कर दिया है या आपके पास साझा करने के लिए कोई उपयोगकर्ता नहीं है।",
|
||||
"album_summary": "एल्बम सारांश",
|
||||
"album_updated": "एल्बम अपडेट किया गया",
|
||||
"album_updated_setting_description": "जब किसी साझा एल्बम में नई संपत्तियाँ हों तो एक ईमेल सूचना प्राप्त करें",
|
||||
"album_user_left": "बायाँ {album}",
|
||||
@@ -452,11 +481,16 @@
|
||||
"api_key_description": "यह की केवल एक बार दिखाई जाएगी। विंडो बंद करने से पहले कृपया इसे कॉपी करना सुनिश्चित करें।।",
|
||||
"api_key_empty": "आपका एपीआई कुंजी नाम खाली नहीं होना चाहिए",
|
||||
"api_keys": "एपीआई कीज",
|
||||
"app_architecture_variant": "रूपान्तर (स्थापत्य/आर्किटेक्चर)",
|
||||
"app_bar_signout_dialog_content": "क्या आप सुनिश्चित हैं कि आप लॉग आउट करना चाहते हैं?",
|
||||
"app_bar_signout_dialog_ok": "हाँ",
|
||||
"app_bar_signout_dialog_title": "लॉग आउट",
|
||||
"app_download_links": "ऐप डाउनलोड लिंक",
|
||||
"app_settings": "एप्लिकेशन सेटिंग",
|
||||
"app_stores": "ऐप स्टोर/गोदाम",
|
||||
"app_update_available": "आधुनिक ऐप उपलब्ध है",
|
||||
"appears_in": "प्रकट होता है",
|
||||
"apply_count": "लागू करें ({count, number})",
|
||||
"archive": "संग्रहालय",
|
||||
"archive_action_prompt": "{count} को संग्रह में जोड़ा गया",
|
||||
"archive_or_unarchive_photo": "फ़ोटो को संग्रहीत या असंग्रहीत करें",
|
||||
@@ -465,17 +499,17 @@
|
||||
"archive_size": "पुरालेख आकार",
|
||||
"archive_size_description": "डाउनलोड के लिए संग्रह आकार कॉन्फ़िगर करें (GiB में)",
|
||||
"archived": "संग्रहित",
|
||||
"archived_count": "{count, plural, other {# संग्रहीत किए गए}",
|
||||
"archived_count": "{count, plural, other {# संग्रहीत किए गए}}",
|
||||
"are_these_the_same_person": "क्या ये वही व्यक्ति हैं?",
|
||||
"are_you_sure_to_do_this": "क्या आप वास्तव में इसे करना चाहते हैं?",
|
||||
"asset_action_delete_err_read_only": "केवल पढ़ने योग्य परिसंपत्ति(ओं) को हटाया नहीं जा सकता, छोड़ा जा सकता है",
|
||||
"asset_action_share_err_offline": "ऑफ़लाइन परिसंपत्ति(एँ) प्राप्त नहीं की जा सकती, छोड़ी जा रही है",
|
||||
"asset_added_to_album": "एल्बम में डाला गया",
|
||||
"asset_adding_to_album": "एल्बम में डाला जा रहा है..।",
|
||||
"asset_adding_to_album": "एल्बम में डाला जा रहा है…",
|
||||
"asset_description_updated": "संपत्ति विवरण अद्यतन कर दिया गया है",
|
||||
"asset_filename_is_offline": "एसेट {filename} ऑफ़लाइन है",
|
||||
"asset_has_unassigned_faces": "एसेट में अनिर्धारित चेहरे हैं",
|
||||
"asset_hashing": "हैशिंग...।",
|
||||
"asset_hashing": "हैशिंग…",
|
||||
"asset_list_group_by_sub_title": "द्वारा समूह बनाएं",
|
||||
"asset_list_layout_settings_dynamic_layout_title": "गतिशील लेआउट",
|
||||
"asset_list_layout_settings_group_automatically": "स्वचालित",
|
||||
@@ -489,6 +523,8 @@
|
||||
"asset_restored_successfully": "संपत्ति(याँ) सफलतापूर्वक पुनर्स्थापित की गईं",
|
||||
"asset_skipped": "छोड़ा गया",
|
||||
"asset_skipped_in_trash": "कचरे में",
|
||||
"asset_trashed": "एसेट नष्ट किया गया",
|
||||
"asset_troubleshoot": "एसेट समस्या निवारण",
|
||||
"asset_uploaded": "अपलोड किए गए",
|
||||
"asset_uploading": "अपलोड हो रहा है…",
|
||||
"asset_viewer_settings_subtitle": "अपनी गैलरी व्यूअर सेटिंग प्रबंधित करें",
|
||||
@@ -496,7 +532,9 @@
|
||||
"assets": "संपत्तियां",
|
||||
"assets_added_count": "{count, plural, one {# asset} other {# assets}} जोड़ा गया",
|
||||
"assets_added_to_album_count": "एल्बम में {count, plural, one {# asset} other {# assets}} जोड़ा गया",
|
||||
"assets_added_to_albums_count": "{assetTotal, plural, one {# asset} other {# assets}} को {albumTotal, plural, one {# album} other {# albums}} से जोड़ा गया",
|
||||
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} को एल्बम में नहीं जोड़ा जा सकता",
|
||||
"assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} किसी एल्बम से नहीं जोड़े जा सकते",
|
||||
"assets_count": "{count, plural, one {# आइटम} other {# आइटम्स}}",
|
||||
"assets_deleted_permanently": "{count} संपत्ति(याँ) स्थायी रूप से हटा दी गईं",
|
||||
"assets_deleted_permanently_from_server": "{count} संपत्ति(याँ) इमिच सर्वर से स्थायी रूप से हटा दी गईं",
|
||||
@@ -513,14 +551,17 @@
|
||||
"assets_trashed_count": "ट्रैश की गई {count, plural, one {# asset} other {# assets}}",
|
||||
"assets_trashed_from_server": "{count} संपत्ति(याँ) इमिच सर्वर से कचरे में डाली गईं",
|
||||
"assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}}एल्बम का पहले से ही हिस्सा थे",
|
||||
"assets_were_part_of_albums_count": "{count, plural, one {Asset was} other {Assets were}} पहले ही एल्बम में संयोजित हैं",
|
||||
"authorized_devices": "अधिकृत उपकरण",
|
||||
"automatic_endpoint_switching_subtitle": "उपलब्ध होने पर निर्दिष्ट वाई-फाई से स्थानीय रूप से कनेक्ट करें और अन्यत्र वैकल्पिक कनेक्शन का उपयोग करें",
|
||||
"automatic_endpoint_switching_title": "स्वचालित URL स्विचिंग",
|
||||
"autoplay_slideshow": "ऑटोप्ले स्लाइड शो",
|
||||
"back": "वापस",
|
||||
"back_close_deselect": "वापस जाएँ, बंद करें, या अचयनित करें",
|
||||
"background_backup_running_error": "परिप्रेक्ष्य बैकअप अभी जारी है, नियमावली बैकअप प्रारंभ नहीं किया जा सकता",
|
||||
"background_location_permission": "पृष्ठभूमि स्थान अनुमति",
|
||||
"background_location_permission_content": "पृष्ठभूमि में चलते समय नेटवर्क बदलने के लिए, Immich के पास *हमेशा* सटीक स्थान तक पहुंच होनी चाहिए ताकि ऐप वाई-फाई नेटवर्क का नाम पढ़ सके",
|
||||
"background_options": "परिप्रेक्ष्य विकल्प",
|
||||
"backup": "बैकअप",
|
||||
"backup_album_selection_page_albums_device": "डिवाइस पर एल्बम ({count})",
|
||||
"backup_album_selection_page_albums_tap": "शामिल करने के लिए टैप करें, बाहर करने के लिए डबल टैप करें",
|
||||
@@ -528,8 +569,10 @@
|
||||
"backup_album_selection_page_select_albums": "एल्बम चुनें",
|
||||
"backup_album_selection_page_selection_info": "चयन जानकारी",
|
||||
"backup_album_selection_page_total_assets": "कुल अद्वितीय संपत्तियाँ",
|
||||
"backup_albums_sync": "बैकअप एल्बम का तुल्यकालन",
|
||||
"backup_all": "सभी",
|
||||
"backup_background_service_backup_failed_message": "संपत्तियों का बैकअप लेने में विफल. पुनः प्रयास किया जा रहा है…",
|
||||
"backup_background_service_complete_notification": "एसेट का बैकअप पूरा हुआ",
|
||||
"backup_background_service_connection_failed_message": "सर्वर से कनेक्ट करने में विफल. पुनः प्रयास किया जा रहा है…",
|
||||
"backup_background_service_current_upload_notification": "{filename} अपलोड हो रहा है",
|
||||
"backup_background_service_default_notification": "नई परिसंपत्तियों की जांच की जा रही है…",
|
||||
@@ -577,6 +620,7 @@
|
||||
"backup_controller_page_turn_on": "अग्रभूमि बैकअप चालू करें",
|
||||
"backup_controller_page_uploading_file_info": "फ़ाइल जानकारी अपलोड करना",
|
||||
"backup_err_only_album": "एकमात्र एल्बम नहीं हटाया जा सकता",
|
||||
"backup_error_sync_failed": "तुल्यकालन विफल. बैकअप संसाधित नहीं किया जा सकता।",
|
||||
"backup_info_card_assets": "संपत्ति",
|
||||
"backup_manual_cancelled": "रद्द",
|
||||
"backup_manual_in_progress": "अपलोड पहले से ही प्रगति पर है। कुछ देर बाद प्रयास करें",
|
||||
@@ -638,12 +682,16 @@
|
||||
"change_password_description": "यह या तो पहली बार है जब आप सिस्टम में साइन इन कर रहे हैं या आपका पासवर्ड बदलने का अनुरोध किया गया है।",
|
||||
"change_password_form_confirm_password": "पासवर्ड की पुष्टि कीजिये",
|
||||
"change_password_form_description": "नमस्ते {name},\n\nया तो आप पहली बार सिस्टम में साइन इन कर रहे हैं या फिर आपका पासवर्ड बदलने का अनुरोध किया गया है। कृपया नीचे नया पासवर्ड डालें।",
|
||||
"change_password_form_log_out": "अन्य सभी डिवाइस को लॉग आउट करें",
|
||||
"change_password_form_log_out_description": "अन्य सभी डिवाइस से लॉग आउट करना अनुशंसित है",
|
||||
"change_password_form_new_password": "नया पासवर्ड",
|
||||
"change_password_form_password_mismatch": "सांकेतिक शब्द मेल नहीं खाते",
|
||||
"change_password_form_reenter_new_password": "नया पासवर्ड पुनः दर्ज करें",
|
||||
"change_pin_code": "पिन कोड बदलें",
|
||||
"change_your_password": "अपना पासवर्ड बदलें",
|
||||
"changed_visibility_successfully": "दृश्यता सफलतापूर्वक परिवर्तित",
|
||||
"charging": "चार्जिंग",
|
||||
"charging_requirement_mobile_backup": "परिप्रेक्ष्य बैकअप के लिए डिवाइस का चार्जिंग पे लगे होना आवश्यक है",
|
||||
"check_corrupt_asset_backup": "दूषित परिसंपत्ति बैकअप की जाँच करें",
|
||||
"check_corrupt_asset_backup_button": "जाँच करें",
|
||||
"check_corrupt_asset_backup_description": "यह जाँच केवल वाई-फ़ाई पर ही करें और सभी संपत्तियों का बैकअप लेने के बाद ही करें। इस प्रक्रिया में कुछ मिनट लग सकते हैं।",
|
||||
@@ -665,7 +713,7 @@
|
||||
"client_cert_subtitle": "केवल PKCS12 (.p12, .pfx) फ़ॉर्मैट का समर्थन करता है। प्रमाणपत्र आयात/हटाएँ केवल लॉगिन से पहले उपलब्ध हैं",
|
||||
"client_cert_title": "SSL क्लाइंट प्रमाणपत्र",
|
||||
"clockwise": "दक्षिणावर्त",
|
||||
"close": "बंद",
|
||||
"close": "बंद करें",
|
||||
"collapse": "गिर जाना",
|
||||
"collapse_all": "सभी को संकुचित करें",
|
||||
"color": "रंग",
|
||||
@@ -675,8 +723,8 @@
|
||||
"comments_and_likes": "टिप्पणियाँ और पसंद",
|
||||
"comments_are_disabled": "टिप्पणियाँ अक्षम हैं",
|
||||
"common_create_new_album": "नया एल्बम बनाएँ",
|
||||
"completed": "पुरा होना",
|
||||
"confirm": "पुष्टि",
|
||||
"completed": "पूरित",
|
||||
"confirm": "पुष्टि करें",
|
||||
"confirm_admin_password": "एडमिन पासवर्ड की पुष्टि करें",
|
||||
"confirm_delete_face": "क्या आप वाकई एसेट से {name} चेहरा हटाना चाहते हैं?",
|
||||
"confirm_delete_shared_link": "क्या आप वाकई इस साझा लिंक को हटाना चाहते हैं?",
|
||||
@@ -685,13 +733,13 @@
|
||||
"confirm_password": "पासवर्ड की पुष्टि कीजिये",
|
||||
"confirm_tag_face": "क्या आप इस चेहरे को {name} के रूप में टैग करना चाहते हैं?",
|
||||
"confirm_tag_face_unnamed": "क्या आप इस चेहरे को टैग करना चाहते हैं?",
|
||||
"connected_device": "कनेक्टेड डिवाइस",
|
||||
"connected_device": "योजित यंत्र",
|
||||
"connected_to": "से जुड़ा",
|
||||
"contain": "समाहित",
|
||||
"context": "संदर्भ",
|
||||
"continue": "जारी",
|
||||
"control_bottom_app_bar_create_new_album": "नया एल्बम बनाएँ",
|
||||
"control_bottom_app_bar_delete_from_immich": "Immich से हटाएं",
|
||||
"control_bottom_app_bar_delete_from_immich": "इम्मिच से हटाएं",
|
||||
"control_bottom_app_bar_delete_from_local": "डिवाइस से हटाएं",
|
||||
"control_bottom_app_bar_edit_location": "स्थान संपादित करें",
|
||||
"control_bottom_app_bar_edit_time": "तारीख और समय संपादित करें",
|
||||
@@ -713,6 +761,7 @@
|
||||
"create": "तैयार करें",
|
||||
"create_album": "एल्बम बनाओ",
|
||||
"create_album_page_untitled": "शीर्षकहीन",
|
||||
"create_api_key": "ऐ.पी.आई. चाभी बनाएं",
|
||||
"create_library": "लाइब्रेरी बनाएं",
|
||||
"create_link": "लिंक बनाएं",
|
||||
"create_link_to_share": "शेयर करने के लिए लिंक बनाएं",
|
||||
@@ -729,6 +778,7 @@
|
||||
"create_user": "उपयोगकर्ता बनाइये",
|
||||
"created": "बनाया",
|
||||
"created_at": "बनाया था",
|
||||
"creating_linked_albums": "जुड़े हुए एल्बम बनाए जा रहे हैं..।",
|
||||
"crop": "छाँटें",
|
||||
"curated_object_page_title": "चीज़ें",
|
||||
"current_device": "वर्तमान उपकरण",
|
||||
@@ -741,6 +791,7 @@
|
||||
"daily_title_text_date_year": "ई, एमएमएम दिन, वर्ष",
|
||||
"dark": "डार्क",
|
||||
"dark_theme": "डार्क थीम टॉगल करें",
|
||||
"date": "दिनांक",
|
||||
"date_after": "इसके बाद की तारीख",
|
||||
"date_and_time": "तिथि और समय",
|
||||
"date_before": "पहले की तारीख",
|
||||
@@ -748,6 +799,7 @@
|
||||
"date_of_birth_saved": "जन्मतिथि सफलतापूर्वक सहेजी गई",
|
||||
"date_range": "तिथि सीमा",
|
||||
"day": "दिन",
|
||||
"days": "दिन",
|
||||
"deduplicate_all": "सभी को डुप्लिकेट करें",
|
||||
"deduplication_criteria_1": "छवि का आकार बाइट्स में",
|
||||
"deduplication_criteria_2": "EXIF डेटा की संख्या",
|
||||
@@ -836,6 +888,8 @@
|
||||
"edit_date": "संपादन की तारीख",
|
||||
"edit_date_and_time": "दिनांक और समय संपादित करें",
|
||||
"edit_date_and_time_action_prompt": "{count} तारीख और समय संपादित किए गए",
|
||||
"edit_date_and_time_by_offset": "अंकुर से दिनांक बदलें",
|
||||
"edit_date_and_time_by_offset_interval": "नयी दिनांक सीमा: {from} - {to}",
|
||||
"edit_description": "संपादित करें वर्णन",
|
||||
"edit_description_prompt": "कृपया एक नया विवरण चुनें:",
|
||||
"edit_exclusion_pattern": "बहिष्करण पैटर्न संपादित करें",
|
||||
@@ -874,7 +928,9 @@
|
||||
"error": "गलती",
|
||||
"error_change_sort_album": "एल्बम का क्रम बदलने में असफल रहा",
|
||||
"error_delete_face": "एसेट से चेहरे को हटाने में त्रुटि हुई",
|
||||
"error_getting_places": "स्थानों को प्राप्त करने में त्रुटि हुई",
|
||||
"error_loading_image": "छवि लोड करने में त्रुटि",
|
||||
"error_loading_partners": "जोड़ीदार लोड करने में त्रुटि हुई: {error}",
|
||||
"error_saving_image": "त्रुटि: {error}",
|
||||
"error_tag_face_bounding_box": "चेहरे को टैग करने में त्रुटि – बाउंडिंग बॉक्स निर्देशांक प्राप्त नहीं कर सके",
|
||||
"error_title": "त्रुटि - कुछ गलत हो गया",
|
||||
@@ -907,6 +963,7 @@
|
||||
"failed_to_load_notifications": "सूचनाएँ लोड करने में विफल",
|
||||
"failed_to_load_people": "लोगों को लोड करने में विफल",
|
||||
"failed_to_remove_product_key": "उत्पाद कुंजी निकालने में विफल",
|
||||
"failed_to_reset_pin_code": "पिन कोड रीसेट करना विफल हुआ",
|
||||
"failed_to_stack_assets": "परिसंपत्तियों का ढेर लगाने में विफल",
|
||||
"failed_to_unstack_assets": "परिसंपत्तियों का ढेर खोलने में विफल",
|
||||
"failed_to_update_notification_status": "सूचना की स्थिति अपडेट करने में विफल",
|
||||
@@ -915,6 +972,7 @@
|
||||
"paths_validation_failed": "{paths, plural, one {# पथ} other {# पथ}} सत्यापन में विफल रहे",
|
||||
"profile_picture_transparent_pixels": "प्रोफ़ाइल चित्रों में पारदर्शी पिक्सेल नहीं हो सकते।",
|
||||
"quota_higher_than_disk_size": "आपने डिस्क आकार से अधिक कोटा निर्धारित किया है",
|
||||
"something_went_wrong": "कुछ त्रुटि हुई",
|
||||
"unable_to_add_album_users": "उपयोगकर्ताओं को एल्बम में डालने में असमर्थ",
|
||||
"unable_to_add_assets_to_shared_link": "साझा लिंक में संपत्ति डालने में असमर्थ",
|
||||
"unable_to_add_comment": "टिप्पणी डालने में असमर्थ",
|
||||
@@ -1000,22 +1058,42 @@
|
||||
},
|
||||
"exif": "एक्सिफ",
|
||||
"exif_bottom_sheet_description": "विवरण जोड़ें..।",
|
||||
"exif_bottom_sheet_description_error": "विवरण के आधुनीकरण करने में त्रुटि हुई",
|
||||
"exif_bottom_sheet_details": "विवरण",
|
||||
"exif_bottom_sheet_location": "स्थान",
|
||||
"exif_bottom_sheet_no_description": "कोई विवरण नहीं",
|
||||
"exif_bottom_sheet_people": "लोग",
|
||||
"exif_bottom_sheet_person_add_person": "नाम डालें",
|
||||
"exit_slideshow": "स्लाइड शो से बाहर निकलें",
|
||||
"expand_all": "सभी का विस्तार",
|
||||
"experimental_settings_new_asset_list_subtitle": "कार्य प्रगति पर है",
|
||||
"experimental_settings_new_asset_list_title": "प्रयोगात्मक फोटो ग्रिड सक्षम करें",
|
||||
"experimental_settings_subtitle": "अपने जोखिम पर उपयोग करें!",
|
||||
"experimental_settings_title": "प्रयोगात्मक",
|
||||
"expire_after": "एक्सपायर आफ्टर",
|
||||
"expired": "खत्म हो चुका",
|
||||
"expires_date": "{date} को समाप्त हो रहा है",
|
||||
"explore": "अन्वेषण करना",
|
||||
"explorer": "समन्वेषक",
|
||||
"export": "निर्यात",
|
||||
"export_as_json": "JSON के रूप में निर्यात करें",
|
||||
"export_database": "डेटाबेस निर्यात करें",
|
||||
"export_database_description": "इस.क्यू.लाइट डेटाबेस निर्यात करें",
|
||||
"extension": "विस्तार",
|
||||
"external": "बाहरी",
|
||||
"external_libraries": "बाहरी पुस्तकालय",
|
||||
"external_network": "बाहरी नेटवर्क",
|
||||
"external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom",
|
||||
"face_unassigned": "सौंपे नहीं गए",
|
||||
"failed": "विफल हुआ",
|
||||
"failed_to_authenticate": "प्रमाणित करने में विफल",
|
||||
"failed_to_load_assets": "एसेट लोड करने में विफल",
|
||||
"failed_to_load_folder": "फोल्डर लोड करने में विफल",
|
||||
"favorite": "पसंदीदा",
|
||||
"favorite_action_prompt": "{count} पसंदीदा संकलन में जोड़े गए",
|
||||
"favorite_or_unfavorite_photo": "पसंदीदा या नापसंद फोटो",
|
||||
"favorites": "पसंदीदा",
|
||||
"favorites_page_no_favorites": "कोई पसंदीदा एसेट नहीं मिले",
|
||||
"feature_photo_updated": "फ़ीचर फ़ोटो अपडेट किया गया",
|
||||
"file_name": "फ़ाइल का नाम",
|
||||
"file_name_or_extension": "फ़ाइल का नाम या एक्सटेंशन",
|
||||
|
||||
@@ -1716,6 +1716,7 @@
|
||||
"running": "Kjører",
|
||||
"save": "Lagre",
|
||||
"save_to_gallery": "Lagre til galleriet",
|
||||
"saved": "Lagret",
|
||||
"saved_api_key": "Lagret API-nøkkel",
|
||||
"saved_profile": "Lagret profil",
|
||||
"saved_settings": "Lagret instillinger",
|
||||
@@ -1732,7 +1733,7 @@
|
||||
"search_by_description_example": "Turdag i Sapa",
|
||||
"search_by_filename": "Søk etter filnavn og filtype",
|
||||
"search_by_filename_example": "f.eks. IMG_1234.JPG eller PNG",
|
||||
"search_by_ocr": "Søk med OCR",
|
||||
"search_by_ocr": "Søk etter tekst i bilde",
|
||||
"search_by_ocr_example": "Latte",
|
||||
"search_camera_lens_model": "Søk etter objektivmodell...",
|
||||
"search_camera_make": "Søk etter kameramerke...",
|
||||
|
||||
@@ -1716,6 +1716,7 @@
|
||||
"running": "W trakcie",
|
||||
"save": "Zapisz",
|
||||
"save_to_gallery": "Zapisz w galerii",
|
||||
"saved": "Zapisano",
|
||||
"saved_api_key": "Zapisany klucz API",
|
||||
"saved_profile": "Zapisany profil",
|
||||
"saved_settings": "Zapisane ustawienia",
|
||||
|
||||
90
i18n/pt.json
90
i18n/pt.json
@@ -344,7 +344,7 @@
|
||||
"transcoding_hardware_acceleration": "Aceleração de hardware",
|
||||
"transcoding_hardware_acceleration_description": "Experimental; transcodificação mais rápida, mas poderá ter qualidade inferior com a mesma taxa de bits",
|
||||
"transcoding_hardware_decoding": "Decodificação de hardware",
|
||||
"transcoding_hardware_decoding_setting_description": "Permite a aceleração ponta a ponta em vez de apenas acelerar a codificação. Pode não funcionar em todos os formatos de arquivo.",
|
||||
"transcoding_hardware_decoding_setting_description": "Permite a aceleração ponta a ponta em vez de apenas acelerar a codificação. Pode não funcionar em todos os videos.",
|
||||
"transcoding_max_b_frames": "Máximo de quadros B",
|
||||
"transcoding_max_b_frames_description": "Valores mais altos melhoram a eficiência da compressão, mas tornam a codificação mais lenta. Pode não ser compatível com aceleração de hardware em dispositivos mais antigos. 0 desativa os quadros B, enquanto -1 define esse valor automaticamente.",
|
||||
"transcoding_max_bitrate": "Taxa de bits máxima",
|
||||
@@ -455,7 +455,7 @@
|
||||
"album_viewer_appbar_delete_confirm": "Tem certeza que deseja excluir este álbum da sua conta?",
|
||||
"album_viewer_appbar_share_err_delete": "Ocorreu um erro ao eliminar álbum",
|
||||
"album_viewer_appbar_share_err_leave": "Ocorreu um erro ao sair do álbum",
|
||||
"album_viewer_appbar_share_err_remove": "Houveram problemas ao remover arquivos do álbum",
|
||||
"album_viewer_appbar_share_err_remove": "Ocorreu um erro ao remover ficheiros do álbum",
|
||||
"album_viewer_appbar_share_err_title": "Ocorreu um erro ao alterar o título do álbum",
|
||||
"album_viewer_appbar_share_leave": "Deixar álbum",
|
||||
"album_viewer_appbar_share_to": "Compartilhar com",
|
||||
@@ -494,7 +494,7 @@
|
||||
"archive": "Arquivo",
|
||||
"archive_action_prompt": "{count} adicionados ao Arquivo",
|
||||
"archive_or_unarchive_photo": "Arquivar ou desarquivar foto",
|
||||
"archive_page_no_archived_assets": "Nenhum arquivo encontrado",
|
||||
"archive_page_no_archived_assets": "Nenhum ficheiro arquivado encontrado",
|
||||
"archive_page_title": "Arquivo ({count})",
|
||||
"archive_size": "Tamanho do arquivo",
|
||||
"archive_size_description": "Configure o tamanho do arquivo para transferências (em GiB)",
|
||||
@@ -502,8 +502,8 @@
|
||||
"archived_count": "{count, plural, one {#Arquivado # item} other {Arquivados # itens}}",
|
||||
"are_these_the_same_person": "Estas pessoas são a mesma pessoa?",
|
||||
"are_you_sure_to_do_this": "Tem a certeza de que quer fazer isto?",
|
||||
"asset_action_delete_err_read_only": "Não é possível excluir arquivo só leitura, ignorando",
|
||||
"asset_action_share_err_offline": "Não foi possível obter os arquivos offline, ignorando",
|
||||
"asset_action_delete_err_read_only": "Não é possível eliminar ficheiro só de leitura, a ignorar",
|
||||
"asset_action_share_err_offline": "Não foi possível obter os ficheiros offline, a ignorar",
|
||||
"asset_added_to_album": "Adicionado ao álbum",
|
||||
"asset_adding_to_album": "A adicionar ao álbum…",
|
||||
"asset_description_updated": "A descrição do ficheiro foi atualizada",
|
||||
@@ -513,14 +513,14 @@
|
||||
"asset_list_group_by_sub_title": "Agrupar por",
|
||||
"asset_list_layout_settings_dynamic_layout_title": "Layout dinâmico",
|
||||
"asset_list_layout_settings_group_automatically": "Automático",
|
||||
"asset_list_layout_settings_group_by": "Agrupar arquivos por",
|
||||
"asset_list_layout_settings_group_by": "Agrupar ficheiros por",
|
||||
"asset_list_layout_settings_group_by_month_day": "Mês + dia",
|
||||
"asset_list_layout_sub_title": "Disposição",
|
||||
"asset_list_settings_subtitle": "Configurações de disposição da grade de fotos",
|
||||
"asset_list_settings_title": "Grade de fotos",
|
||||
"asset_offline": "Ficheiro Indisponível",
|
||||
"asset_offline_description": "Este ficheiro externo deixou de estar disponível no disco. Contacte o seu administrador do Immich para obter ajuda.",
|
||||
"asset_restored_successfully": "Arquivo restaurado com sucesso",
|
||||
"asset_restored_successfully": "FIcheiro restaurado com sucesso",
|
||||
"asset_skipped": "Ignorado",
|
||||
"asset_skipped_in_trash": "Na reciclagem",
|
||||
"asset_trashed": "Ficheiro apagado",
|
||||
@@ -565,19 +565,19 @@
|
||||
"backup": "Cópia de segurança",
|
||||
"backup_album_selection_page_albums_device": "Álbuns no dispositivo ({count})",
|
||||
"backup_album_selection_page_albums_tap": "Toque para incluir, duplo toque para excluir",
|
||||
"backup_album_selection_page_assets_scatter": "Os arquivos podem estar espalhados em vários álbuns. Assim, os álbuns podem ser incluídos ou excluídos durante o processo de backup.",
|
||||
"backup_album_selection_page_assets_scatter": "Os ficheros podem estar espalhados por vários álbuns. Desta forma, os álbuns podem ser incluídos ou excluídos durante o processo de cópia de segurança.",
|
||||
"backup_album_selection_page_select_albums": "Selecione Álbuns",
|
||||
"backup_album_selection_page_selection_info": "Informações da Seleção",
|
||||
"backup_album_selection_page_total_assets": "Total de arquivos únicos",
|
||||
"backup_album_selection_page_total_assets": "Total de ficheiros únicos",
|
||||
"backup_albums_sync": "Cópia de segurança de sincronização de álbuns",
|
||||
"backup_all": "Tudo",
|
||||
"backup_background_service_backup_failed_message": "Ocorreu um erro ao efetuar cópia de segurança dos ficheiros. A tentar de novo…",
|
||||
"backup_background_service_complete_notification": "Cópia de conteúdos concluída",
|
||||
"backup_background_service_connection_failed_message": "Ocorreu um erro na ligação ao servidor. A tentar de novo…",
|
||||
"backup_background_service_current_upload_notification": "A enviar {filename}",
|
||||
"backup_background_service_default_notification": "Verificando novos arquivos…",
|
||||
"backup_background_service_default_notification": "A verificar se há novos ficheiros…",
|
||||
"backup_background_service_error_title": "Erro de backup",
|
||||
"backup_background_service_in_progress_notification": "Fazendo backup dos arquivos…",
|
||||
"backup_background_service_in_progress_notification": "A fazer cópia de segurança dos seus ficheiros…",
|
||||
"backup_background_service_upload_failure_notification": "Ocorreu um erro ao enviar {filename}",
|
||||
"backup_controller_page_albums": "Backup Álbuns",
|
||||
"backup_controller_page_background_app_refresh_disabled_content": "Para utilizar a cópia de segurança em segundo plano, ative a atualização da aplicação em segundo plano em Definições > Geral > Atualização da aplicação em segundo plano.",
|
||||
@@ -600,7 +600,7 @@
|
||||
"backup_controller_page_backup_selected": "Selecionado: ",
|
||||
"backup_controller_page_backup_sub": "Fotos e vídeos salvos em backup",
|
||||
"backup_controller_page_created": "Criado em: {date}",
|
||||
"backup_controller_page_desc_backup": "Ative o backup para enviar automáticamente novos arquivos para o servidor.",
|
||||
"backup_controller_page_desc_backup": "Ative a cópia de segurança em primeiro plano para enviar novos ficheiros automaticamente ao abrir a aplicação.",
|
||||
"backup_controller_page_excluded": "Eliminado: ",
|
||||
"backup_controller_page_failed": "Falhou ({count})",
|
||||
"backup_controller_page_filename": "Nome do ficheiro: {filename} [{size}]",
|
||||
@@ -618,10 +618,10 @@
|
||||
"backup_controller_page_total_sub": "Todas as fotos e vídeos dos álbuns selecionados",
|
||||
"backup_controller_page_turn_off": "Desativar backup",
|
||||
"backup_controller_page_turn_on": "Ativar backup",
|
||||
"backup_controller_page_uploading_file_info": "Enviando arquivo",
|
||||
"backup_controller_page_uploading_file_info": "A enviar informações do ficheiro",
|
||||
"backup_err_only_album": "Não é possível remover apenas o álbum",
|
||||
"backup_error_sync_failed": "A sincronização falhou. Não é possível fazer a cópia de segurança.",
|
||||
"backup_info_card_assets": "arquivos",
|
||||
"backup_info_card_assets": "ficheiros",
|
||||
"backup_manual_cancelled": "Cancelado",
|
||||
"backup_manual_in_progress": "Envio já está em progresso. Tente novamente mais tarde",
|
||||
"backup_manual_success": "Sucesso",
|
||||
@@ -694,7 +694,7 @@
|
||||
"charging_requirement_mobile_backup": "Cópia de segurança de fundo necessita que o dispositivo esteja a carregar",
|
||||
"check_corrupt_asset_backup": "Verificar por backups corrompidos",
|
||||
"check_corrupt_asset_backup_button": "Verificar",
|
||||
"check_corrupt_asset_backup_description": "Execute esta verificação somente em uma rede Wi-Fi e quando o backup de todos os arquivos já estiver concluído. O processo demora alguns minutos.",
|
||||
"check_corrupt_asset_backup_description": "Execute esta verificação apenas numa rede Wi-Fi e quando a cópia de segurança de todos os ficheiros já estiver concluída. O processo pode demorar alguns minutos.",
|
||||
"check_logs": "Verificar registos",
|
||||
"choose_matching_people_to_merge": "Escolha pessoas correspondentes para unir",
|
||||
"city": "Cidade/Localidade",
|
||||
@@ -770,7 +770,7 @@
|
||||
"create_new_person": "Criar nova pessoa",
|
||||
"create_new_person_hint": "Associe os ficheiros a uma nova pessoa",
|
||||
"create_new_user": "Criar novo utilizador",
|
||||
"create_shared_album_page_share_add_assets": "ADICIONAR ARQUIVOS",
|
||||
"create_shared_album_page_share_add_assets": "ADICIONAR FICHEIROS",
|
||||
"create_shared_album_page_share_select_photos": "Selecionar Fotos",
|
||||
"create_shared_link": "Criar link partilhado",
|
||||
"create_tag": "Criar etiqueta",
|
||||
@@ -812,10 +812,10 @@
|
||||
"delete_action_prompt": "{count} eliminados",
|
||||
"delete_album": "Apagar álbum",
|
||||
"delete_api_key_prompt": "Tem a certeza de que deseja remover esta chave de API?",
|
||||
"delete_dialog_alert": "Esses arquivos serão permanentemente apagados do Immich e de seu dispositivo",
|
||||
"delete_dialog_alert_local": "Estes arquivos serão permanentemente excluídos do seu dispositivo, mas continuarão disponíveis no servidor Immich",
|
||||
"delete_dialog_alert_local_non_backed_up": "Não há backup de alguns dos arquivos no servidor e eles serão excluídos permanentemente do seu dispositivo",
|
||||
"delete_dialog_alert_remote": "Estes arquivos serão permanentemente excluídos do servidor Immich",
|
||||
"delete_dialog_alert": "Estes ficheiros serão eliminados permanentemente do Immich e do seu dispositivo",
|
||||
"delete_dialog_alert_local": "Estes ficheiros serão eliminados permanentemente do seu dispositivo, mas continuarão disponíveis no servidor Immich",
|
||||
"delete_dialog_alert_local_non_backed_up": "Alguns dos ficheiros não têm cópia de segurança no Immich e serão eliminados permanentemente do seu dispositivo",
|
||||
"delete_dialog_alert_remote": "Estes ficheiros serão eliminados permanentemente do servidor Immich",
|
||||
"delete_dialog_ok_force": "Confirmo que quero excluir",
|
||||
"delete_dialog_title": "Excluir Permanentemente",
|
||||
"delete_duplicates_confirmation": "Tem a certeza de que deseja eliminar permanentemente estes itens duplicados?",
|
||||
@@ -824,7 +824,7 @@
|
||||
"delete_library": "Eliminar Biblioteca",
|
||||
"delete_link": "Eliminar link",
|
||||
"delete_local_action_prompt": "{count} eliminados localmente",
|
||||
"delete_local_dialog_ok_backed_up_only": "Excluir apenas arquivos com backup",
|
||||
"delete_local_dialog_ok_backed_up_only": "Eliminar apenas ficheiros com cópia de segurança",
|
||||
"delete_local_dialog_ok_force": "Excluir mesmo assim",
|
||||
"delete_others": "Excluir outros",
|
||||
"delete_permanently": "Eliminar permanentemente",
|
||||
@@ -872,7 +872,7 @@
|
||||
"download_settings_description": "Gerir definições relacionadas com a transferência de ficheiros",
|
||||
"download_started": "Iniciando",
|
||||
"download_sucess": "Baixado com sucesso",
|
||||
"download_sucess_android": "O arquivo foi baixado na pasta DCIM/Immich",
|
||||
"download_sucess_android": "O ficheiro foi descarregado para a pasta DCIM/Immich",
|
||||
"download_waiting_to_retry": "Tentando novamente",
|
||||
"downloading": "A transferir",
|
||||
"downloading_asset_filename": "A transferir o ficheiro {filename}",
|
||||
@@ -1134,7 +1134,7 @@
|
||||
"group_owner": "Agrupar por dono",
|
||||
"group_places_by": "Agrupar lugares por...",
|
||||
"group_year": "Agrupar por ano",
|
||||
"haptic_feedback_switch": "Habilitar vibração",
|
||||
"haptic_feedback_switch": "Ativar vibração",
|
||||
"haptic_feedback_title": "Vibração",
|
||||
"has_quota": "Tem quota",
|
||||
"hash_asset": "Criptografar ficheiro",
|
||||
@@ -1154,20 +1154,20 @@
|
||||
"hide_unnamed_people": "Ocultar pessoas sem nome",
|
||||
"home_page_add_to_album_conflicts": "Foram adicionados {added} ficheiros ao álbum {album}. {failed} ficheiros já estão no álbum.",
|
||||
"home_page_add_to_album_err_local": "Ainda não é possível adicionar recursos locais aos álbuns, ignorando",
|
||||
"home_page_add_to_album_success": "Adicionado {added} arquivos ao álbum {album}.",
|
||||
"home_page_album_err_partner": "Ainda não é possível adicionar arquivos do parceiro a um álbum, ignorando",
|
||||
"home_page_add_to_album_success": "{added} ficheiros foram adicionados ao álbum {album}.",
|
||||
"home_page_album_err_partner": "Ainda não é possível adicionar ficheiros do parceiro a um álbum, a ignorar",
|
||||
"home_page_archive_err_local": "Ainda não é possível arquivar recursos locais, ignorando",
|
||||
"home_page_archive_err_partner": "Não é possível arquivar Fotos e Videos do parceiro, ignorando",
|
||||
"home_page_building_timeline": "Construindo a linha do tempo",
|
||||
"home_page_delete_err_partner": "Não é possível excluir arquivos do parceiro, ignorando",
|
||||
"home_page_delete_remote_err_local": "Foram selecionados arquivos locais para excluir remotamente, ignorando",
|
||||
"home_page_delete_err_partner": "Não é possível eliminar ficheiros do parceiro, a ignorar",
|
||||
"home_page_delete_remote_err_local": "Foram selecionados ficheiros locais para excluir remotamente, a ignorar",
|
||||
"home_page_favorite_err_local": "Ainda não é possível adicionar recursos locais favoritos, ignorando",
|
||||
"home_page_favorite_err_partner": "Ainda não é possível marcar arquivos do parceiro como favoritos, ignorando",
|
||||
"home_page_favorite_err_partner": "Ainda não é possível marcar ficheiros do parceiro como favoritos, a ignorar",
|
||||
"home_page_first_time_notice": "Se é a primeira vez que utiliza a aplicação, certifique-se de que marca pelo menos um álbum do dispositivo para cópia de segurança, para a linha do tempo poder ser preenchida com fotos e vídeos",
|
||||
"home_page_locked_error_local": "Não foi possível mover ficheiros locais para a pasta trancada, a continuar",
|
||||
"home_page_locked_error_partner": "Não foi possível mover ficheiros do parceiro para a pasta trancada, a continuar",
|
||||
"home_page_share_err_local": "Não é possível compartilhar arquivos locais com um link, ignorando",
|
||||
"home_page_upload_err_limit": "Só é possível enviar 30 arquivos por vez, ignorando",
|
||||
"home_page_share_err_local": "Não é possível partilhar ficheiros locais com um link, a ignorar",
|
||||
"home_page_upload_err_limit": "Só é possível enviar um máximo de 30 ficheiros de cada vez, a ignorar",
|
||||
"host": "Servidor",
|
||||
"hour": "Hora",
|
||||
"hours": "Horas",
|
||||
@@ -1187,7 +1187,7 @@
|
||||
"image_alt_text_date_place_3_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e {person3} em {date}",
|
||||
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e outras {additionalCount, number} pessoas em {date}",
|
||||
"image_saved_successfully": "Imagem salva",
|
||||
"image_viewer_page_state_provider_download_started": "Baixando arquivo",
|
||||
"image_viewer_page_state_provider_download_started": "A descarregar ficheiro",
|
||||
"image_viewer_page_state_provider_download_success": "Baixado com sucesso",
|
||||
"image_viewer_page_state_provider_share_error": "Erro ao compartilhar",
|
||||
"immich_logo": "Logotipo do Immich",
|
||||
@@ -1244,7 +1244,7 @@
|
||||
"library_options": "Opções da biblioteca",
|
||||
"library_page_device_albums": "Álbuns no dispositivo",
|
||||
"library_page_new_album": "Novo álbum",
|
||||
"library_page_sort_asset_count": "Quantidade de arquivos",
|
||||
"library_page_sort_asset_count": "Quantidade de ficheiros",
|
||||
"library_page_sort_created": "Data de criação",
|
||||
"library_page_sort_last_modified": "Última modificação",
|
||||
"library_page_sort_title": "Título do álbum",
|
||||
@@ -1383,8 +1383,8 @@
|
||||
"moved_to_archive": "{count, plural, one {Foi movido # ficheiro} other {Foram movidos # ficheiros}} para o arquivo",
|
||||
"moved_to_library": "{count, plural, one {Foi movido # ficheiro} other {Foram movidos # ficheiros}} para a biblioteca",
|
||||
"moved_to_trash": "Enviado para a reciclagem",
|
||||
"multiselect_grid_edit_date_time_err_read_only": "Não é possível editar a data de arquivo só leitura, ignorando",
|
||||
"multiselect_grid_edit_gps_err_read_only": "Não é possível editar a localização de arquivo só leitura, ignorando",
|
||||
"multiselect_grid_edit_date_time_err_read_only": "Não é possível editar a data de um ficheiro só de leitura, a ignorar",
|
||||
"multiselect_grid_edit_gps_err_read_only": "Não é possível editar a localização de um ficheiro só de leitura, a ignorar",
|
||||
"mute_memories": "Silenciar Memórias",
|
||||
"my_albums": "Os meus álbuns",
|
||||
"name": "Nome",
|
||||
@@ -1417,7 +1417,7 @@
|
||||
"no_albums_yet": "Parece que ainda não tem nenhum álbum.",
|
||||
"no_archived_assets_message": "Arquive fotos e vídeos para os ocultar da sua visualização de fotos",
|
||||
"no_assets_message": "FAÇA CLIQUE PARA CARREGAR A SUA PRIMEIRA FOTO",
|
||||
"no_assets_to_show": "Não há arquivos para exibir",
|
||||
"no_assets_to_show": "Não há ficheiros para exibir",
|
||||
"no_cast_devices_found": "Nenhum dispositivo de transmissão encontrado",
|
||||
"no_checksum_local": "Sem cálculo de verificação disponível - não pode capturar conteúdos locais",
|
||||
"no_checksum_remote": "Soma de verificação (checksum) não disponível - não é possível obter o recurso remoto",
|
||||
@@ -1586,7 +1586,7 @@
|
||||
"public_album": "Álbum público",
|
||||
"public_share": "Partilhar Publicamente",
|
||||
"purchase_account_info": "Apoiante",
|
||||
"purchase_activated_subtitle": "Agradecemos por apoiar o Immich e software de código aberto",
|
||||
"purchase_activated_subtitle": "Agradecemos o seu apoio ao Immich e ao software de código aberto",
|
||||
"purchase_activated_time": "Ativado em {date}",
|
||||
"purchase_activated_title": "A sua chave foi ativada com sucesso",
|
||||
"purchase_button_activate": "Ativar",
|
||||
@@ -1747,7 +1747,7 @@
|
||||
"search_filter_date_title": "Selecione a data",
|
||||
"search_filter_display_option_not_in_album": "Fora de álbum",
|
||||
"search_filter_display_options": "Opções de exibição",
|
||||
"search_filter_filename": "Pesquisar por nome do arquivo",
|
||||
"search_filter_filename": "Pesquisar por nome do ficheiro",
|
||||
"search_filter_location": "Localização",
|
||||
"search_filter_location_title": "Selecione a localização",
|
||||
"search_filter_media_type": "Tipo da mídia",
|
||||
@@ -1839,15 +1839,15 @@
|
||||
"setting_notifications_notify_minutes": "{count} minutos",
|
||||
"setting_notifications_notify_never": "Nunca",
|
||||
"setting_notifications_notify_seconds": "{count} segundos",
|
||||
"setting_notifications_single_progress_subtitle": "Informações detalhadas sobre o progresso do envio por arquivo",
|
||||
"setting_notifications_single_progress_subtitle": "Informações detalhadas sobre o progresso do envio por ficheiro",
|
||||
"setting_notifications_single_progress_title": "Mostrar progresso detalhado do backup em segundo plano",
|
||||
"setting_notifications_subtitle": "Ajuste as preferências de notificação",
|
||||
"setting_notifications_total_progress_subtitle": "Progresso do envio de arquivos (concluídos/total)",
|
||||
"setting_notifications_total_progress_subtitle": "Progresso do envio de ficheiro (concluídos/total)",
|
||||
"setting_notifications_total_progress_title": "Mostrar progresso total do backup em segundo plano",
|
||||
"setting_video_viewer_auto_play_subtitle": "Reproduzir os vídeos automaticamente quando abertos",
|
||||
"setting_video_viewer_auto_play_title": "Reproduzir vídeos automaticamente",
|
||||
"setting_video_viewer_looping_title": "Repetir",
|
||||
"setting_video_viewer_original_video_subtitle": "Ao transmitir um vídeo do servidor, usar o arquivo original, mesmo quando uma versão transcodificada esteja disponível. Pode fazer com que o vídeo demore para carregar. Vídeos disponíveis localmente são exibidos na qualidade original independente desta configuração.",
|
||||
"setting_video_viewer_original_video_subtitle": "Ao transmitir um vídeo do servidor, usar o ficheiro original, mesmo se uma versão transcodificada estiver disponível. Pode causar interrupções. Vídeos disponíveis localmente são exibidos na qualidade original independentemente desta definição.",
|
||||
"setting_video_viewer_original_video_title": "Forçar vídeo original",
|
||||
"settings": "Definições",
|
||||
"settings_require_restart": "Reinicie o Immich para aplicar essa configuração",
|
||||
@@ -2019,7 +2019,7 @@
|
||||
"theme_setting_primary_color_subtitle": "Selecione a cor primária, utilizada nas ações principais e nos realces.",
|
||||
"theme_setting_primary_color_title": "Cor primária",
|
||||
"theme_setting_system_primary_color_title": "Use a cor do sistema",
|
||||
"theme_setting_system_theme_switch": "Automático (Siga a configuração do sistema)",
|
||||
"theme_setting_system_theme_switch": "Automático (Seguir a configuração do sistema)",
|
||||
"theme_setting_theme_subtitle": "Escolha a configuração do tema da aplicação",
|
||||
"theme_setting_three_stage_loading_subtitle": "O carregamento em três estágios pode aumentar o desempenho do carregamento, mas causa uma carga de rede significativamente maior",
|
||||
"theme_setting_three_stage_loading_title": "Habilitar carregamento em três estágios",
|
||||
@@ -2048,11 +2048,11 @@
|
||||
"trash_emptied": "Lixeira esvaziada",
|
||||
"trash_no_results_message": "Fotos e vídeos enviados para a reciclagem aparecem aqui.",
|
||||
"trash_page_delete_all": "Excluir tudo",
|
||||
"trash_page_empty_trash_dialog_content": "Deseja esvaziar a lixera? Estes arquivos serão apagados de forma permanente do Immich",
|
||||
"trash_page_empty_trash_dialog_content": "Deseja esvaziar a reciclagem? Estes ficheiros serão apagados permanentemente do Immich",
|
||||
"trash_page_info": "Ficheiros na reciclagem irão ser eliminados permanentemente após {days} dias",
|
||||
"trash_page_no_assets": "Lixeira vazia",
|
||||
"trash_page_restore_all": "Restaurar tudo",
|
||||
"trash_page_select_assets_btn": "Selecionar arquivos",
|
||||
"trash_page_select_assets_btn": "Selecionar ficheiros",
|
||||
"trash_page_title": "Reciclagem ({count})",
|
||||
"trashed_items_will_be_permanently_deleted_after": "Os itens da reciclagem são eliminados permanentemente após {days, plural, one {# dia} other {# dias}}.",
|
||||
"troubleshoot": "Diagnosticar problemas",
|
||||
@@ -2094,8 +2094,8 @@
|
||||
"upload_action_prompt": "{count} à espera de carregar",
|
||||
"upload_concurrency": "Carregamentos em simultâneo",
|
||||
"upload_details": "Detalhes do Carregamento",
|
||||
"upload_dialog_info": "Deseja fazer o backup dos arquivos selecionados no servidor?",
|
||||
"upload_dialog_title": "Enviar arquivo",
|
||||
"upload_dialog_info": "Deseja realizar uma cópia de segurança dos ficheiros selecionados para o servidor?",
|
||||
"upload_dialog_title": "Enviar ficheiro",
|
||||
"upload_errors": "Envio completo com {count, plural, one {# erro} other {# erros}}, atualize a página para ver os novos ficheiros enviados.",
|
||||
"upload_finished": "Carregamento acabado",
|
||||
"upload_progress": "Restante(s) {remaining, number} - Processado(s) {processed, number}/{total, number}",
|
||||
|
||||
@@ -485,7 +485,7 @@
|
||||
"app_bar_signout_dialog_content": "Вы уверены, что хотите выйти?",
|
||||
"app_bar_signout_dialog_ok": "Да",
|
||||
"app_bar_signout_dialog_title": "Выйти",
|
||||
"app_download_links": "Загрузка приложения",
|
||||
"app_download_links": "Ссылки на загрузку мобильного приложения",
|
||||
"app_settings": "Параметры приложения",
|
||||
"app_stores": "Магазины приложений",
|
||||
"app_update_available": "Доступна новая версия приложения",
|
||||
@@ -1346,7 +1346,7 @@
|
||||
"map_zoom_to_see_photos": "Уменьшение масштаба для просмотра фотографий",
|
||||
"mark_all_as_read": "Прочитано",
|
||||
"mark_as_read": "Отметить как прочитанное",
|
||||
"marked_all_as_read": "Отмечены как прочитанные",
|
||||
"marked_all_as_read": "Все уведомления отмечены как прочитанные",
|
||||
"matches": "Совпадения",
|
||||
"matching_assets": "Соответствующие объекты",
|
||||
"media_type": "Тип медиа",
|
||||
@@ -1453,7 +1453,7 @@
|
||||
"oauth": "OAuth",
|
||||
"obtainium_configurator": "Настройка Obtainium",
|
||||
"obtainium_configurator_instructions": "Для установки и обновления Android приложения Immich напрямую из источников на GitHub (минуя магазины приложений) можно использовать Obtainium. Создайте новый API ключ и укажите архитектуру приложения для формирования ссылки для Obtainium.",
|
||||
"ocr": "Распознавание текста",
|
||||
"ocr": "OCR",
|
||||
"official_immich_resources": "Официальные ресурсы Immich",
|
||||
"offline": "Недоступен",
|
||||
"offset": "Смещение",
|
||||
@@ -1858,7 +1858,7 @@
|
||||
"share_add_photos": "Добавить фото",
|
||||
"share_assets_selected": "{count} выбрано",
|
||||
"share_dialog_preparing": "Подготовка...",
|
||||
"share_link": "Поделиться ссылкой",
|
||||
"share_link": "Создать ссылку",
|
||||
"shared": "Общиe",
|
||||
"shared_album_activities_input_disable": "Комментарии отключены",
|
||||
"shared_album_activity_remove_content": "Удалить сообщение?",
|
||||
|
||||
@@ -418,7 +418,7 @@
|
||||
"advanced_settings_prefer_remote_title": "Föredra bilder från servern",
|
||||
"advanced_settings_proxy_headers_subtitle": "Definiera proxy-headers som Immich ska skicka med i varje närverksanrop",
|
||||
"advanced_settings_proxy_headers_title": "Anpassade proxyheaders [EXPERIMENTELLT]",
|
||||
"advanced_settings_readonly_mode_subtitle": "Aktiverar skrivskyddat läge där foton endast kan visas. Följande funktioner inaktiveras: välj flera bilder, dela, casta, ta bort bilder. Aktivera/inaktivera skrivskyddat läge via profilbilden på appens hemskärm",
|
||||
"advanced_settings_readonly_mode_subtitle": "Aktiverar skrivskyddat-läge där foton endast kan visas. Följande funktioner inaktiveras: välj flera bilder, dela, casta, ta bort bilder. Aktivera/inaktivera skrivskyddat läge via profilbilden på appens hemskärm",
|
||||
"advanced_settings_readonly_mode_title": "Skrivskyddat läge",
|
||||
"advanced_settings_self_signed_ssl_subtitle": "Hoppar över verifiering av serverns SSL-certifikat. Krävs för självsignerade certifikat.",
|
||||
"advanced_settings_self_signed_ssl_title": "Tillåt självsignerade SSL-certifikat [EXPERIMENTELLT]",
|
||||
@@ -791,6 +791,7 @@
|
||||
"daily_title_text_date_year": "E, dd MMM, yyyy",
|
||||
"dark": "Mörk",
|
||||
"dark_theme": "Växla mörkt tema",
|
||||
"date": "Datum",
|
||||
"date_after": "Datum efter",
|
||||
"date_and_time": "Datum och Tid",
|
||||
"date_before": "Datum före",
|
||||
@@ -1099,6 +1100,7 @@
|
||||
"features_setting_description": "Hantera appens funktioner",
|
||||
"file_name": "Filnamn",
|
||||
"file_name_or_extension": "Filnamn eller -tillägg",
|
||||
"file_size": "Filstorlek",
|
||||
"filename": "Filnamn",
|
||||
"filetype": "Filtyp",
|
||||
"filter": "Filter",
|
||||
@@ -1262,6 +1264,7 @@
|
||||
"local_media_summary": "Sammanfattning av lokala medier",
|
||||
"local_network": "Lokalt nätverk",
|
||||
"local_network_sheet_info": "Appen kommer ansluta till servern via denna URL när det specificerade WiFi-nätverket används",
|
||||
"location": "Position",
|
||||
"location_permission": "Plats-rättighet",
|
||||
"location_permission_content": "För att använda funktionen för automatisk växling behöver Immich behörighet till exakt plats så att appen kan läsa av det aktuella Wi-Fi-nätverkets namn",
|
||||
"location_picker_choose_on_map": "Välj på karta",
|
||||
@@ -1694,6 +1697,7 @@
|
||||
"reset_sqlite_confirmation": "Är du säker på att du vill återställa SQLite-databasen? Du måste logga ut och logga in igen för att synkronisera om data",
|
||||
"reset_sqlite_success": "Återställde SQLite-databasen",
|
||||
"reset_to_default": "Återställ till standard",
|
||||
"resolution": "Upplösning",
|
||||
"resolve_duplicates": "Lös dubletter",
|
||||
"resolved_all_duplicates": "Lös alla dubletter",
|
||||
"restore": "Återställ",
|
||||
@@ -1712,6 +1716,7 @@
|
||||
"running": "Igångsatt",
|
||||
"save": "Spara",
|
||||
"save_to_gallery": "Spara i galleri",
|
||||
"saved": "Sparad",
|
||||
"saved_api_key": "Sparad API-nyckel",
|
||||
"saved_profile": "Sparade profil",
|
||||
"saved_settings": "Sparade inställningar",
|
||||
@@ -2020,6 +2025,7 @@
|
||||
"theme_setting_three_stage_loading_title": "Aktivera trestegsladdning",
|
||||
"they_will_be_merged_together": "De kommer att slås samman",
|
||||
"third_party_resources": "Tredjepartsresurser",
|
||||
"time": "Tid",
|
||||
"time_based_memories": "Tidsbaserade minnen",
|
||||
"timeline": "Tidslinje",
|
||||
"timezone": "Tidszon",
|
||||
|
||||
25
i18n/ta.json
25
i18n/ta.json
@@ -154,6 +154,18 @@
|
||||
"machine_learning_min_detection_score_description": "ஒரு முகம் 0-1 முதல் கண்டறியப்படுவதற்கு குறைந்தபட்ச நம்பிக்கை மதிப்பெண். குறைந்த மதிப்புகள் அதிக முகங்களைக் கண்டறியும், ஆனால் தவறான நேர்மறைகளை ஏற்படுத்தக்கூடும்.",
|
||||
"machine_learning_min_recognized_faces": "குறைந்தபட்ச அங்கீகரிக்கப்பட்ட முகங்கள்",
|
||||
"machine_learning_min_recognized_faces_description": "ஒரு நபருக்கு உருவாக்கப்பட வேண்டிய அங்கீகரிக்கப்பட்ட முகங்களின் குறைந்தபட்ச எண்ணிக்கை. இதை அதிகரிப்பது, ஒரு நபருக்கு முகம் ஒதுக்கப்படாமல் போகும் வாய்ப்பை அதிகரிக்கும் செலவில், முக அங்கீகாரத்தை மிகவும் துல்லியமாக்குகிறது.",
|
||||
"machine_learning_ocr": "ஓசிஆர்",
|
||||
"machine_learning_ocr_description": "படங்களில் உள்ள உரையை அடையாளம் காண இயந்திர கற்றலைப் பயன்படுத்தவும்",
|
||||
"machine_learning_ocr_enabled": "ஓசிஆர் ஐ இயலச்செய்",
|
||||
"machine_learning_ocr_enabled_description": "முடக்கப்பட்டால், படங்கள் உரை அடையாளம் காணப்படாது.",
|
||||
"machine_learning_ocr_max_resolution": "அதிகபட்ச தெளிவுத்திறன்",
|
||||
"machine_learning_ocr_max_resolution_description": "இந்தத் தெளிவுத்திறனுக்கு மேலே உள்ள மாதிரிக்காட்சிகள், தோற்ற விகிதத்தைப் பாதுகாக்கும் அதே வேளையில், அளவு மாற்றப்படும். அதிக மதிப்புகள் மிகவும் துல்லியமானவை, ஆனால் செயலாக்கவும் அதிக நினைவகத்தைப் பயன்படுத்தவும் அதிக நேரம் எடுக்கும்.",
|
||||
"machine_learning_ocr_min_detection_score": "குறைந்தபட்ச கண்டறிதல் மதிப்பெண்",
|
||||
"machine_learning_ocr_min_detection_score_description": "உரையைக் கண்டறிய குறைந்தபட்ச நம்பிக்கை மதிப்பெண் 0-1. குறைந்த மதிப்பெண் அதிக உரையைக் கண்டறியும், ஆனால் தவறான தகவல்க்கு வழிவகுக்கும்.",
|
||||
"machine_learning_ocr_min_recognition_score": "குறைந்தபட்ச அங்கீகார மதிப்பெண்",
|
||||
"machine_learning_ocr_min_score_recognition_description": "கண்டறியப்பட்ட உரையை அங்கீகரிக்க குறைந்தபட்ச நம்பிக்கை மதிப்பெண் 0-1 ஆகும். குறைந்த மதிப்பெண் அதிக உரையை அங்கீகரிக்கும், ஆனால் தவறான நேர்மறைகளுக்கு வழிவகுக்கும்.",
|
||||
"machine_learning_ocr_model": "ஓசிஆர் மாதிரி",
|
||||
"machine_learning_ocr_model_description": "மொபைல் மாடல்களை விட சர்வர் மாடல்கள் மிகவும் துல்லியமானவை, ஆனால் அதிக நினைவகத்தை செயலாக்கி பயன்படுத்த அதிக நேரம் எடுக்கும்.",
|
||||
"machine_learning_settings": "இயந்திர கற்றல் அமைப்புகள்",
|
||||
"machine_learning_settings_description": "இயந்திர கற்றல் அம்சங்கள் மற்றும் அமைப்புகளை நிர்வகிக்கவும்",
|
||||
"machine_learning_smart_search": "ஸ்மார்ட் தேடல்",
|
||||
@@ -245,6 +257,7 @@
|
||||
"oauth_storage_quota_default_description": "GiB இல் உள்ள ஒதுக்கீடு எந்த உரிமைகோரலும் வழங்கப்படாதபோது பயன்படுத்தப்படும் .",
|
||||
"oauth_timeout": "கோரிக்கை நேரம் முடிந்தது",
|
||||
"oauth_timeout_description": "கோரிக்கைகளுக்கான காலக்கெடு மில்லி வினாடிகளில்",
|
||||
"ocr_job_description": "படங்களில் உள்ள உரையை அடையாளம் காண இயந்திர கற்றலைப் பயன்படுத்தவும்",
|
||||
"password_enable_description": "மின்னஞ்சல் மற்றும் கடவுச்சொல் மூலம் உள்நுழையவும்",
|
||||
"password_settings": "கடவுச்சொல் உள்நுழைவு",
|
||||
"password_settings_description": "கடவுச்சொல் உள்நுழைவு அமைப்புகளை நிர்வகிக்கவும்",
|
||||
@@ -669,6 +682,8 @@
|
||||
"change_password_description": "நீங்கள் கணினியில் கையொப்பமிடுவது இதுவே முதல் முறை அல்லது உங்கள் கடவுச்சொல்லை மாற்றுவதற்கான கோரிக்கை செய்யப்பட்டுள்ளது. கீழே புதிய கடவுச்சொல்லை உள்ளிடவும்.",
|
||||
"change_password_form_confirm_password": "கடவுச்சொல்லை உறுதிப்படுத்தவும்",
|
||||
"change_password_form_description": "ஆய் {name}, \n\nநீங்கள் கணினியில் கையொப்பமிடுவது இதுவே முதல் முறை அல்லது உங்கள் கடவுச்சொல்லை மாற்றுவதற்கான கோரிக்கை செய்யப்பட்டுள்ளது. கீழே புதிய கடவுச்சொல்லை உள்ளிடவும்.",
|
||||
"change_password_form_log_out": "மற்ற எல்லா சாதனங்களிலிருந்தும் வெளியேறு",
|
||||
"change_password_form_log_out_description": "மற்ற எல்லா சாதனங்களிலிருந்தும் வெளியேற பரிந்துரைக்கப்படுகிறது",
|
||||
"change_password_form_new_password": "புதிய கடவுச்சொல்",
|
||||
"change_password_form_password_mismatch": "கடவுச்சொற்கள் பொருந்தவில்லை",
|
||||
"change_password_form_reenter_new_password": "புதிய கடவுச்சொல்லை மீண்டும் உள்ளிடவும்",
|
||||
@@ -776,6 +791,7 @@
|
||||
"daily_title_text_date_year": "E, mmm dd, yyyy",
|
||||
"dark": "இருண்ட",
|
||||
"dark_theme": "இருண்ட கருப்பொருளை மாற்றவும்",
|
||||
"date": "தேதி",
|
||||
"date_after": "தேதி",
|
||||
"date_and_time": "தேதி மற்றும் நேரம்",
|
||||
"date_before": "முன் தேதி",
|
||||
@@ -1084,6 +1100,7 @@
|
||||
"features_setting_description": "பயன்பாட்டு அம்சங்களை நிர்வகிக்கவும்",
|
||||
"file_name": "கோப்பு பெயர்",
|
||||
"file_name_or_extension": "கோப்பு பெயர் அல்லது நீட்டிப்பு",
|
||||
"file_size": "கோப்பு அளவு",
|
||||
"filename": "கோப்புப்பெயர்",
|
||||
"filetype": "பைல்டைப்",
|
||||
"filter": "வடிப்பி",
|
||||
@@ -1247,6 +1264,7 @@
|
||||
"local_media_summary": "உள்ளக ஊடக சுருக்கம்",
|
||||
"local_network": "உள்ளக பிணையம்",
|
||||
"local_network_sheet_info": "குறிப்பிட்ட வைஃபை நெட்வொர்க்கைப் பயன்படுத்தும் போது பயன்பாடு இந்த முகவரி மூலம் சேவையகத்துடன் இணைக்கப்படும்",
|
||||
"location": "இடம்",
|
||||
"location_permission": "இருப்பிட இசைவு",
|
||||
"location_permission_content": "ஆட்டோ-ச்விட்சிங் அம்சத்தைப் பயன்படுத்த, இம்மிக்கு துல்லியமான இருப்பிட இசைவு தேவை, எனவே இது தற்போதைய வைஃபை நெட்வொர்க்கின் பெயரைப் படிக்க முடியும்",
|
||||
"location_picker_choose_on_map": "வரைபடத்தில் தேர்வு செய்யவும்",
|
||||
@@ -1435,6 +1453,7 @@
|
||||
"oauth": "Oauth",
|
||||
"obtainium_configurator": "ஒப்டெய்னியம் கட்டமைப்பாளர்",
|
||||
"obtainium_configurator_instructions": "Immich GitHub வெளியீட்டில் இருந்து நேரடியாக ஆண்ட்ராய்டு பயன்பாட்டை நிறுவவும் புதுப்பிக்கவும் Obtainium ஐப் பயன்படுத்தவும். பநிஇ விசையை உருவாக்கி, உங்கள் ஒப்டெய்னியம் உள்ளமைவு இணைப்பை உருவாக்க ஒரு மாறுபாட்டைத் தேர்ந்தெடுக்கவும்",
|
||||
"ocr": "ஓசிஆர்",
|
||||
"official_immich_resources": "உத்தியோகபூர்வ இம்மா வளங்கள்",
|
||||
"offline": "இணையமில்லாமல்",
|
||||
"offset": "ஈடுசெய்யும்",
|
||||
@@ -1678,6 +1697,7 @@
|
||||
"reset_sqlite_confirmation": "SQLITE தரவுத்தளத்தை மீட்டமைக்க விரும்புகிறீர்களா? தரவை மீண்டும் ஒத்திசைக்க நீங்கள் வெளியேறி மீண்டும் உள்நுழைய வேண்டும்",
|
||||
"reset_sqlite_success": "SQLITE தரவுத்தளத்தை வெற்றிகரமாக மீட்டமைக்கவும்",
|
||||
"reset_to_default": "இயல்புநிலைக்கு மீட்டமைக்கவும்",
|
||||
"resolution": "தெளிவுத்திறன்",
|
||||
"resolve_duplicates": "நகல்களைத் தீர்க்கவும்",
|
||||
"resolved_all_duplicates": "அனைத்து நகல்களையும் தீர்க்கும்",
|
||||
"restore": "மீட்டமை",
|
||||
@@ -1696,6 +1716,7 @@
|
||||
"running": "இயங்கும்",
|
||||
"save": "சேமி",
|
||||
"save_to_gallery": "கேலரியில் சேமிக்கவும்",
|
||||
"saved": "சேமிக்கப்பட்டது",
|
||||
"saved_api_key": "சேமித்த பநிஇ விசை",
|
||||
"saved_profile": "சேமித்த சுயவிவரம்",
|
||||
"saved_settings": "சேமித்த அமைப்புகள்",
|
||||
@@ -1712,6 +1733,8 @@
|
||||
"search_by_description_example": "சப்பாவில் நடைபயணம்",
|
||||
"search_by_filename": "கோப்பு பெயர் அல்லது நீட்டிப்பு மூலம் தேடுங்கள்",
|
||||
"search_by_filename_example": "I.E. IMG_1234.JPG அல்லது PNG",
|
||||
"search_by_ocr": "ஓசிஆர் மூலம் தேடு",
|
||||
"search_by_ocr_example": "லேட்",
|
||||
"search_camera_lens_model": "கண்ணாடி வில்லை மாதிரியைத் தேடு...",
|
||||
"search_camera_make": "தேடல் கேமரா செய்யுங்கள் ...",
|
||||
"search_camera_model": "கேமரா மாதிரியைத் தேடுங்கள் ...",
|
||||
@@ -1729,6 +1752,7 @@
|
||||
"search_filter_location_title": "இருப்பிடத்தைத் தேர்ந்தெடுக்கவும்",
|
||||
"search_filter_media_type": "ஊடக வகை",
|
||||
"search_filter_media_type_title": "மீடியா வகையைத் தேர்ந்தெடுக்கவும்",
|
||||
"search_filter_ocr": "ஓசிஆர் மூலம் தேடு",
|
||||
"search_filter_people_title": "மக்களைத் தேர்ந்தெடுக்கவும்",
|
||||
"search_for": "தேடுங்கள்",
|
||||
"search_for_existing_person": "இருக்கும் நபரைத் தேடுங்கள்",
|
||||
@@ -2001,6 +2025,7 @@
|
||||
"theme_setting_three_stage_loading_title": "மூன்று-நிலை ஏற்றுதலை இயக்கவும்",
|
||||
"they_will_be_merged_together": "அவர்கள் ஒன்றாக இணைக்கப்படுவார்கள்",
|
||||
"third_party_resources": "மூன்றாம் தரப்பு வளங்கள்",
|
||||
"time": "நேரம்",
|
||||
"time_based_memories": "நேர அடிப்படையிலான நினைவுகள்",
|
||||
"timeline": "காலவரிசை",
|
||||
"timezone": "நேர மண்டலம்",
|
||||
|
||||
@@ -163,6 +163,7 @@
|
||||
"machine_learning_ocr_min_detection_score": "En düşük tespit puanı",
|
||||
"machine_learning_ocr_min_detection_score_description": "Metnin tespit edilmesi için minimum güven puanı 0-1 arasındadır. Düşük değerler daha fazla metin tespit eder, ancak yanlış pozitif sonuçlara yol açabilir.",
|
||||
"machine_learning_ocr_min_recognition_score": "Minimum tespit puanı",
|
||||
"machine_learning_ocr_model": "OCR modeli",
|
||||
"machine_learning_settings": "Makine Öğrenmesi ayarları",
|
||||
"machine_learning_settings_description": "Makine öğrenmesi özelliklerini ve ayarlarını yönet",
|
||||
"machine_learning_smart_search": "Akıllı Arama",
|
||||
@@ -785,6 +786,7 @@
|
||||
"daily_title_text_date_year": "dd MMM yyyy E",
|
||||
"dark": "Koyu",
|
||||
"dark_theme": "Karanlık temaya geç",
|
||||
"date": "Tarih",
|
||||
"date_after": "Sonraki tarih",
|
||||
"date_and_time": "Tarih ve Zaman",
|
||||
"date_before": "Önceki tarih",
|
||||
@@ -1093,6 +1095,7 @@
|
||||
"features_setting_description": "Uygulamanın özelliklerini yönet",
|
||||
"file_name": "Dosya adı",
|
||||
"file_name_or_extension": "Dosya adı veya uzantı",
|
||||
"file_size": "Dosya boyutu",
|
||||
"filename": "Dosya adı",
|
||||
"filetype": "Dosya tipi",
|
||||
"filter": "Filtre",
|
||||
@@ -1444,6 +1447,7 @@
|
||||
"oauth": "OAuth",
|
||||
"obtainium_configurator": "Obtainium Yapılandırıcı",
|
||||
"obtainium_configurator_instructions": "Obtainium kullanarak Android uygulamasını doğrudan Immich GitHub sürümünden yükleyin ve güncelleyin. Bir API anahtarı oluşturun ve bir varyant seçerek Obtainium yapılandırma bağlantınızı oluşturun",
|
||||
"ocr": "OCR",
|
||||
"official_immich_resources": "Resmi Immich Kaynakları",
|
||||
"offline": "Çevrim dışı",
|
||||
"offset": "Ofset",
|
||||
@@ -1687,6 +1691,7 @@
|
||||
"reset_sqlite_confirmation": "SQLite veritabanını sıfırlamak istediğinizden emin misiniz? Verileri yeniden eşzamanlamak için oturumu kapatıp tekrar oturum açmanız gerekecektir",
|
||||
"reset_sqlite_success": "SQLite veritabanını başarıyla sıfırladınız",
|
||||
"reset_to_default": "Varsayılana sıfırla",
|
||||
"resolution": "Çözünürlük",
|
||||
"resolve_duplicates": "Çiftleri çöz",
|
||||
"resolved_all_duplicates": "Tüm çiftler çözüldü",
|
||||
"restore": "Geri yükle",
|
||||
@@ -2010,6 +2015,7 @@
|
||||
"theme_setting_three_stage_loading_title": "Üç aşamalı yüklemeyi etkinleştir",
|
||||
"they_will_be_merged_together": "Birlikte birleştirilecekler",
|
||||
"third_party_resources": "Üçüncü taraf kaynaklar",
|
||||
"time": "Zaman",
|
||||
"time_based_memories": "Zaman bazlı anılar",
|
||||
"timeline": "Zaman Çizelgesi",
|
||||
"timezone": "Zaman dilimi",
|
||||
|
||||
@@ -154,6 +154,8 @@
|
||||
"machine_learning_min_detection_score_description": "臉孔偵測的最低信心分數,範圍為 0 至 1。數值較低時會偵測到更多臉孔,但可能導致誤判。",
|
||||
"machine_learning_min_recognized_faces": "最低臉部辨識數量",
|
||||
"machine_learning_min_recognized_faces_description": "建立新人物所需的最低已辨識臉孔數量。提高此數值可讓臉孔辨識更精確,但同時會增加臉孔未被指派給任何人物的可能性。",
|
||||
"machine_learning_ocr": "文字辨識(OCR)",
|
||||
"machine_learning_ocr_description": "使用機器學習來識別圖片中的文字",
|
||||
"machine_learning_settings": "機器學習設定",
|
||||
"machine_learning_settings_description": "管理機器學習的功能和設定",
|
||||
"machine_learning_smart_search": "智慧搜尋",
|
||||
@@ -245,6 +247,7 @@
|
||||
"oauth_storage_quota_default_description": "未提供宣告時所使用的配額(GiB)。",
|
||||
"oauth_timeout": "請求逾時",
|
||||
"oauth_timeout_description": "請求的逾時時間(毫秒)",
|
||||
"ocr_job_description": "使用機器學習來識別圖像中的文字",
|
||||
"password_enable_description": "使用電子郵件和密碼登入",
|
||||
"password_settings": "密碼登入",
|
||||
"password_settings_description": "管理密碼登入設定",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
from PIL import Image
|
||||
from rapidocr.ch_ppocr_det import TextDetector as RapidTextDetector
|
||||
from rapidocr.ch_ppocr_det.utils import DBPostProcess
|
||||
from rapidocr.inference_engine.base import FileInfo, InferSession
|
||||
from rapidocr.utils import DownloadFile, DownloadFileInput
|
||||
from rapidocr.utils.typings import EngineType, LangDet, OCRVersion, TaskType
|
||||
@@ -10,11 +12,10 @@ from rapidocr.utils.typings import ModelType as RapidModelType
|
||||
|
||||
from immich_ml.config import log
|
||||
from immich_ml.models.base import InferenceModel
|
||||
from immich_ml.models.transforms import decode_cv2
|
||||
from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType
|
||||
from immich_ml.sessions.ort import OrtSession
|
||||
|
||||
from .schemas import OcrOptions, TextDetectionOutput
|
||||
from .schemas import TextDetectionOutput
|
||||
|
||||
|
||||
class TextDetector(InferenceModel):
|
||||
@@ -24,13 +25,20 @@ class TextDetector(InferenceModel):
|
||||
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
||||
super().__init__(model_name, **model_kwargs, model_format=ModelFormat.ONNX)
|
||||
self.max_resolution = 736
|
||||
self.min_score = 0.5
|
||||
self.score_mode = "fast"
|
||||
self.mean = np.array([0.5, 0.5, 0.5], dtype=np.float32)
|
||||
self.std_inv = np.float32(1.0) / (np.array([0.5, 0.5, 0.5], dtype=np.float32) * 255.0)
|
||||
self._empty: TextDetectionOutput = {
|
||||
"image": np.empty(0, dtype=np.float32),
|
||||
"boxes": np.empty(0, dtype=np.float32),
|
||||
"scores": np.empty(0, dtype=np.float32),
|
||||
}
|
||||
self.postprocess = DBPostProcess(
|
||||
thresh=0.3,
|
||||
box_thresh=model_kwargs.get("minScore", 0.5),
|
||||
max_candidates=1000,
|
||||
unclip_ratio=1.6,
|
||||
use_dilation=True,
|
||||
score_mode="fast",
|
||||
)
|
||||
|
||||
def _download(self) -> None:
|
||||
model_info = InferSession.get_model_url(
|
||||
@@ -52,35 +60,65 @@ class TextDetector(InferenceModel):
|
||||
|
||||
def _load(self) -> ModelSession:
|
||||
# TODO: support other runtime sessions
|
||||
session = OrtSession(self.model_path)
|
||||
self.model = RapidTextDetector(
|
||||
OcrOptions(
|
||||
session=session.session,
|
||||
limit_side_len=self.max_resolution,
|
||||
limit_type="min",
|
||||
box_thresh=self.min_score,
|
||||
score_mode=self.score_mode,
|
||||
)
|
||||
)
|
||||
return session
|
||||
return OrtSession(self.model_path)
|
||||
|
||||
def _predict(self, inputs: bytes | Image.Image) -> TextDetectionOutput:
|
||||
results = self.model(decode_cv2(inputs))
|
||||
if results.boxes is None or results.scores is None or results.img is None:
|
||||
# partly adapted from RapidOCR
|
||||
def _predict(self, inputs: Image.Image) -> TextDetectionOutput:
|
||||
w, h = inputs.size
|
||||
if w < 32 or h < 32:
|
||||
return self._empty
|
||||
out = self.session.run(None, {"x": self._transform(inputs)})[0]
|
||||
boxes, scores = self.postprocess(out, (h, w))
|
||||
if len(boxes) == 0:
|
||||
return self._empty
|
||||
return {
|
||||
"image": results.img,
|
||||
"boxes": np.array(results.boxes, dtype=np.float32),
|
||||
"scores": np.array(results.scores, dtype=np.float32),
|
||||
"boxes": self.sorted_boxes(boxes),
|
||||
"scores": np.array(scores, dtype=np.float32),
|
||||
}
|
||||
|
||||
# adapted from RapidOCR
|
||||
def _transform(self, img: Image.Image) -> NDArray[np.float32]:
|
||||
if img.height < img.width:
|
||||
ratio = float(self.max_resolution) / img.height
|
||||
else:
|
||||
ratio = float(self.max_resolution) / img.width
|
||||
|
||||
resize_h = int(img.height * ratio)
|
||||
resize_w = int(img.width * ratio)
|
||||
|
||||
resize_h = int(round(resize_h / 32) * 32)
|
||||
resize_w = int(round(resize_w / 32) * 32)
|
||||
resized_img = img.resize((int(resize_w), int(resize_h)), resample=Image.Resampling.LANCZOS)
|
||||
|
||||
img_np: NDArray[np.float32] = cv2.cvtColor(np.array(resized_img, dtype=np.float32), cv2.COLOR_RGB2BGR) # type: ignore
|
||||
img_np -= self.mean
|
||||
img_np *= self.std_inv
|
||||
img_np = np.transpose(img_np, (2, 0, 1))
|
||||
return np.expand_dims(img_np, axis=0)
|
||||
|
||||
def sorted_boxes(self, dt_boxes: NDArray[np.float32]) -> NDArray[np.float32]:
|
||||
if len(dt_boxes) == 0:
|
||||
return dt_boxes
|
||||
|
||||
# Sort by y, then identify lines, then sort by (line, x)
|
||||
y_order = np.argsort(dt_boxes[:, 0, 1], kind="stable")
|
||||
sorted_y = dt_boxes[y_order, 0, 1]
|
||||
|
||||
line_ids = np.empty(len(dt_boxes), dtype=np.int32)
|
||||
line_ids[0] = 0
|
||||
np.cumsum(np.abs(np.diff(sorted_y)) >= 10, out=line_ids[1:])
|
||||
|
||||
# Create composite sort key for final ordering
|
||||
# Shift line_ids by large factor, add x for tie-breaking
|
||||
sort_key = line_ids[y_order] * 1e6 + dt_boxes[y_order, 0, 0]
|
||||
final_order = np.argsort(sort_key, kind="stable")
|
||||
sorted_boxes: NDArray[np.float32] = dt_boxes[y_order[final_order]]
|
||||
return sorted_boxes
|
||||
|
||||
def configure(self, **kwargs: Any) -> None:
|
||||
if (max_resolution := kwargs.get("maxResolution")) is not None:
|
||||
self.max_resolution = max_resolution
|
||||
self.model.limit_side_len = max_resolution
|
||||
if (min_score := kwargs.get("minScore")) is not None:
|
||||
self.min_score = min_score
|
||||
self.model.postprocess_op.box_thresh = min_score
|
||||
self.postprocess.box_thresh = min_score
|
||||
if (score_mode := kwargs.get("scoreMode")) is not None:
|
||||
self.score_mode = score_mode
|
||||
self.model.postprocess_op.score_mode = score_mode
|
||||
self.postprocess.score_mode = score_mode
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
from PIL.Image import Image
|
||||
from PIL import Image
|
||||
from rapidocr.ch_ppocr_rec import TextRecInput
|
||||
from rapidocr.ch_ppocr_rec import TextRecognizer as RapidTextRecognizer
|
||||
from rapidocr.inference_engine.base import FileInfo, InferSession
|
||||
from rapidocr.utils import DownloadFile, DownloadFileInput
|
||||
from rapidocr.utils.typings import EngineType, LangRec, OCRVersion, TaskType
|
||||
from rapidocr.utils.typings import ModelType as RapidModelType
|
||||
from rapidocr.utils.vis_res import VisRes
|
||||
|
||||
from immich_ml.config import log, settings
|
||||
from immich_ml.models.base import InferenceModel
|
||||
from immich_ml.models.transforms import pil_to_cv2
|
||||
from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType
|
||||
from immich_ml.sessions.ort import OrtSession
|
||||
|
||||
@@ -31,6 +32,7 @@ class TextRecognizer(InferenceModel):
|
||||
"text": [],
|
||||
"textScore": np.empty(0, dtype=np.float32),
|
||||
}
|
||||
VisRes.__init__ = lambda self, **kwargs: None # pyright: ignore[reportAttributeAccessIssue]
|
||||
super().__init__(model_name, **model_kwargs, model_format=ModelFormat.ONNX)
|
||||
|
||||
def _download(self) -> None:
|
||||
@@ -63,17 +65,16 @@ class TextRecognizer(InferenceModel):
|
||||
)
|
||||
return session
|
||||
|
||||
def _predict(self, _: Image, texts: TextDetectionOutput) -> TextRecognitionOutput:
|
||||
boxes, img, box_scores = texts["boxes"], texts["image"], texts["scores"]
|
||||
def _predict(self, img: Image.Image, texts: TextDetectionOutput) -> TextRecognitionOutput:
|
||||
boxes, box_scores = texts["boxes"], texts["scores"]
|
||||
if boxes.shape[0] == 0:
|
||||
return self._empty
|
||||
rec = self.model(TextRecInput(img=self.get_crop_img_list(img, boxes)))
|
||||
if rec.txts is None:
|
||||
return self._empty
|
||||
|
||||
height, width = img.shape[0:2]
|
||||
boxes[:, :, 0] /= width
|
||||
boxes[:, :, 1] /= height
|
||||
boxes[:, :, 0] /= img.width
|
||||
boxes[:, :, 1] /= img.height
|
||||
|
||||
text_scores = np.array(rec.scores)
|
||||
valid_text_score_idx = text_scores > self.min_score
|
||||
@@ -85,7 +86,7 @@ class TextRecognizer(InferenceModel):
|
||||
"textScore": text_scores[valid_text_score_idx],
|
||||
}
|
||||
|
||||
def get_crop_img_list(self, img: NDArray[np.float32], boxes: NDArray[np.float32]) -> list[NDArray[np.float32]]:
|
||||
def get_crop_img_list(self, img: Image.Image, boxes: NDArray[np.float32]) -> list[NDArray[np.uint8]]:
|
||||
img_crop_width = np.maximum(
|
||||
np.linalg.norm(boxes[:, 1] - boxes[:, 0], axis=1), np.linalg.norm(boxes[:, 2] - boxes[:, 3], axis=1)
|
||||
).astype(np.int32)
|
||||
@@ -96,22 +97,55 @@ class TextRecognizer(InferenceModel):
|
||||
pts_std[:, 1:3, 0] = img_crop_width[:, None]
|
||||
pts_std[:, 2:4, 1] = img_crop_height[:, None]
|
||||
|
||||
img_crop_sizes = np.stack([img_crop_width, img_crop_height], axis=1).tolist()
|
||||
imgs: list[NDArray[np.float32]] = []
|
||||
for box, pts_std, dst_size in zip(list(boxes), list(pts_std), img_crop_sizes):
|
||||
M = cv2.getPerspectiveTransform(box, pts_std)
|
||||
dst_img: NDArray[np.float32] = cv2.warpPerspective(
|
||||
img,
|
||||
M,
|
||||
dst_size,
|
||||
borderMode=cv2.BORDER_REPLICATE,
|
||||
flags=cv2.INTER_CUBIC,
|
||||
) # type: ignore
|
||||
dst_height, dst_width = dst_img.shape[0:2]
|
||||
img_crop_sizes = np.stack([img_crop_width, img_crop_height], axis=1)
|
||||
all_coeffs = self._get_perspective_transform(pts_std, boxes)
|
||||
imgs: list[NDArray[np.uint8]] = []
|
||||
for coeffs, dst_size in zip(all_coeffs, img_crop_sizes):
|
||||
dst_img = img.transform(
|
||||
size=tuple(dst_size),
|
||||
method=Image.Transform.PERSPECTIVE,
|
||||
data=tuple(coeffs),
|
||||
resample=Image.Resampling.BICUBIC,
|
||||
)
|
||||
|
||||
dst_width, dst_height = dst_img.size
|
||||
if dst_height * 1.0 / dst_width >= 1.5:
|
||||
dst_img = np.rot90(dst_img)
|
||||
imgs.append(dst_img)
|
||||
dst_img = dst_img.rotate(90, expand=True)
|
||||
imgs.append(pil_to_cv2(dst_img))
|
||||
|
||||
return imgs
|
||||
|
||||
def _get_perspective_transform(self, src: NDArray[np.float32], dst: NDArray[np.float32]) -> NDArray[np.float32]:
|
||||
N = src.shape[0]
|
||||
x, y = src[:, :, 0], src[:, :, 1]
|
||||
u, v = dst[:, :, 0], dst[:, :, 1]
|
||||
A = np.zeros((N, 8, 9), dtype=np.float32)
|
||||
|
||||
# Fill even rows (0, 2, 4, 6): [x, y, 1, 0, 0, 0, -u*x, -u*y, -u]
|
||||
A[:, ::2, 0] = x
|
||||
A[:, ::2, 1] = y
|
||||
A[:, ::2, 2] = 1
|
||||
A[:, ::2, 6] = -u * x
|
||||
A[:, ::2, 7] = -u * y
|
||||
A[:, ::2, 8] = -u
|
||||
|
||||
# Fill odd rows (1, 3, 5, 7): [0, 0, 0, x, y, 1, -v*x, -v*y, -v]
|
||||
A[:, 1::2, 3] = x
|
||||
A[:, 1::2, 4] = y
|
||||
A[:, 1::2, 5] = 1
|
||||
A[:, 1::2, 6] = -v * x
|
||||
A[:, 1::2, 7] = -v * y
|
||||
A[:, 1::2, 8] = -v
|
||||
|
||||
# Solve using SVD for all matrices at once
|
||||
_, _, Vt = np.linalg.svd(A)
|
||||
H = Vt[:, -1, :].reshape(N, 3, 3)
|
||||
H = H / H[:, 2:3, 2:3]
|
||||
|
||||
# Extract the 8 coefficients for each transformation
|
||||
return np.column_stack(
|
||||
[H[:, 0, 0], H[:, 0, 1], H[:, 0, 2], H[:, 1, 0], H[:, 1, 1], H[:, 1, 2], H[:, 2, 0], H[:, 2, 1]]
|
||||
) # pyright: ignore[reportReturnType]
|
||||
|
||||
def configure(self, **kwargs: Any) -> None:
|
||||
self.min_score = kwargs.get("minScore", self.min_score)
|
||||
|
||||
@@ -7,7 +7,6 @@ from typing_extensions import TypedDict
|
||||
|
||||
|
||||
class TextDetectionOutput(TypedDict):
|
||||
image: npt.NDArray[np.float32]
|
||||
boxes: npt.NDArray[np.float32]
|
||||
scores: npt.NDArray[np.float32]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "2.2.0"
|
||||
version = "2.2.3"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.10,<4.0"
|
||||
@@ -22,7 +22,6 @@ dependencies = [
|
||||
"rich>=13.4.2",
|
||||
"tokenizers>=0.15.0,<1.0",
|
||||
"uvicorn[standard]>=0.22.0,<1.0",
|
||||
"setuptools>=78.1.0",
|
||||
"rapidocr>=3.1.0",
|
||||
]
|
||||
|
||||
|
||||
2
machine-learning/uv.lock
generated
2
machine-learning/uv.lock
generated
@@ -1100,7 +1100,6 @@ dependencies = [
|
||||
{ name = "python-multipart" },
|
||||
{ name = "rapidocr" },
|
||||
{ name = "rich" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "tokenizers" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
@@ -1188,7 +1187,6 @@ requires-dist = [
|
||||
{ name = "rapidocr", specifier = ">=3.1.0" },
|
||||
{ name = "rich", specifier = ">=13.4.2" },
|
||||
{ name = "rknn-toolkit-lite2", marker = "extra == 'rknn'", specifier = ">=2.3.0,<3" },
|
||||
{ name = "setuptools", specifier = ">=78.1.0" },
|
||||
{ name = "tokenizers", specifier = ">=0.15.0,<1.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.22.0,<1.0" },
|
||||
]
|
||||
|
||||
@@ -88,7 +88,6 @@ if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
|
||||
fi
|
||||
|
||||
sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile
|
||||
sed -i "s/version_number: \"$CURRENT_SERVER\"$/version_number: \"$NEXT_SERVER\"/" mobile/ios/fastlane/Fastfile
|
||||
sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile
|
||||
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3023,
|
||||
"android.injected.version.name" => "2.2.0",
|
||||
"android.injected.version.code" => 3026,
|
||||
"android.injected.version.name" => "2.2.3",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
gem "cocoapods"
|
||||
gem "cocoapods"
|
||||
gem "abbrev" # Required for Ruby 3.4+
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -714,7 +714,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 231;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -858,7 +858,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 231;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -888,7 +888,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 231;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -922,7 +922,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 231;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
@@ -965,7 +965,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 231;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
@@ -1005,7 +1005,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 231;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
@@ -1044,7 +1044,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 231;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -1088,7 +1088,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 231;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -1129,7 +1129,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 231;
|
||||
CURRENT_PROJECT_VERSION = 233;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.1.0</string>
|
||||
<string>2.2.1</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
@@ -107,7 +107,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>231</string>
|
||||
<string>233</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -16,42 +16,92 @@
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
desc "iOS Release to TestFlight"
|
||||
lane :release_ci do
|
||||
# Setup CI environment
|
||||
setup_ci
|
||||
|
||||
# Load App Store Connect API Key
|
||||
api_key = app_store_connect_api_key(
|
||||
# Constants
|
||||
TEAM_ID = "2F67MQ8R79"
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution: Hau Tran (#{TEAM_ID})"
|
||||
BASE_BUNDLE_ID = "app.alextran.immich"
|
||||
|
||||
# Helper method to get App Store Connect API key
|
||||
def get_api_key
|
||||
app_store_connect_api_key(
|
||||
key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
|
||||
issuer_id: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"],
|
||||
key_filepath: "api_key.json"
|
||||
key_filepath: "#{Dir.home}/.appstoreconnect/private_keys/AuthKey_#{ENV['APP_STORE_CONNECT_API_KEY_ID']}.p8",
|
||||
duration: 1200,
|
||||
in_house: false
|
||||
)
|
||||
end
|
||||
|
||||
# Helper method to get version from pubspec.yaml
|
||||
def get_version_from_pubspec
|
||||
require 'yaml'
|
||||
|
||||
pubspec_path = File.join(Dir.pwd, "../..", "pubspec.yaml")
|
||||
pubspec = YAML.load_file(pubspec_path)
|
||||
|
||||
version_string = pubspec['version']
|
||||
version_string ? version_string.split('+').first : nil
|
||||
end
|
||||
|
||||
# Helper method to configure code signing for all targets
|
||||
def configure_code_signing(bundle_id_suffix: "")
|
||||
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
|
||||
|
||||
# Import certificate and provisioning profile
|
||||
import_certificate(
|
||||
certificate_path: "certificate.p12",
|
||||
certificate_password: ENV["IOS_CERTIFICATE_PASSWORD"],
|
||||
keychain_name: ENV["KEYCHAIN_NAME"],
|
||||
keychain_password: ENV["KEYCHAIN_PASSWORD"]
|
||||
)
|
||||
|
||||
# Install provisioning profile
|
||||
install_provisioning_profile(path: "profile.mobileprovision")
|
||||
|
||||
# Configure code signing
|
||||
# Runner (main app)
|
||||
update_code_signing_settings(
|
||||
use_automatic_signing: false,
|
||||
path: "./Runner.xcodeproj",
|
||||
team_id: ENV["FASTLANE_TEAM_ID"],
|
||||
profile_name: "app.alextran.immich AppStore"
|
||||
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
|
||||
code_sign_identity: CODE_SIGN_IDENTITY,
|
||||
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}",
|
||||
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix} AppStore",
|
||||
targets: ["Runner"]
|
||||
)
|
||||
|
||||
# ShareExtension
|
||||
update_code_signing_settings(
|
||||
use_automatic_signing: false,
|
||||
path: "./Runner.xcodeproj",
|
||||
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
|
||||
code_sign_identity: CODE_SIGN_IDENTITY,
|
||||
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
|
||||
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension AppStore",
|
||||
targets: ["ShareExtension"]
|
||||
)
|
||||
|
||||
# WidgetExtension
|
||||
update_code_signing_settings(
|
||||
use_automatic_signing: false,
|
||||
path: "./Runner.xcodeproj",
|
||||
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
|
||||
code_sign_identity: CODE_SIGN_IDENTITY,
|
||||
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
|
||||
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget AppStore",
|
||||
targets: ["WidgetExtension"]
|
||||
)
|
||||
end
|
||||
|
||||
# Helper method to build and upload to TestFlight
|
||||
def build_and_upload(
|
||||
api_key:,
|
||||
bundle_id_suffix: "",
|
||||
configuration: "Release",
|
||||
distribute_external: true,
|
||||
version_number: nil
|
||||
)
|
||||
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
|
||||
app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}"
|
||||
|
||||
# Set version number if provided
|
||||
if version_number
|
||||
increment_version_number(version_number: version_number)
|
||||
end
|
||||
|
||||
# Increment build number
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number(
|
||||
api_key: api_key,
|
||||
app_identifier: "app.alextran.immich"
|
||||
app_identifier: app_identifier
|
||||
) + 1,
|
||||
xcodeproj: "./Runner.xcodeproj"
|
||||
)
|
||||
@@ -60,35 +110,101 @@ platform :ios do
|
||||
build_app(
|
||||
scheme: "Runner",
|
||||
workspace: "Runner.xcworkspace",
|
||||
configuration: configuration,
|
||||
export_method: "app-store",
|
||||
xcargs: "CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
|
||||
export_options: {
|
||||
provisioningProfiles: {
|
||||
"app.alextran.immich" => "app.alextran.immich AppStore"
|
||||
}
|
||||
"#{app_identifier}" => "#{app_identifier} AppStore",
|
||||
"#{app_identifier}.ShareExtension" => "#{app_identifier}.ShareExtension AppStore",
|
||||
"#{app_identifier}.Widget" => "#{app_identifier}.Widget AppStore"
|
||||
},
|
||||
signingStyle: "manual",
|
||||
signingCertificate: CODE_SIGN_IDENTITY
|
||||
}
|
||||
)
|
||||
|
||||
# Upload to TestFlight
|
||||
upload_to_testflight(
|
||||
api_key: api_key,
|
||||
skip_waiting_for_build_processing: true
|
||||
skip_waiting_for_build_processing: true,
|
||||
distribute_external: distribute_external
|
||||
)
|
||||
end
|
||||
|
||||
desc "iOS Development Build to TestFlight (requires separate bundle ID)"
|
||||
lane :gha_testflight_dev do
|
||||
api_key = get_api_key
|
||||
|
||||
# Install development provisioning profiles
|
||||
install_provisioning_profile(path: "profile_dev.mobileprovision")
|
||||
install_provisioning_profile(path: "profile_dev_share.mobileprovision")
|
||||
install_provisioning_profile(path: "profile_dev_widget.mobileprovision")
|
||||
|
||||
# Configure code signing for dev bundle IDs
|
||||
configure_code_signing(bundle_id_suffix: "development")
|
||||
|
||||
# Build and upload
|
||||
build_and_upload(
|
||||
api_key: api_key,
|
||||
bundle_id_suffix: "development",
|
||||
configuration: "Profile",
|
||||
distribute_external: false
|
||||
)
|
||||
end
|
||||
|
||||
desc "iOS Release"
|
||||
lane :release do
|
||||
desc "iOS Release to TestFlight"
|
||||
lane :gha_release_prod do
|
||||
api_key = get_api_key
|
||||
|
||||
# Install provisioning profiles
|
||||
install_provisioning_profile(path: "profile.mobileprovision")
|
||||
install_provisioning_profile(path: "profile_share.mobileprovision")
|
||||
install_provisioning_profile(path: "profile_widget.mobileprovision")
|
||||
|
||||
|
||||
# Configure code signing for production bundle IDs
|
||||
configure_code_signing
|
||||
|
||||
# Build and upload with version number
|
||||
build_and_upload(
|
||||
api_key: api_key,
|
||||
version_number: get_version_from_pubspec,
|
||||
distribute_external: false,
|
||||
)
|
||||
end
|
||||
|
||||
desc "iOS Manual Release"
|
||||
lane :release_manual do
|
||||
enable_automatic_code_signing(
|
||||
path: "./Runner.xcodeproj",
|
||||
targets: ["Runner", "ShareExtension", "WidgetExtension"]
|
||||
)
|
||||
|
||||
increment_version_number(
|
||||
version_number: "2.2.0"
|
||||
version_number: get_version_from_pubspec
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
)
|
||||
build_app(scheme: "Runner",
|
||||
workspace: "Runner.xcworkspace",
|
||||
xcargs: "-allowProvisioningUpdates")
|
||||
|
||||
# Build archive with automatic signing
|
||||
gym(
|
||||
scheme: "Runner",
|
||||
workspace: "Runner.xcworkspace",
|
||||
configuration: "Release",
|
||||
export_method: "app-store",
|
||||
skip_package_ipa: false,
|
||||
xcargs: "-allowProvisioningUpdates",
|
||||
export_options: {
|
||||
method: "app-store",
|
||||
signingStyle: "automatic",
|
||||
uploadBitcode: false,
|
||||
uploadSymbols: true,
|
||||
compileBitcode: false
|
||||
}
|
||||
)
|
||||
|
||||
upload_to_testflight(
|
||||
skip_waiting_for_build_processing: true
|
||||
)
|
||||
|
||||
@@ -15,13 +15,29 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
|
||||
|
||||
## iOS
|
||||
|
||||
### ios release
|
||||
### ios gha_testflight_dev
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios release
|
||||
[bundle exec] fastlane ios gha_testflight_dev
|
||||
```
|
||||
|
||||
iOS Release
|
||||
iOS Development Build to TestFlight (requires separate bundle ID)
|
||||
|
||||
### ios gha_release_prod
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios gha_release_prod
|
||||
```
|
||||
|
||||
iOS Release to TestFlight
|
||||
|
||||
### ios release_manual
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios release_manual
|
||||
```
|
||||
|
||||
iOS Manual Release
|
||||
|
||||
----
|
||||
|
||||
|
||||
@@ -53,3 +53,8 @@ const int kMinMonthsToEnableScrubberSnap = 12;
|
||||
const String kImmichAppStoreLink = "https://apps.apple.com/app/immich/id6449244941";
|
||||
const String kImmichPlayStoreLink = "https://play.google.com/store/apps/details?id=app.alextran.immich";
|
||||
const String kImmichLatestRelease = "https://github.com/immich-app/immich/releases/latest";
|
||||
|
||||
const int kPhotoTabIndex = 0;
|
||||
const int kSearchTabIndex = 1;
|
||||
const int kAlbumTabIndex = 2;
|
||||
const int kLibraryTabIndex = 3;
|
||||
|
||||
@@ -239,7 +239,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
final networkCapabilities = await _ref?.read(connectivityApiProvider).getCapabilities() ?? [];
|
||||
return _ref
|
||||
?.read(uploadServiceProvider)
|
||||
.startBackupWithHttpClient(currentUser.id, networkCapabilities.hasWifi, _cancellationToken);
|
||||
.startBackupWithHttpClient(currentUser.id, networkCapabilities.isUnmetered, _cancellationToken);
|
||||
},
|
||||
(error, stack) {
|
||||
dPrint(() => "Error in backup zone $error, $stack");
|
||||
|
||||
@@ -132,7 +132,8 @@ class SyncStreamService {
|
||||
return;
|
||||
// SyncCompleteV1 is used to signal the completion of the sync process. Cleanup stale assets and signal completion
|
||||
case SyncEntityType.syncCompleteV1:
|
||||
return _syncStreamRepository.pruneAssets();
|
||||
return;
|
||||
// return _syncStreamRepository.pruneAssets();
|
||||
// Request to reset the client state. Clear everything related to remote entities
|
||||
case SyncEntityType.syncResetV1:
|
||||
return _syncStreamRepository.reset();
|
||||
|
||||
@@ -65,7 +65,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
if (Store.isBetaTimelineEnabled) {
|
||||
bool syncSuccess = false;
|
||||
await Future.wait([
|
||||
backgroundManager.syncLocal(),
|
||||
backgroundManager.syncLocal(full: true),
|
||||
backgroundManager.syncRemote().then((success) => syncSuccess = success),
|
||||
]);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
@@ -107,30 +108,30 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
|
||||
|
||||
void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
|
||||
// On Photos page menu tapped
|
||||
if (router.activeIndex == 0 && index == 0) {
|
||||
if (router.activeIndex == kPhotoTabIndex && index == kPhotoTabIndex) {
|
||||
EventStream.shared.emit(const ScrollToTopEvent());
|
||||
}
|
||||
|
||||
if (index == 0) {
|
||||
if (index == kPhotoTabIndex) {
|
||||
ref.invalidate(driftMemoryFutureProvider);
|
||||
}
|
||||
|
||||
if (router.activeIndex != 1 && index == 1) {
|
||||
if (router.activeIndex != kSearchTabIndex && index == kSearchTabIndex) {
|
||||
ref.read(searchPreFilterProvider.notifier).clear();
|
||||
}
|
||||
|
||||
// On Search page tapped
|
||||
if (router.activeIndex == 1 && index == 1) {
|
||||
if (router.activeIndex == kSearchTabIndex && index == kSearchTabIndex) {
|
||||
ref.read(searchInputFocusProvider).requestFocus();
|
||||
}
|
||||
|
||||
// Album page
|
||||
if (index == 2) {
|
||||
if (index == kAlbumTabIndex) {
|
||||
ref.read(remoteAlbumProvider.notifier).refresh();
|
||||
}
|
||||
|
||||
// Library page
|
||||
if (index == 3) {
|
||||
if (index == kLibraryTabIndex) {
|
||||
ref.invalidate(localAlbumProvider);
|
||||
ref.invalidate(driftGetAllPeopleProvider);
|
||||
}
|
||||
|
||||
@@ -73,27 +73,29 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
search() async {
|
||||
if (filter.value.isEmpty) {
|
||||
searchFilter(SearchFilter filter) async {
|
||||
if (filter.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (preFilter == null && filter.value == previousFilter.value) {
|
||||
if (preFilter == null && filter == previousFilter.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSearching.value = true;
|
||||
ref.watch(paginatedSearchProvider.notifier).clear();
|
||||
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
|
||||
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter);
|
||||
|
||||
if (!hasResult) {
|
||||
context.showSnackBar(searchInfoSnackBar('search_no_result'.t(context: context)));
|
||||
}
|
||||
|
||||
previousFilter.value = filter.value;
|
||||
previousFilter.value = filter;
|
||||
isSearching.value = false;
|
||||
}
|
||||
|
||||
search() => searchFilter(filter.value);
|
||||
|
||||
loadMoreSearchResult() async {
|
||||
isSearching.value = true;
|
||||
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
|
||||
@@ -108,7 +110,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
searchPreFilter() {
|
||||
if (preFilter != null) {
|
||||
Future.delayed(Duration.zero, () {
|
||||
search();
|
||||
searchFilter(preFilter);
|
||||
|
||||
if (preFilter.location.city != null) {
|
||||
locationCurrentFilterWidget.value = Text(preFilter.location.city!, style: context.textTheme.labelLarge);
|
||||
@@ -122,7 +124,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
searchPreFilter();
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
}, [preFilter]);
|
||||
|
||||
showPeoplePicker() {
|
||||
handleOnSelect(Set<PersonDto> value) {
|
||||
|
||||
@@ -35,7 +35,8 @@ class SimilarPhotosActionButton extends ConsumerWidget {
|
||||
mediaType: AssetType.image,
|
||||
),
|
||||
);
|
||||
unawaited(context.router.popAndPush(const DriftSearchRoute()));
|
||||
|
||||
unawaited(context.navigateTo(const DriftSearchRoute()));
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -141,14 +141,28 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
}
|
||||
|
||||
Widget _buildAppearsInList(WidgetRef ref, BuildContext context) {
|
||||
final isRemote = ref.watch(currentAssetNotifier)?.hasRemote ?? false;
|
||||
if (!isRemote) {
|
||||
final aseet = ref.watch(currentAssetNotifier);
|
||||
if (aseet == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (!aseet.hasRemote) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
String? remoteAssetId;
|
||||
if (aseet is RemoteAsset) {
|
||||
remoteAssetId = aseet.id;
|
||||
} else if (aseet is LocalAsset) {
|
||||
remoteAssetId = aseet.remoteAssetId;
|
||||
}
|
||||
|
||||
if (remoteAssetId == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final remoteAsset = ref.watch(currentAssetNotifier) as RemoteAsset;
|
||||
final userId = ref.watch(currentUserProvider)?.id;
|
||||
final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAsset.id));
|
||||
final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAssetId));
|
||||
|
||||
return assetAlbums.when(
|
||||
data: (albums) {
|
||||
|
||||
@@ -228,6 +228,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
curve: Curves.easeInOut,
|
||||
)
|
||||
.whenComplete(() => ref.read(timelineStateProvider.notifier).setScrubbing(false));
|
||||
} else {
|
||||
ref.read(timelineStateProvider.notifier).setScrubbing(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (clientVersion < serverVersion) {
|
||||
if (clientVersion < serverVersion && clientVersion.differenceType(serverVersion) != SemVerType.patch) {
|
||||
state = state.copyWith(versionStatus: VersionStatus.clientOutOfDate);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,9 +89,16 @@ class AssetMediaRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
// titleAsync gets the correct original filename for some assets on iOS
|
||||
// otherwise using the `entity.title` would return a random GUID
|
||||
return await entity.titleAsync;
|
||||
try {
|
||||
// titleAsync gets the correct original filename for some assets on iOS
|
||||
// otherwise using the `entity.title` would return a random GUID
|
||||
final originalFilename = await entity.titleAsync;
|
||||
// treat empty filename as missing
|
||||
return originalFilename.isNotEmpty ? originalFilename : null;
|
||||
} catch (e) {
|
||||
_log.warning("Failed to get original filename for asset: $id. Error: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: make this more efficient
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/mixins/error_logger.mixin.dart';
|
||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/repositories/activity_api.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -69,6 +70,7 @@ class ActivityService with ErrorLoggerMixin {
|
||||
return AssetViewerRoute(
|
||||
initialIndex: 0,
|
||||
timelineService: _timelineFactory.fromAssets([asset], TimelineOrigin.albumActivities),
|
||||
currentAlbum: ref.read(currentRemoteAlbumProvider),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ enum ActionButtonType {
|
||||
context.currentAlbum!.isShared,
|
||||
ActionButtonType.similarPhotos =>
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote,
|
||||
context.asset is RemoteAsset,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
enum SemVerType { major, minor, patch }
|
||||
|
||||
class SemVer {
|
||||
final int major;
|
||||
final int minor;
|
||||
@@ -15,8 +17,20 @@ class SemVer {
|
||||
}
|
||||
|
||||
factory SemVer.fromString(String version) {
|
||||
if (version.toLowerCase().startsWith("v")) {
|
||||
version = version.substring(1);
|
||||
}
|
||||
|
||||
final parts = version.split("-")[0].split('.');
|
||||
return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2]));
|
||||
if (parts.length != 3) {
|
||||
throw FormatException('Invalid semantic version string: $version');
|
||||
}
|
||||
|
||||
try {
|
||||
return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2]));
|
||||
} catch (e) {
|
||||
throw FormatException('Invalid semantic version string: $version');
|
||||
}
|
||||
}
|
||||
|
||||
bool operator >(SemVer other) {
|
||||
@@ -54,6 +68,20 @@ class SemVer {
|
||||
return other is SemVer && other.major == major && other.minor == minor && other.patch == patch;
|
||||
}
|
||||
|
||||
SemVerType? differenceType(SemVer other) {
|
||||
if (major != other.major) {
|
||||
return SemVerType.major;
|
||||
}
|
||||
if (minor != other.minor) {
|
||||
return SemVerType.minor;
|
||||
}
|
||||
if (patch != other.patch) {
|
||||
return SemVerType.patch;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode;
|
||||
}
|
||||
|
||||
9
mobile/openapi/README.md
generated
9
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 2.2.0
|
||||
- API version: 2.2.3
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
@@ -265,6 +265,11 @@ Class | Method | HTTP request | Description
|
||||
*TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty |
|
||||
*TrashApi* | [**restoreAssets**](doc//TrashApi.md#restoreassets) | **POST** /trash/restore/assets |
|
||||
*TrashApi* | [**restoreTrash**](doc//TrashApi.md#restoretrash) | **POST** /trash/restore |
|
||||
*UploadApi* | [**cancelUpload**](doc//UploadApi.md#cancelupload) | **DELETE** /upload/{id} |
|
||||
*UploadApi* | [**getUploadOptions**](doc//UploadApi.md#getuploadoptions) | **OPTIONS** /upload |
|
||||
*UploadApi* | [**getUploadStatus**](doc//UploadApi.md#getuploadstatus) | **HEAD** /upload/{id} |
|
||||
*UploadApi* | [**resumeUpload**](doc//UploadApi.md#resumeupload) | **PATCH** /upload/{id} |
|
||||
*UploadApi* | [**startUpload**](doc//UploadApi.md#startupload) | **POST** /upload |
|
||||
*UsersApi* | [**createProfileImage**](doc//UsersApi.md#createprofileimage) | **POST** /users/profile-image |
|
||||
*UsersApi* | [**deleteProfileImage**](doc//UsersApi.md#deleteprofileimage) | **DELETE** /users/profile-image |
|
||||
*UsersApi* | [**deleteUserLicense**](doc//UsersApi.md#deleteuserlicense) | **DELETE** /users/me/license |
|
||||
@@ -579,6 +584,8 @@ Class | Method | HTTP request | Description
|
||||
- [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md)
|
||||
- [UpdateAssetDto](doc//UpdateAssetDto.md)
|
||||
- [UpdateLibraryDto](doc//UpdateLibraryDto.md)
|
||||
- [UploadBackupConfig](doc//UploadBackupConfig.md)
|
||||
- [UploadOkDto](doc//UploadOkDto.md)
|
||||
- [UsageByUserDto](doc//UsageByUserDto.md)
|
||||
- [UserAdminCreateDto](doc//UserAdminCreateDto.md)
|
||||
- [UserAdminDeleteDto](doc//UserAdminDeleteDto.md)
|
||||
|
||||
3
mobile/openapi/lib/api.dart
generated
3
mobile/openapi/lib/api.dart
generated
@@ -60,6 +60,7 @@ part 'api/system_metadata_api.dart';
|
||||
part 'api/tags_api.dart';
|
||||
part 'api/timeline_api.dart';
|
||||
part 'api/trash_api.dart';
|
||||
part 'api/upload_api.dart';
|
||||
part 'api/users_api.dart';
|
||||
part 'api/users_admin_api.dart';
|
||||
part 'api/view_api.dart';
|
||||
@@ -347,6 +348,8 @@ part 'model/update_album_dto.dart';
|
||||
part 'model/update_album_user_dto.dart';
|
||||
part 'model/update_asset_dto.dart';
|
||||
part 'model/update_library_dto.dart';
|
||||
part 'model/upload_backup_config.dart';
|
||||
part 'model/upload_ok_dto.dart';
|
||||
part 'model/usage_by_user_dto.dart';
|
||||
part 'model/user_admin_create_dto.dart';
|
||||
part 'model/user_admin_delete_dto.dart';
|
||||
|
||||
379
mobile/openapi/lib/api/upload_api.dart
generated
Normal file
379
mobile/openapi/lib/api/upload_api.dart
generated
Normal file
@@ -0,0 +1,379 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
|
||||
class UploadApi {
|
||||
UploadApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// This endpoint requires the `asset.upload` permission.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<Response> cancelUploadWithHttpInfo(String id, { String? key, String? slug, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/upload/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
if (slug != null) {
|
||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'DELETE',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// This endpoint requires the `asset.upload` permission.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<void> cancelUpload(String id, { String? key, String? slug, }) async {
|
||||
final response = await cancelUploadWithHttpInfo(id, key: key, slug: slug, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'OPTIONS /upload' operation and returns the [Response].
|
||||
Future<Response> getUploadOptionsWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/upload';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'OPTIONS',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> getUploadOptions() async {
|
||||
final response = await getUploadOptionsWithHttpInfo();
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
|
||||
/// This endpoint requires the `asset.upload` permission.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] uploadDraftInteropVersion (required):
|
||||
/// Indicates the version of the RUFH protocol supported by the client.
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<Response> getUploadStatusWithHttpInfo(String id, String uploadDraftInteropVersion, { String? key, String? slug, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/upload/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
if (slug != null) {
|
||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||
}
|
||||
|
||||
headerParams[r'upload-draft-interop-version'] = parameterToString(uploadDraftInteropVersion);
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'HEAD',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// This endpoint requires the `asset.upload` permission.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] uploadDraftInteropVersion (required):
|
||||
/// Indicates the version of the RUFH protocol supported by the client.
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<void> getUploadStatus(String id, String uploadDraftInteropVersion, { String? key, String? slug, }) async {
|
||||
final response = await getUploadStatusWithHttpInfo(id, uploadDraftInteropVersion, key: key, slug: slug, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
|
||||
/// This endpoint requires the `asset.upload` permission.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] contentLength (required):
|
||||
/// Non-negative size of the request body in bytes.
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] uploadComplete (required):
|
||||
/// Structured boolean indicating whether this request completes the file. Use Upload-Incomplete instead for version <= 3.
|
||||
///
|
||||
/// * [String] uploadDraftInteropVersion (required):
|
||||
/// Indicates the version of the RUFH protocol supported by the client.
|
||||
///
|
||||
/// * [String] uploadOffset (required):
|
||||
/// Non-negative byte offset indicating the starting position of the data in the request body within the entire file.
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<Response> resumeUploadWithHttpInfo(String contentLength, String id, String uploadComplete, String uploadDraftInteropVersion, String uploadOffset, { String? key, String? slug, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/upload/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
if (slug != null) {
|
||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||
}
|
||||
|
||||
headerParams[r'content-length'] = parameterToString(contentLength);
|
||||
headerParams[r'upload-complete'] = parameterToString(uploadComplete);
|
||||
headerParams[r'upload-draft-interop-version'] = parameterToString(uploadDraftInteropVersion);
|
||||
headerParams[r'upload-offset'] = parameterToString(uploadOffset);
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PATCH',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// This endpoint requires the `asset.upload` permission.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] contentLength (required):
|
||||
/// Non-negative size of the request body in bytes.
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] uploadComplete (required):
|
||||
/// Structured boolean indicating whether this request completes the file. Use Upload-Incomplete instead for version <= 3.
|
||||
///
|
||||
/// * [String] uploadDraftInteropVersion (required):
|
||||
/// Indicates the version of the RUFH protocol supported by the client.
|
||||
///
|
||||
/// * [String] uploadOffset (required):
|
||||
/// Non-negative byte offset indicating the starting position of the data in the request body within the entire file.
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<UploadOkDto?> resumeUpload(String contentLength, String id, String uploadComplete, String uploadDraftInteropVersion, String uploadOffset, { String? key, String? slug, }) async {
|
||||
final response = await resumeUploadWithHttpInfo(contentLength, id, uploadComplete, uploadDraftInteropVersion, uploadOffset, key: key, slug: slug, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UploadOkDto',) as UploadOkDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// This endpoint requires the `asset.upload` permission.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] contentLength (required):
|
||||
/// Non-negative size of the request body in bytes.
|
||||
///
|
||||
/// * [String] reprDigest (required):
|
||||
/// RFC 9651 structured dictionary containing an `sha` (bytesequence) checksum used to detect duplicate files and validate data integrity.
|
||||
///
|
||||
/// * [String] xImmichAssetData (required):
|
||||
/// RFC 9651 structured dictionary containing asset metadata with the following keys: - device-asset-id (string, required): Unique device asset identifier - device-id (string, required): Device identifier - file-created-at (string/date, required): ISO 8601 date string or Unix timestamp - file-modified-at (string/date, required): ISO 8601 date string or Unix timestamp - filename (string, required): Original filename - is-favorite (boolean, optional): Favorite status - live-photo-video-id (string, optional): Live photo ID for assets from iOS devices - icloud-id (string, optional): iCloud identifier for assets from iOS devices
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
///
|
||||
/// * [String] uploadComplete:
|
||||
/// Structured boolean indicating whether this request completes the file. Use Upload-Incomplete instead for version <= 3.
|
||||
///
|
||||
/// * [String] uploadDraftInteropVersion:
|
||||
/// Indicates the version of the RUFH protocol supported by the client.
|
||||
Future<Response> startUploadWithHttpInfo(String contentLength, String reprDigest, String xImmichAssetData, { String? key, String? slug, String? uploadComplete, String? uploadDraftInteropVersion, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/upload';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
if (slug != null) {
|
||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||
}
|
||||
|
||||
headerParams[r'content-length'] = parameterToString(contentLength);
|
||||
headerParams[r'repr-digest'] = parameterToString(reprDigest);
|
||||
if (uploadComplete != null) {
|
||||
headerParams[r'upload-complete'] = parameterToString(uploadComplete);
|
||||
}
|
||||
if (uploadDraftInteropVersion != null) {
|
||||
headerParams[r'upload-draft-interop-version'] = parameterToString(uploadDraftInteropVersion);
|
||||
}
|
||||
headerParams[r'x-immich-asset-data'] = parameterToString(xImmichAssetData);
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// This endpoint requires the `asset.upload` permission.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] contentLength (required):
|
||||
/// Non-negative size of the request body in bytes.
|
||||
///
|
||||
/// * [String] reprDigest (required):
|
||||
/// RFC 9651 structured dictionary containing an `sha` (bytesequence) checksum used to detect duplicate files and validate data integrity.
|
||||
///
|
||||
/// * [String] xImmichAssetData (required):
|
||||
/// RFC 9651 structured dictionary containing asset metadata with the following keys: - device-asset-id (string, required): Unique device asset identifier - device-id (string, required): Device identifier - file-created-at (string/date, required): ISO 8601 date string or Unix timestamp - file-modified-at (string/date, required): ISO 8601 date string or Unix timestamp - filename (string, required): Original filename - is-favorite (boolean, optional): Favorite status - live-photo-video-id (string, optional): Live photo ID for assets from iOS devices - icloud-id (string, optional): iCloud identifier for assets from iOS devices
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
///
|
||||
/// * [String] uploadComplete:
|
||||
/// Structured boolean indicating whether this request completes the file. Use Upload-Incomplete instead for version <= 3.
|
||||
///
|
||||
/// * [String] uploadDraftInteropVersion:
|
||||
/// Indicates the version of the RUFH protocol supported by the client.
|
||||
Future<UploadOkDto?> startUpload(String contentLength, String reprDigest, String xImmichAssetData, { String? key, String? slug, String? uploadComplete, String? uploadDraftInteropVersion, }) async {
|
||||
final response = await startUploadWithHttpInfo(contentLength, reprDigest, xImmichAssetData, key: key, slug: slug, uploadComplete: uploadComplete, uploadDraftInteropVersion: uploadDraftInteropVersion, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UploadOkDto',) as UploadOkDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
4
mobile/openapi/lib/api_client.dart
generated
4
mobile/openapi/lib/api_client.dart
generated
@@ -748,6 +748,10 @@ class ApiClient {
|
||||
return UpdateAssetDto.fromJson(value);
|
||||
case 'UpdateLibraryDto':
|
||||
return UpdateLibraryDto.fromJson(value);
|
||||
case 'UploadBackupConfig':
|
||||
return UploadBackupConfig.fromJson(value);
|
||||
case 'UploadOkDto':
|
||||
return UploadOkDto.fromJson(value);
|
||||
case 'UsageByUserDto':
|
||||
return UsageByUserDto.fromJson(value);
|
||||
case 'UserAdminCreateDto':
|
||||
|
||||
@@ -14,25 +14,31 @@ class SystemConfigBackupsDto {
|
||||
/// Returns a new [SystemConfigBackupsDto] instance.
|
||||
SystemConfigBackupsDto({
|
||||
required this.database,
|
||||
required this.upload,
|
||||
});
|
||||
|
||||
DatabaseBackupConfig database;
|
||||
|
||||
UploadBackupConfig upload;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigBackupsDto &&
|
||||
other.database == database;
|
||||
other.database == database &&
|
||||
other.upload == upload;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(database.hashCode);
|
||||
(database.hashCode) +
|
||||
(upload.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigBackupsDto[database=$database]';
|
||||
String toString() => 'SystemConfigBackupsDto[database=$database, upload=$upload]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'database'] = this.database;
|
||||
json[r'upload'] = this.upload;
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -46,6 +52,7 @@ class SystemConfigBackupsDto {
|
||||
|
||||
return SystemConfigBackupsDto(
|
||||
database: DatabaseBackupConfig.fromJson(json[r'database'])!,
|
||||
upload: UploadBackupConfig.fromJson(json[r'upload'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -94,6 +101,7 @@ class SystemConfigBackupsDto {
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'database',
|
||||
'upload',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ class SystemConfigNightlyTasksDto {
|
||||
required this.databaseCleanup,
|
||||
required this.generateMemories,
|
||||
required this.missingThumbnails,
|
||||
required this.removeStaleUploads,
|
||||
required this.startTime,
|
||||
required this.syncQuotaUsage,
|
||||
});
|
||||
@@ -29,6 +30,8 @@ class SystemConfigNightlyTasksDto {
|
||||
|
||||
bool missingThumbnails;
|
||||
|
||||
bool removeStaleUploads;
|
||||
|
||||
String startTime;
|
||||
|
||||
bool syncQuotaUsage;
|
||||
@@ -39,6 +42,7 @@ class SystemConfigNightlyTasksDto {
|
||||
other.databaseCleanup == databaseCleanup &&
|
||||
other.generateMemories == generateMemories &&
|
||||
other.missingThumbnails == missingThumbnails &&
|
||||
other.removeStaleUploads == removeStaleUploads &&
|
||||
other.startTime == startTime &&
|
||||
other.syncQuotaUsage == syncQuotaUsage;
|
||||
|
||||
@@ -49,11 +53,12 @@ class SystemConfigNightlyTasksDto {
|
||||
(databaseCleanup.hashCode) +
|
||||
(generateMemories.hashCode) +
|
||||
(missingThumbnails.hashCode) +
|
||||
(removeStaleUploads.hashCode) +
|
||||
(startTime.hashCode) +
|
||||
(syncQuotaUsage.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigNightlyTasksDto[clusterNewFaces=$clusterNewFaces, databaseCleanup=$databaseCleanup, generateMemories=$generateMemories, missingThumbnails=$missingThumbnails, startTime=$startTime, syncQuotaUsage=$syncQuotaUsage]';
|
||||
String toString() => 'SystemConfigNightlyTasksDto[clusterNewFaces=$clusterNewFaces, databaseCleanup=$databaseCleanup, generateMemories=$generateMemories, missingThumbnails=$missingThumbnails, removeStaleUploads=$removeStaleUploads, startTime=$startTime, syncQuotaUsage=$syncQuotaUsage]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -61,6 +66,7 @@ class SystemConfigNightlyTasksDto {
|
||||
json[r'databaseCleanup'] = this.databaseCleanup;
|
||||
json[r'generateMemories'] = this.generateMemories;
|
||||
json[r'missingThumbnails'] = this.missingThumbnails;
|
||||
json[r'removeStaleUploads'] = this.removeStaleUploads;
|
||||
json[r'startTime'] = this.startTime;
|
||||
json[r'syncQuotaUsage'] = this.syncQuotaUsage;
|
||||
return json;
|
||||
@@ -79,6 +85,7 @@ class SystemConfigNightlyTasksDto {
|
||||
databaseCleanup: mapValueOfType<bool>(json, r'databaseCleanup')!,
|
||||
generateMemories: mapValueOfType<bool>(json, r'generateMemories')!,
|
||||
missingThumbnails: mapValueOfType<bool>(json, r'missingThumbnails')!,
|
||||
removeStaleUploads: mapValueOfType<bool>(json, r'removeStaleUploads')!,
|
||||
startTime: mapValueOfType<String>(json, r'startTime')!,
|
||||
syncQuotaUsage: mapValueOfType<bool>(json, r'syncQuotaUsage')!,
|
||||
);
|
||||
@@ -132,6 +139,7 @@ class SystemConfigNightlyTasksDto {
|
||||
'databaseCleanup',
|
||||
'generateMemories',
|
||||
'missingThumbnails',
|
||||
'removeStaleUploads',
|
||||
'startTime',
|
||||
'syncQuotaUsage',
|
||||
};
|
||||
|
||||
100
mobile/openapi/lib/model/upload_backup_config.dart
generated
Normal file
100
mobile/openapi/lib/model/upload_backup_config.dart
generated
Normal file
@@ -0,0 +1,100 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class UploadBackupConfig {
|
||||
/// Returns a new [UploadBackupConfig] instance.
|
||||
UploadBackupConfig({
|
||||
required this.maxAgeHours,
|
||||
});
|
||||
|
||||
/// Minimum value: 1
|
||||
num maxAgeHours;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is UploadBackupConfig &&
|
||||
other.maxAgeHours == maxAgeHours;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(maxAgeHours.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'UploadBackupConfig[maxAgeHours=$maxAgeHours]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'maxAgeHours'] = this.maxAgeHours;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [UploadBackupConfig] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static UploadBackupConfig? fromJson(dynamic value) {
|
||||
upgradeDto(value, "UploadBackupConfig");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return UploadBackupConfig(
|
||||
maxAgeHours: num.parse('${json[r'maxAgeHours']}'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<UploadBackupConfig> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <UploadBackupConfig>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = UploadBackupConfig.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, UploadBackupConfig> mapFromJson(dynamic json) {
|
||||
final map = <String, UploadBackupConfig>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = UploadBackupConfig.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of UploadBackupConfig-objects as value to a dart map
|
||||
static Map<String, List<UploadBackupConfig>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<UploadBackupConfig>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = UploadBackupConfig.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'maxAgeHours',
|
||||
};
|
||||
}
|
||||
|
||||
99
mobile/openapi/lib/model/upload_ok_dto.dart
generated
Normal file
99
mobile/openapi/lib/model/upload_ok_dto.dart
generated
Normal file
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class UploadOkDto {
|
||||
/// Returns a new [UploadOkDto] instance.
|
||||
UploadOkDto({
|
||||
required this.id,
|
||||
});
|
||||
|
||||
String id;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is UploadOkDto &&
|
||||
other.id == id;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(id.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'UploadOkDto[id=$id]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'id'] = this.id;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [UploadOkDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static UploadOkDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "UploadOkDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return UploadOkDto(
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<UploadOkDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <UploadOkDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = UploadOkDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, UploadOkDto> mapFromJson(dynamic json) {
|
||||
final map = <String, UploadOkDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = UploadOkDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of UploadOkDto-objects as value to a dart map
|
||||
static Map<String, List<UploadOkDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<UploadOkDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = UploadOkDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'id',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1233,8 +1233,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: d921ae2
|
||||
resolved-ref: d921ae210e294d2821954009ec2cc8aeae918725
|
||||
ref: e132bc3
|
||||
resolved-ref: e132bc3ecc6a6d8fc2089d96f849c8a13129500e
|
||||
url: "https://github.com/immich-app/native_video_player"
|
||||
source: git
|
||||
version: "1.3.1"
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 2.2.0+3023
|
||||
version: 2.2.3+3026
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
@@ -57,7 +57,7 @@ dependencies:
|
||||
native_video_player:
|
||||
git:
|
||||
url: https://github.com/immich-app/native_video_player
|
||||
ref: 'd921ae2'
|
||||
ref: 'e132bc3'
|
||||
network_info_plus: ^6.1.3
|
||||
octo_image: ^2.1.0
|
||||
openapi:
|
||||
|
||||
@@ -8,11 +8,11 @@ bash tool/build_android.sh x64
|
||||
bash tool/build_android.sh armv7
|
||||
bash tool/build_android.sh arm64
|
||||
mv libisar_android_arm64.so libisar.so
|
||||
mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/arm64-v8a/
|
||||
mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/arm64-v8a/
|
||||
mv libisar_android_armv7.so libisar.so
|
||||
mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/armeabi-v7a/
|
||||
mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/armeabi-v7a/
|
||||
mv libisar_android_x64.so libisar.so
|
||||
mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86_64/
|
||||
mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/x86_64/
|
||||
mv libisar_android_x86.so libisar.so
|
||||
mv libisar.so ../.pub-cache/hosted/pub.isar-community.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86/
|
||||
)
|
||||
mv libisar.so ../.pub-cache/hosted/pub.dev/isar_community_flutter_libs-*/android/src/main/jniLibs/x86/
|
||||
)
|
||||
|
||||
92
mobile/test/utils/semver_test.dart
Normal file
92
mobile/test/utils/semver_test.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
void main() {
|
||||
group('SemVer', () {
|
||||
test('Parses valid semantic version strings correctly', () {
|
||||
final version = SemVer.fromString('1.2.3');
|
||||
expect(version.major, 1);
|
||||
expect(version.minor, 2);
|
||||
expect(version.patch, 3);
|
||||
});
|
||||
|
||||
test('Throws FormatException for invalid version strings', () {
|
||||
expect(() => SemVer.fromString('1.2'), throwsFormatException);
|
||||
expect(() => SemVer.fromString('a.b.c'), throwsFormatException);
|
||||
expect(() => SemVer.fromString('1.2.3.4'), throwsFormatException);
|
||||
});
|
||||
|
||||
test('Compares equal versons correctly', () {
|
||||
final v1 = SemVer.fromString('1.2.3');
|
||||
final v2 = SemVer.fromString('1.2.3');
|
||||
expect(v1 == v2, isTrue);
|
||||
expect(v1 > v2, isFalse);
|
||||
expect(v1 < v2, isFalse);
|
||||
});
|
||||
|
||||
test('Compares major version correctly', () {
|
||||
final v1 = SemVer.fromString('2.0.0');
|
||||
final v2 = SemVer.fromString('1.9.9');
|
||||
expect(v1 == v2, isFalse);
|
||||
expect(v1 > v2, isTrue);
|
||||
expect(v1 < v2, isFalse);
|
||||
});
|
||||
|
||||
test('Compares minor version correctly', () {
|
||||
final v1 = SemVer.fromString('1.3.0');
|
||||
final v2 = SemVer.fromString('1.2.9');
|
||||
expect(v1 == v2, isFalse);
|
||||
expect(v1 > v2, isTrue);
|
||||
expect(v1 < v2, isFalse);
|
||||
});
|
||||
|
||||
test('Compares patch version correctly', () {
|
||||
final v1 = SemVer.fromString('1.2.4');
|
||||
final v2 = SemVer.fromString('1.2.3');
|
||||
expect(v1 == v2, isFalse);
|
||||
expect(v1 > v2, isTrue);
|
||||
expect(v1 < v2, isFalse);
|
||||
});
|
||||
|
||||
test('Gives correct major difference type', () {
|
||||
final v1 = SemVer.fromString('2.0.0');
|
||||
final v2 = SemVer.fromString('1.9.9');
|
||||
expect(v1.differenceType(v2), SemVerType.major);
|
||||
});
|
||||
|
||||
test('Gives correct minor difference type', () {
|
||||
final v1 = SemVer.fromString('1.3.0');
|
||||
final v2 = SemVer.fromString('1.2.9');
|
||||
expect(v1.differenceType(v2), SemVerType.minor);
|
||||
});
|
||||
|
||||
test('Gives correct patch difference type', () {
|
||||
final v1 = SemVer.fromString('1.2.4');
|
||||
final v2 = SemVer.fromString('1.2.3');
|
||||
expect(v1.differenceType(v2), SemVerType.patch);
|
||||
});
|
||||
|
||||
test('Gives null difference type for equal versions', () {
|
||||
final v1 = SemVer.fromString('1.2.3');
|
||||
final v2 = SemVer.fromString('1.2.3');
|
||||
expect(v1.differenceType(v2), isNull);
|
||||
});
|
||||
|
||||
test('toString returns correct format', () {
|
||||
final version = SemVer.fromString('1.2.3');
|
||||
expect(version.toString(), '1.2.3');
|
||||
});
|
||||
|
||||
test('Parses versions with leading v correctly', () {
|
||||
final version1 = SemVer.fromString('v1.2.3');
|
||||
expect(version1.major, 1);
|
||||
expect(version1.minor, 2);
|
||||
expect(version1.patch, 3);
|
||||
|
||||
final version2 = SemVer.fromString('V1.2.3');
|
||||
expect(version2.major, 1);
|
||||
expect(version2.minor, 2);
|
||||
expect(version2.patch, 3);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -9373,6 +9373,324 @@
|
||||
"description": "This endpoint requires the `asset.delete` permission."
|
||||
}
|
||||
},
|
||||
"/upload": {
|
||||
"options": {
|
||||
"operationId": "getUploadOptions",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Upload"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "startUpload",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "content-length",
|
||||
"in": "header",
|
||||
"description": "Non-negative size of the request body in bytes.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "repr-digest",
|
||||
"in": "header",
|
||||
"description": "RFC 9651 structured dictionary containing an `sha` (bytesequence) checksum used to detect duplicate files and validate data integrity.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "slug",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "upload-complete",
|
||||
"in": "header",
|
||||
"description": "Structured boolean indicating whether this request completes the file. Use Upload-Incomplete instead for version <= 3.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "upload-draft-interop-version",
|
||||
"in": "header",
|
||||
"description": "Indicates the version of the RUFH protocol supported by the client.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x-immich-asset-data",
|
||||
"in": "header",
|
||||
"description": "RFC 9651 structured dictionary containing asset metadata with the following keys:\n- device-asset-id (string, required): Unique device asset identifier\n- device-id (string, required): Device identifier\n- file-created-at (string/date, required): ISO 8601 date string or Unix timestamp\n- file-modified-at (string/date, required): ISO 8601 date string or Unix timestamp\n- filename (string, required): Original filename\n- is-favorite (boolean, optional): Favorite status\n- live-photo-video-id (string, optional): Live photo ID for assets from iOS devices\n- icloud-id (string, optional): iCloud identifier for assets from iOS devices",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UploadOkDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
},
|
||||
"201": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Upload"
|
||||
],
|
||||
"x-immich-permission": "asset.upload",
|
||||
"description": "This endpoint requires the `asset.upload` permission."
|
||||
}
|
||||
},
|
||||
"/upload/{id}": {
|
||||
"delete": {
|
||||
"operationId": "cancelUpload",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "slug",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Upload"
|
||||
],
|
||||
"x-immich-permission": "asset.upload",
|
||||
"description": "This endpoint requires the `asset.upload` permission."
|
||||
},
|
||||
"head": {
|
||||
"operationId": "getUploadStatus",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "slug",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "upload-draft-interop-version",
|
||||
"in": "header",
|
||||
"description": "Indicates the version of the RUFH protocol supported by the client.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Upload"
|
||||
],
|
||||
"x-immich-permission": "asset.upload",
|
||||
"description": "This endpoint requires the `asset.upload` permission."
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "resumeUpload",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "content-length",
|
||||
"in": "header",
|
||||
"description": "Non-negative size of the request body in bytes.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "slug",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "upload-complete",
|
||||
"in": "header",
|
||||
"description": "Structured boolean indicating whether this request completes the file. Use Upload-Incomplete instead for version <= 3.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "upload-draft-interop-version",
|
||||
"in": "header",
|
||||
"description": "Indicates the version of the RUFH protocol supported by the client.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "upload-offset",
|
||||
"in": "header",
|
||||
"description": "Non-negative byte offset indicating the starting position of the data in the request body within the entire file.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UploadOkDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Upload"
|
||||
],
|
||||
"x-immich-permission": "asset.upload",
|
||||
"description": "This endpoint requires the `asset.upload` permission."
|
||||
}
|
||||
},
|
||||
"/users": {
|
||||
"get": {
|
||||
"operationId": "searchUsers",
|
||||
@@ -10006,7 +10324,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.3",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [],
|
||||
@@ -16340,10 +16658,14 @@
|
||||
"properties": {
|
||||
"database": {
|
||||
"$ref": "#/components/schemas/DatabaseBackupConfig"
|
||||
},
|
||||
"upload": {
|
||||
"$ref": "#/components/schemas/UploadBackupConfig"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"database"
|
||||
"database",
|
||||
"upload"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -16876,6 +17198,9 @@
|
||||
"missingThumbnails": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"removeStaleUploads": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"startTime": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16888,6 +17213,7 @@
|
||||
"databaseCleanup",
|
||||
"generateMemories",
|
||||
"missingThumbnails",
|
||||
"removeStaleUploads",
|
||||
"startTime",
|
||||
"syncQuotaUsage"
|
||||
],
|
||||
@@ -17740,6 +18066,29 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"UploadBackupConfig": {
|
||||
"properties": {
|
||||
"maxAgeHours": {
|
||||
"minimum": 1,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"maxAgeHours"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UploadOkDto": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UsageByUserDto": {
|
||||
"properties": {
|
||||
"photos": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.3",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
@@ -19,7 +19,7 @@
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.18.12",
|
||||
"@types/node": "^22.18.13",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 2.2.0
|
||||
* 2.2.3
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
@@ -1359,8 +1359,12 @@ export type DatabaseBackupConfig = {
|
||||
enabled: boolean;
|
||||
keepLastAmount: number;
|
||||
};
|
||||
export type UploadBackupConfig = {
|
||||
maxAgeHours: number;
|
||||
};
|
||||
export type SystemConfigBackupsDto = {
|
||||
database: DatabaseBackupConfig;
|
||||
upload: UploadBackupConfig;
|
||||
};
|
||||
export type SystemConfigFFmpegDto = {
|
||||
accel: TranscodeHWAccel;
|
||||
@@ -1489,6 +1493,7 @@ export type SystemConfigNightlyTasksDto = {
|
||||
databaseCleanup: boolean;
|
||||
generateMemories: boolean;
|
||||
missingThumbnails: boolean;
|
||||
removeStaleUploads: boolean;
|
||||
startTime: string;
|
||||
syncQuotaUsage: boolean;
|
||||
};
|
||||
@@ -1654,6 +1659,9 @@ export type TimeBucketsResponseDto = {
|
||||
export type TrashResponseDto = {
|
||||
count: number;
|
||||
};
|
||||
export type UploadOkDto = {
|
||||
id: string;
|
||||
};
|
||||
export type UserUpdateMeDto = {
|
||||
avatarColor?: (UserAvatarColor) | null;
|
||||
email?: string;
|
||||
@@ -4518,6 +4526,109 @@ export function restoreAssets({ bulkIdsDto }: {
|
||||
body: bulkIdsDto
|
||||
})));
|
||||
}
|
||||
export function getUploadOptions(opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText("/upload", {
|
||||
...opts,
|
||||
method: "OPTIONS"
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* This endpoint requires the `asset.upload` permission.
|
||||
*/
|
||||
export function startUpload({ contentLength, key, reprDigest, slug, uploadComplete, uploadDraftInteropVersion, xImmichAssetData }: {
|
||||
contentLength: string;
|
||||
key?: string;
|
||||
reprDigest: string;
|
||||
slug?: string;
|
||||
uploadComplete?: string;
|
||||
uploadDraftInteropVersion?: string;
|
||||
xImmichAssetData: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: UploadOkDto;
|
||||
} | {
|
||||
status: 201;
|
||||
}>(`/upload${QS.query(QS.explode({
|
||||
key,
|
||||
slug
|
||||
}))}`, {
|
||||
...opts,
|
||||
method: "POST",
|
||||
headers: oazapfts.mergeHeaders(opts?.headers, {
|
||||
"content-length": contentLength,
|
||||
"repr-digest": reprDigest,
|
||||
"upload-complete": uploadComplete,
|
||||
"upload-draft-interop-version": uploadDraftInteropVersion,
|
||||
"x-immich-asset-data": xImmichAssetData
|
||||
})
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* This endpoint requires the `asset.upload` permission.
|
||||
*/
|
||||
export function cancelUpload({ id, key, slug }: {
|
||||
id: string;
|
||||
key?: string;
|
||||
slug?: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText(`/upload/${encodeURIComponent(id)}${QS.query(QS.explode({
|
||||
key,
|
||||
slug
|
||||
}))}`, {
|
||||
...opts,
|
||||
method: "DELETE"
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* This endpoint requires the `asset.upload` permission.
|
||||
*/
|
||||
export function getUploadStatus({ id, key, slug, uploadDraftInteropVersion }: {
|
||||
id: string;
|
||||
key?: string;
|
||||
slug?: string;
|
||||
uploadDraftInteropVersion: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText(`/upload/${encodeURIComponent(id)}${QS.query(QS.explode({
|
||||
key,
|
||||
slug
|
||||
}))}`, {
|
||||
...opts,
|
||||
method: "HEAD",
|
||||
headers: oazapfts.mergeHeaders(opts?.headers, {
|
||||
"upload-draft-interop-version": uploadDraftInteropVersion
|
||||
})
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* This endpoint requires the `asset.upload` permission.
|
||||
*/
|
||||
export function resumeUpload({ contentLength, id, key, slug, uploadComplete, uploadDraftInteropVersion, uploadOffset }: {
|
||||
contentLength: string;
|
||||
id: string;
|
||||
key?: string;
|
||||
slug?: string;
|
||||
uploadComplete: string;
|
||||
uploadDraftInteropVersion: string;
|
||||
uploadOffset: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: UploadOkDto;
|
||||
}>(`/upload/${encodeURIComponent(id)}${QS.query(QS.explode({
|
||||
key,
|
||||
slug
|
||||
}))}`, {
|
||||
...opts,
|
||||
method: "PATCH",
|
||||
headers: oazapfts.mergeHeaders(opts?.headers, {
|
||||
"content-length": contentLength,
|
||||
"upload-complete": uploadComplete,
|
||||
"upload-draft-interop-version": uploadDraftInteropVersion,
|
||||
"upload-offset": uploadOffset
|
||||
})
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* This endpoint requires the `user.read` permission.
|
||||
*/
|
||||
|
||||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@@ -63,7 +63,7 @@ importers:
|
||||
specifier: ^4.13.1
|
||||
version: 4.13.4
|
||||
'@types/node':
|
||||
specifier: ^22.18.12
|
||||
specifier: ^22.18.13
|
||||
version: 22.18.13
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^3.0.0
|
||||
@@ -191,6 +191,10 @@ importers:
|
||||
version: 5.9.3
|
||||
|
||||
e2e:
|
||||
dependencies:
|
||||
structured-headers:
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.8.0
|
||||
@@ -211,7 +215,7 @@ importers:
|
||||
specifier: ^3.4.2
|
||||
version: 3.7.1
|
||||
'@types/node':
|
||||
specifier: ^22.18.12
|
||||
specifier: ^22.18.13
|
||||
version: 22.18.13
|
||||
'@types/oidc-provider':
|
||||
specifier: ^9.0.0
|
||||
@@ -293,7 +297,7 @@ importers:
|
||||
version: 1.0.4
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.18.12
|
||||
specifier: ^22.18.13
|
||||
version: 22.18.13
|
||||
typescript:
|
||||
specifier: ^5.3.3
|
||||
@@ -511,6 +515,9 @@ importers:
|
||||
socket.io:
|
||||
specifier: ^4.8.1
|
||||
version: 4.8.1
|
||||
structured-headers:
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2
|
||||
tailwindcss-preset-email:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.1(tailwindcss@3.4.18(yaml@2.8.1))
|
||||
@@ -582,7 +589,7 @@ importers:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
'@types/node':
|
||||
specifier: ^22.18.12
|
||||
specifier: ^22.18.13
|
||||
version: 22.18.13
|
||||
'@types/nodemailer':
|
||||
specifier: ^7.0.0
|
||||
@@ -10407,6 +10414,10 @@ packages:
|
||||
resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
structured-headers@2.0.2:
|
||||
resolution: {integrity: sha512-IUul56vVHuMg2UxWhwDj9zVJE6ztYEQQkynr1FQ/NydPhivtk5+Qb2N1RS36owEFk2fNUriTguJ2R7htRObcdA==}
|
||||
engines: {node: '>=18', npm: '>=6'}
|
||||
|
||||
style-to-js@1.1.18:
|
||||
resolution: {integrity: sha512-JFPn62D4kJaPTnhFUI244MThx+FEGbi+9dw1b9yBBQ+1CZpV7QAT8kUtJ7b7EUNdHajjF/0x8fT+16oLJoojLg==}
|
||||
|
||||
@@ -23397,6 +23408,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@tokenizer/token': 0.3.0
|
||||
|
||||
structured-headers@2.0.2: {}
|
||||
|
||||
style-to-js@1.1.18:
|
||||
dependencies:
|
||||
style-to-object: 1.0.11
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
| LivePhoto/MotionPhoto воспроизведение и бекап | Да | Да |
|
||||
| Отображение 360° изображений | Нет | Да |
|
||||
| Настраиваемая структура хранилища | Да | Да |
|
||||
| Общий доступ к контенту | Нет | Да |
|
||||
| Общий доступ к контенту | Да | Да |
|
||||
| Архив и избранное | Да | Да |
|
||||
| Мировая карта | Да | Да |
|
||||
| Совместное использование | Да | Да |
|
||||
@@ -104,7 +104,7 @@
|
||||
| Галереи только для просмотра | Да | Да |
|
||||
| Коллажи | Да | Да |
|
||||
| Метки (теги) | Нет | Да |
|
||||
| Просмотр папкой | Нет | Да |
|
||||
| Просмотр папкой | Да | Да |
|
||||
|
||||
## Перевод
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.3",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -104,6 +104,7 @@
|
||||
"sharp": "^0.34.4",
|
||||
"sirv": "^3.0.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"structured-headers": "^2.0.2",
|
||||
"tailwindcss-preset-email": "^1.4.0",
|
||||
"thumbhash": "^0.1.1",
|
||||
"ua-parser-js": "^2.0.0",
|
||||
@@ -129,7 +130,7 @@
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.18.12",
|
||||
"@types/node": "^22.18.13",
|
||||
"@types/nodemailer": "^7.0.0",
|
||||
"@types/picomatch": "^4.0.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface SystemConfig {
|
||||
cronExpression: string;
|
||||
keepLastAmount: number;
|
||||
};
|
||||
upload: {
|
||||
maxAgeHours: number;
|
||||
};
|
||||
};
|
||||
ffmpeg: {
|
||||
crf: number;
|
||||
@@ -140,6 +143,7 @@ export interface SystemConfig {
|
||||
clusterNewFaces: boolean;
|
||||
generateMemories: boolean;
|
||||
syncQuotaUsage: boolean;
|
||||
removeStaleUploads: boolean;
|
||||
};
|
||||
trash: {
|
||||
enabled: boolean;
|
||||
@@ -198,6 +202,9 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
cronExpression: CronExpression.EVERY_DAY_AT_2AM,
|
||||
keepLastAmount: 14,
|
||||
},
|
||||
upload: {
|
||||
maxAgeHours: 72,
|
||||
},
|
||||
},
|
||||
ffmpeg: {
|
||||
crf: 23,
|
||||
@@ -341,6 +348,7 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
syncQuotaUsage: true,
|
||||
missingThumbnails: true,
|
||||
clusterNewFaces: true,
|
||||
removeStaleUploads: true,
|
||||
},
|
||||
trash: {
|
||||
enabled: true,
|
||||
|
||||
445
server/src/controllers/asset-upload.controller.spec.ts
Normal file
445
server/src/controllers/asset-upload.controller.spec.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { AssetUploadController } from 'src/controllers/asset-upload.controller';
|
||||
import { AssetUploadService } from 'src/services/asset-upload.service';
|
||||
import { serializeDictionary } from 'structured-headers';
|
||||
import request from 'supertest';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
const makeAssetData = (overrides?: Partial<any>): string => {
|
||||
return serializeDictionary({
|
||||
filename: 'test-image.jpg',
|
||||
'device-asset-id': 'test-asset-id',
|
||||
'device-id': 'test-device',
|
||||
'file-created-at': new Date('2025-01-02T00:00:00Z').toISOString(),
|
||||
'file-modified-at': new Date('2025-01-01T00:00:00Z').toISOString(),
|
||||
'is-favorite': false,
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
describe(AssetUploadController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
let buffer: Buffer;
|
||||
let checksum: string;
|
||||
const service = mockBaseService(AssetUploadService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(AssetUploadController, [{ provide: AssetUploadService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
service.startUpload.mockImplementation((_, __, res, ___) => {
|
||||
res.send();
|
||||
return Promise.resolve();
|
||||
});
|
||||
service.resumeUpload.mockImplementation((_, __, res, ___, ____) => {
|
||||
res.send();
|
||||
return Promise.resolve();
|
||||
});
|
||||
service.cancelUpload.mockImplementation((_, __, res) => {
|
||||
res.send();
|
||||
return Promise.resolve();
|
||||
});
|
||||
service.getUploadStatus.mockImplementation((_, res, __, ___) => {
|
||||
res.send();
|
||||
return Promise.resolve();
|
||||
});
|
||||
ctx.reset();
|
||||
|
||||
buffer = Buffer.from(randomUUID());
|
||||
checksum = `sha=:${createHash('sha1').update(buffer).digest('base64')}:`;
|
||||
});
|
||||
|
||||
describe('POST /upload', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/upload');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require at least version 3 of Upload-Draft-Interop-Version header if provided', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('X-Immich-Asset-Data', makeAssetData())
|
||||
.set('Upload-Draft-Interop-Version', '2')
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
message: expect.arrayContaining(['version must not be less than 3']),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require X-Immich-Asset-Data header', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(expect.objectContaining({ message: 'x-immich-asset-data header is required' }));
|
||||
});
|
||||
|
||||
it('should require Repr-Digest header', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', makeAssetData())
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(expect.objectContaining({ message: 'Missing repr-digest header' }));
|
||||
});
|
||||
|
||||
it('should allow conventional upload without Upload-Complete header', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('X-Immich-Asset-Data', makeAssetData())
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(201);
|
||||
});
|
||||
|
||||
it('should require Upload-Length header for incomplete upload', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', makeAssetData())
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Complete', '?0')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(expect.objectContaining({ message: 'Missing upload-length header' }));
|
||||
});
|
||||
|
||||
it('should infer upload length from content length if complete upload', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', makeAssetData())
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Complete', '?1')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(201);
|
||||
});
|
||||
|
||||
it('should reject invalid Repr-Digest format', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', checksum)
|
||||
.set('Repr-Digest', 'invalid-format')
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(expect.objectContaining({ message: 'Invalid repr-digest header' }));
|
||||
});
|
||||
|
||||
it('should validate device-asset-id is required in asset data', async () => {
|
||||
const assetData = serializeDictionary({
|
||||
filename: 'test.jpg',
|
||||
'device-id': 'test-device',
|
||||
'file-created-at': new Date().toISOString(),
|
||||
'file-modified-at': new Date().toISOString(),
|
||||
});
|
||||
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
message: expect.arrayContaining([expect.stringContaining('deviceAssetId')]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate device-id is required in asset data', async () => {
|
||||
const assetData = serializeDictionary({
|
||||
filename: 'test.jpg',
|
||||
'device-asset-id': 'test-asset',
|
||||
'file-created-at': new Date().toISOString(),
|
||||
'file-modified-at': new Date().toISOString(),
|
||||
});
|
||||
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
message: expect.arrayContaining([expect.stringContaining('deviceId')]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate filename is required in asset data', async () => {
|
||||
const assetData = serializeDictionary({
|
||||
'device-asset-id': 'test-asset',
|
||||
'device-id': 'test-device',
|
||||
'file-created-at': new Date().toISOString(),
|
||||
'file-modified-at': new Date().toISOString(),
|
||||
});
|
||||
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
message: expect.arrayContaining([expect.stringContaining('filename')]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept Upload-Incomplete header for version 3', async () => {
|
||||
const { body, status } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '3')
|
||||
.set('X-Immich-Asset-Data', makeAssetData())
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Incomplete', '?0')
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(body).toEqual({});
|
||||
expect(status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should validate Upload-Complete is a boolean structured field', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', makeAssetData())
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Complete', 'true')
|
||||
.set('Upload-Length', '1024')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(expect.objectContaining({ message: 'upload-complete must be a structured boolean value' }));
|
||||
});
|
||||
|
||||
it('should validate Upload-Length is a positive integer', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', makeAssetData())
|
||||
.set('Repr-Digest', checksum)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Upload-Length', '-100')
|
||||
.send(buffer);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
message: expect.arrayContaining(['uploadLength must not be less than 1']),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /upload/:id', () => {
|
||||
const uploadId = factory.uuid();
|
||||
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).patch(`/upload/${uploadId}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require Upload-Draft-Interop-Version header', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.patch(`/upload/${uploadId}`)
|
||||
.set('Upload-Offset', '0')
|
||||
.set('Upload-Complete', '?1')
|
||||
.send(Buffer.from('test'));
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
message: expect.arrayContaining(['version must be an integer number', 'version must not be less than 3']),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require Upload-Offset header', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.patch(`/upload/${uploadId}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('Upload-Complete', '?1')
|
||||
.send(Buffer.from('test'));
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
message: expect.arrayContaining([
|
||||
'uploadOffset must be an integer number',
|
||||
'uploadOffset must not be less than 0',
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require Upload-Complete header', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.patch(`/upload/${uploadId}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('Upload-Offset', '0')
|
||||
.set('Content-Type', 'application/partial-upload')
|
||||
.send(Buffer.from('test'));
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(expect.objectContaining({ message: ['uploadComplete must be a boolean value'] }));
|
||||
});
|
||||
|
||||
it('should validate UUID parameter', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.patch('/upload/invalid-uuid')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('Upload-Offset', '0')
|
||||
.set('Upload-Complete', '?0')
|
||||
.send(Buffer.from('test'));
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(expect.objectContaining({ message: ['id must be a UUID'] }));
|
||||
});
|
||||
|
||||
it('should validate Upload-Offset is a non-negative integer', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.patch(`/upload/${uploadId}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('Upload-Offset', '-50')
|
||||
.set('Upload-Complete', '?0')
|
||||
.send(Buffer.from('test'));
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
message: expect.arrayContaining(['uploadOffset must not be less than 0']),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require Content-Type: application/partial-upload for version >= 6', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.patch(`/upload/${uploadId}`)
|
||||
.set('Upload-Draft-Interop-Version', '6')
|
||||
.set('Upload-Offset', '0')
|
||||
.set('Upload-Complete', '?0')
|
||||
.set('Content-Type', 'application/octet-stream')
|
||||
.send(Buffer.from('test'));
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
message: ['contentType must be equal to application/partial-upload'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow other Content-Type for version < 6', async () => {
|
||||
const { body } = await request(ctx.getHttpServer())
|
||||
.patch(`/upload/${uploadId}`)
|
||||
.set('Upload-Draft-Interop-Version', '3')
|
||||
.set('Upload-Offset', '0')
|
||||
.set('Upload-Incomplete', '?1')
|
||||
.set('Content-Type', 'application/octet-stream')
|
||||
.send();
|
||||
|
||||
// Will fail for other reasons, but not content-type validation
|
||||
expect(body).not.toEqual(
|
||||
expect.objectContaining({
|
||||
message: expect.arrayContaining([expect.stringContaining('contentType')]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept Upload-Incomplete header for version 3', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.patch(`/upload/${uploadId}`)
|
||||
.set('Upload-Draft-Interop-Version', '3')
|
||||
.set('Upload-Offset', '0')
|
||||
.set('Upload-Incomplete', '?1')
|
||||
.send();
|
||||
|
||||
// Should not fail validation
|
||||
expect(status).not.toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /upload/:id', () => {
|
||||
const uploadId = factory.uuid();
|
||||
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/upload/${uploadId}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate UUID parameter', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete('/upload/invalid-uuid');
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(expect.objectContaining({ message: ['id must be a UUID'] }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('HEAD /upload/:id', () => {
|
||||
const uploadId = factory.uuid();
|
||||
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).head(`/upload/${uploadId}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require Upload-Draft-Interop-Version header', async () => {
|
||||
const { status } = await request(ctx.getHttpServer()).head(`/upload/${uploadId}`);
|
||||
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('should validate UUID parameter', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.head('/upload/invalid-uuid')
|
||||
.set('Upload-Draft-Interop-Version', '8');
|
||||
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
108
server/src/controllers/asset-upload.controller.ts
Normal file
108
server/src/controllers/asset-upload.controller.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Controller, Delete, Head, HttpCode, HttpStatus, Options, Param, Patch, Post, Req, Res } from '@nestjs/common';
|
||||
import { ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { GetUploadStatusDto, Header, ResumeUploadDto, StartUploadDto, UploadOkDto } from 'src/dtos/asset-upload.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ImmichHeader, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { AssetUploadService } from 'src/services/asset-upload.service';
|
||||
import { validateSyncOrReject } from 'src/utils/request';
|
||||
import { UUIDParamDto } from 'src/validation';
|
||||
|
||||
const apiInteropVersion = {
|
||||
name: Header.InteropVersion,
|
||||
description: `Indicates the version of the RUFH protocol supported by the client.`,
|
||||
required: true,
|
||||
};
|
||||
|
||||
const apiUploadComplete = {
|
||||
name: Header.UploadComplete,
|
||||
description:
|
||||
'Structured boolean indicating whether this request completes the file. Use Upload-Incomplete instead for version <= 3.',
|
||||
required: true,
|
||||
};
|
||||
|
||||
const apiContentLength = {
|
||||
name: Header.ContentLength,
|
||||
description: 'Non-negative size of the request body in bytes.',
|
||||
required: true,
|
||||
};
|
||||
|
||||
// This is important to let go of the asset lock for an inactive request
|
||||
const SOCKET_TIMEOUT_MS = 30_000;
|
||||
|
||||
@ApiTags('Upload')
|
||||
@Controller('upload')
|
||||
export class AssetUploadController {
|
||||
constructor(private service: AssetUploadService) {}
|
||||
|
||||
@Post()
|
||||
@Authenticated({ sharedLink: true, permission: Permission.AssetUpload })
|
||||
@ApiHeader({
|
||||
name: ImmichHeader.AssetData,
|
||||
description: `RFC 9651 structured dictionary containing asset metadata with the following keys:
|
||||
- device-asset-id (string, required): Unique device asset identifier
|
||||
- device-id (string, required): Device identifier
|
||||
- file-created-at (string/date, required): ISO 8601 date string or Unix timestamp
|
||||
- file-modified-at (string/date, required): ISO 8601 date string or Unix timestamp
|
||||
- filename (string, required): Original filename
|
||||
- is-favorite (boolean, optional): Favorite status
|
||||
- live-photo-video-id (string, optional): Live photo ID for assets from iOS devices
|
||||
- icloud-id (string, optional): iCloud identifier for assets from iOS devices`,
|
||||
required: true,
|
||||
example:
|
||||
'device-asset-id="abc123", device-id="phone1", filename="photo.jpg", file-created-at="2024-01-01T00:00:00Z", file-modified-at="2024-01-01T00:00:00Z"',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: Header.ReprDigest,
|
||||
description:
|
||||
'RFC 9651 structured dictionary containing an `sha` (bytesequence) checksum used to detect duplicate files and validate data integrity.',
|
||||
required: true,
|
||||
})
|
||||
@ApiHeader({ ...apiInteropVersion, required: false })
|
||||
@ApiHeader({ ...apiUploadComplete, required: false })
|
||||
@ApiHeader(apiContentLength)
|
||||
@ApiOkResponse({ type: UploadOkDto })
|
||||
startUpload(@Auth() auth: AuthDto, @Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
res.setTimeout(SOCKET_TIMEOUT_MS);
|
||||
return this.service.startUpload(auth, req, res, validateSyncOrReject(StartUploadDto, req.headers));
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Authenticated({ sharedLink: true, permission: Permission.AssetUpload })
|
||||
@ApiHeader({
|
||||
name: Header.UploadOffset,
|
||||
description:
|
||||
'Non-negative byte offset indicating the starting position of the data in the request body within the entire file.',
|
||||
required: true,
|
||||
})
|
||||
@ApiHeader(apiInteropVersion)
|
||||
@ApiHeader(apiUploadComplete)
|
||||
@ApiHeader(apiContentLength)
|
||||
@ApiOkResponse({ type: UploadOkDto })
|
||||
resumeUpload(@Auth() auth: AuthDto, @Req() req: Request, @Res() res: Response, @Param() { id }: UUIDParamDto) {
|
||||
res.setTimeout(SOCKET_TIMEOUT_MS);
|
||||
return this.service.resumeUpload(auth, req, res, id, validateSyncOrReject(ResumeUploadDto, req.headers));
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Authenticated({ sharedLink: true, permission: Permission.AssetUpload })
|
||||
cancelUpload(@Auth() auth: AuthDto, @Res() res: Response, @Param() { id }: UUIDParamDto) {
|
||||
res.setTimeout(SOCKET_TIMEOUT_MS);
|
||||
return this.service.cancelUpload(auth, id, res);
|
||||
}
|
||||
|
||||
@Head(':id')
|
||||
@Authenticated({ sharedLink: true, permission: Permission.AssetUpload })
|
||||
@ApiHeader(apiInteropVersion)
|
||||
getUploadStatus(@Auth() auth: AuthDto, @Req() req: Request, @Res() res: Response, @Param() { id }: UUIDParamDto) {
|
||||
res.setTimeout(SOCKET_TIMEOUT_MS);
|
||||
return this.service.getUploadStatus(auth, res, id, validateSyncOrReject(GetUploadStatusDto, req.headers));
|
||||
}
|
||||
|
||||
@Options()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
getUploadOptions(@Res() res: Response) {
|
||||
return this.service.getUploadOptions(res);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,28 @@ describe(AssetController.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /assets/copy', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/copy`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require target and source id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put('/assets/copy').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(expect.arrayContaining(['sourceId must be a UUID', 'targetId must be a UUID'])),
|
||||
);
|
||||
});
|
||||
|
||||
it('should work', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.put('/assets/copy')
|
||||
.send({ sourceId: factory.uuid(), targetId: factory.uuid() });
|
||||
expect(status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /assets/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/123`);
|
||||
|
||||
@@ -81,6 +81,13 @@ export class AssetController {
|
||||
return this.service.get(auth, id) as Promise<AssetResponseDto>;
|
||||
}
|
||||
|
||||
@Put('copy')
|
||||
@Authenticated({ permission: Permission.AssetCopy })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
copyAsset(@Auth() auth: AuthDto, @Body() dto: AssetCopyDto): Promise<void> {
|
||||
return this.service.copy(auth, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@Authenticated({ permission: Permission.AssetUpdate })
|
||||
updateAsset(
|
||||
@@ -91,13 +98,6 @@ export class AssetController {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@Put('copy')
|
||||
@Authenticated({ permission: Permission.AssetCopy })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
copyAsset(@Auth() auth: AuthDto, @Body() dto: AssetCopyDto): Promise<void> {
|
||||
return this.service.copy(auth, dto);
|
||||
}
|
||||
|
||||
@Get(':id/metadata')
|
||||
@Authenticated({ permission: Permission.AssetRead })
|
||||
getAssetMetadata(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetMetadataResponseDto[]> {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AlbumController } from 'src/controllers/album.controller';
|
||||
import { ApiKeyController } from 'src/controllers/api-key.controller';
|
||||
import { AppController } from 'src/controllers/app.controller';
|
||||
import { AssetMediaController } from 'src/controllers/asset-media.controller';
|
||||
import { AssetUploadController } from 'src/controllers/asset-upload.controller';
|
||||
import { AssetController } from 'src/controllers/asset.controller';
|
||||
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
|
||||
import { AuthController } from 'src/controllers/auth.controller';
|
||||
@@ -40,6 +41,7 @@ export const controllers = [
|
||||
AppController,
|
||||
AssetController,
|
||||
AssetMediaController,
|
||||
AssetUploadController,
|
||||
AuthController,
|
||||
AuthAdminController,
|
||||
DownloadController,
|
||||
|
||||
@@ -356,7 +356,7 @@ export const columns = {
|
||||
'asset.stackId',
|
||||
'asset.libraryId',
|
||||
],
|
||||
syncAlbumUser: ['album_user.albumsId as albumId', 'album_user.usersId as userId', 'album_user.role'],
|
||||
syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'],
|
||||
syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],
|
||||
syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'],
|
||||
stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'],
|
||||
|
||||
196
server/src/dtos/asset-upload.dto.ts
Normal file
196
server/src/dtos/asset-upload.dto.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Expose, plainToInstance, Transform, Type } from 'class-transformer';
|
||||
import { Equals, IsBoolean, IsInt, IsNotEmpty, IsString, Min, ValidateIf, ValidateNested } from 'class-validator';
|
||||
import { ImmichHeader } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateDate } from 'src/validation';
|
||||
import { parseDictionary } from 'structured-headers';
|
||||
|
||||
export enum Header {
|
||||
ContentLength = 'content-length',
|
||||
ContentType = 'content-type',
|
||||
InteropVersion = 'upload-draft-interop-version',
|
||||
ReprDigest = 'repr-digest',
|
||||
UploadComplete = 'upload-complete',
|
||||
UploadIncomplete = 'upload-incomplete',
|
||||
UploadLength = 'upload-length',
|
||||
UploadOffset = 'upload-offset',
|
||||
}
|
||||
|
||||
export class UploadAssetDataDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
deviceAssetId!: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
deviceId!: string;
|
||||
|
||||
@ValidateDate()
|
||||
fileCreatedAt!: Date;
|
||||
|
||||
@ValidateDate()
|
||||
fileModifiedAt!: Date;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
filename!: string;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isFavorite?: boolean;
|
||||
|
||||
@Optional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
livePhotoVideoId?: string;
|
||||
|
||||
@Optional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
iCloudId!: string;
|
||||
}
|
||||
|
||||
export class BaseUploadHeadersDto {
|
||||
@Expose({ name: Header.ContentLength })
|
||||
@Min(0)
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
contentLength!: number;
|
||||
}
|
||||
|
||||
export class StartUploadDto extends BaseUploadHeadersDto {
|
||||
@Expose({ name: Header.InteropVersion })
|
||||
@Optional()
|
||||
@Min(3)
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
version?: number;
|
||||
|
||||
@Expose({ name: ImmichHeader.AssetData })
|
||||
@ValidateNested()
|
||||
@Transform(({ value }) => {
|
||||
if (!value) {
|
||||
throw new BadRequestException(`${ImmichHeader.AssetData} header is required`);
|
||||
}
|
||||
|
||||
try {
|
||||
const dict = parseDictionary(value);
|
||||
return plainToInstance(UploadAssetDataDto, {
|
||||
deviceAssetId: dict.get('device-asset-id')?.[0],
|
||||
deviceId: dict.get('device-id')?.[0],
|
||||
filename: dict.get('filename')?.[0],
|
||||
duration: dict.get('duration')?.[0],
|
||||
fileCreatedAt: dict.get('file-created-at')?.[0],
|
||||
fileModifiedAt: dict.get('file-modified-at')?.[0],
|
||||
isFavorite: dict.get('is-favorite')?.[0],
|
||||
livePhotoVideoId: dict.get('live-photo-video-id')?.[0],
|
||||
iCloudId: dict.get('icloud-id')?.[0],
|
||||
});
|
||||
} catch {
|
||||
throw new BadRequestException(`${ImmichHeader.AssetData} must be a valid structured dictionary`);
|
||||
}
|
||||
})
|
||||
assetData!: UploadAssetDataDto;
|
||||
|
||||
@Expose({ name: Header.ReprDigest })
|
||||
@Transform(({ value }) => {
|
||||
if (!value) {
|
||||
throw new BadRequestException(`Missing ${Header.ReprDigest} header`);
|
||||
}
|
||||
|
||||
const checksum = parseDictionary(value).get('sha')?.[0];
|
||||
if (checksum instanceof ArrayBuffer && checksum.byteLength === 20) {
|
||||
return Buffer.from(checksum);
|
||||
}
|
||||
throw new BadRequestException(`Invalid ${Header.ReprDigest} header`);
|
||||
})
|
||||
checksum!: Buffer;
|
||||
|
||||
@Expose()
|
||||
@Min(1)
|
||||
@IsInt()
|
||||
@Transform(({ obj }) => {
|
||||
const uploadLength = obj[Header.UploadLength];
|
||||
if (uploadLength != undefined) {
|
||||
return Number(uploadLength);
|
||||
}
|
||||
|
||||
const contentLength = obj[Header.ContentLength];
|
||||
if (contentLength && isUploadComplete(obj) !== false) {
|
||||
return Number(contentLength);
|
||||
}
|
||||
throw new BadRequestException(`Missing ${Header.UploadLength} header`);
|
||||
})
|
||||
uploadLength!: number;
|
||||
|
||||
@Expose()
|
||||
@Transform(({ obj }) => isUploadComplete(obj))
|
||||
uploadComplete?: boolean;
|
||||
}
|
||||
|
||||
export class ResumeUploadDto extends BaseUploadHeadersDto {
|
||||
@Expose({ name: Header.InteropVersion })
|
||||
@Min(3)
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
version!: number;
|
||||
|
||||
@Expose({ name: Header.ContentType })
|
||||
@ValidateIf((o) => o.version && o.version >= 6)
|
||||
@Equals('application/partial-upload')
|
||||
contentType!: string;
|
||||
|
||||
@Expose({ name: Header.UploadLength })
|
||||
@Min(1)
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
@Optional()
|
||||
uploadLength?: number;
|
||||
|
||||
@Expose({ name: Header.UploadOffset })
|
||||
@Min(0)
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
uploadOffset!: number;
|
||||
|
||||
@Expose()
|
||||
@IsBoolean()
|
||||
@Transform(({ obj }) => isUploadComplete(obj))
|
||||
uploadComplete!: boolean;
|
||||
}
|
||||
|
||||
export class GetUploadStatusDto {
|
||||
@Expose({ name: Header.InteropVersion })
|
||||
@Min(3)
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
version!: number;
|
||||
}
|
||||
|
||||
export class UploadOkDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
}
|
||||
|
||||
const STRUCTURED_TRUE = '?1';
|
||||
const STRUCTURED_FALSE = '?0';
|
||||
|
||||
function isUploadComplete(obj: any) {
|
||||
const uploadComplete = obj[Header.UploadComplete];
|
||||
if (uploadComplete === STRUCTURED_TRUE) {
|
||||
return true;
|
||||
} else if (uploadComplete === STRUCTURED_FALSE) {
|
||||
return false;
|
||||
} else if (uploadComplete !== undefined) {
|
||||
throw new BadRequestException('upload-complete must be a structured boolean value');
|
||||
}
|
||||
|
||||
const uploadIncomplete = obj[Header.UploadIncomplete];
|
||||
if (uploadIncomplete === STRUCTURED_TRUE) {
|
||||
return false;
|
||||
} else if (uploadIncomplete === STRUCTURED_FALSE) {
|
||||
return true;
|
||||
} else if (uploadComplete !== undefined) {
|
||||
throw new BadRequestException('upload-incomplete must be a structured boolean value');
|
||||
}
|
||||
}
|
||||
@@ -55,11 +55,23 @@ export class DatabaseBackupConfig {
|
||||
keepLastAmount!: number;
|
||||
}
|
||||
|
||||
export class UploadBackupConfig {
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
@IsNotEmpty()
|
||||
maxAgeHours!: number;
|
||||
}
|
||||
|
||||
export class SystemConfigBackupsDto {
|
||||
@Type(() => DatabaseBackupConfig)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
database!: DatabaseBackupConfig;
|
||||
|
||||
@Type(() => UploadBackupConfig)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
upload!: UploadBackupConfig;
|
||||
}
|
||||
|
||||
export class SystemConfigFFmpegDto {
|
||||
@@ -355,6 +367,9 @@ class SystemConfigNightlyTasksDto {
|
||||
|
||||
@ValidateBoolean()
|
||||
syncQuotaUsage!: boolean;
|
||||
|
||||
@ValidateBoolean()
|
||||
removeStaleUploads!: boolean;
|
||||
}
|
||||
|
||||
class SystemConfigOAuthDto {
|
||||
|
||||
@@ -20,6 +20,7 @@ export enum ImmichHeader {
|
||||
SharedLinkSlug = 'x-immich-share-slug',
|
||||
Checksum = 'x-immich-checksum',
|
||||
Cid = 'x-immich-cid',
|
||||
AssetData = 'x-immich-asset-data',
|
||||
}
|
||||
|
||||
export enum ImmichQuery {
|
||||
@@ -306,6 +307,7 @@ export enum AssetStatus {
|
||||
Active = 'active',
|
||||
Trashed = 'trashed',
|
||||
Deleted = 'deleted',
|
||||
Partial = 'partial',
|
||||
}
|
||||
|
||||
export enum SourceType {
|
||||
@@ -496,6 +498,7 @@ export enum BootstrapEventPriority {
|
||||
JobService = -190,
|
||||
// Initialise config after other bootstrap services, stop other services from using config on bootstrap
|
||||
SystemConfig = 100,
|
||||
UploadService = 200,
|
||||
}
|
||||
|
||||
export enum QueueName {
|
||||
@@ -532,6 +535,8 @@ export enum JobName {
|
||||
AssetFileMigration = 'AssetFileMigration',
|
||||
AssetGenerateThumbnailsQueueAll = 'AssetGenerateThumbnailsQueueAll',
|
||||
AssetGenerateThumbnails = 'AssetGenerateThumbnails',
|
||||
PartialAssetCleanup = 'PartialAssetCleanup',
|
||||
PartialAssetCleanupQueueAll = 'PartialAssetCleanupQueueAll',
|
||||
|
||||
AuditLogCleanup = 'AuditLogCleanup',
|
||||
AuditTableCleanup = 'AuditTableCleanup',
|
||||
|
||||
@@ -25,8 +25,8 @@ select
|
||||
"album"."id"
|
||||
from
|
||||
"album"
|
||||
left join "album_user" as "albumUsers" on "albumUsers"."albumsId" = "album"."id"
|
||||
left join "user" on "user"."id" = "albumUsers"."usersId"
|
||||
left join "album_user" as "albumUsers" on "albumUsers"."albumId" = "album"."id"
|
||||
left join "user" on "user"."id" = "albumUsers"."userId"
|
||||
and "user"."deletedAt" is null
|
||||
where
|
||||
"album"."id" in ($1)
|
||||
@@ -52,8 +52,8 @@ select
|
||||
"album"."id"
|
||||
from
|
||||
"album"
|
||||
left join "album_user" on "album_user"."albumsId" = "album"."id"
|
||||
left join "user" on "user"."id" = "album_user"."usersId"
|
||||
left join "album_user" on "album_user"."albumId" = "album"."id"
|
||||
left join "user" on "user"."id" = "album_user"."userId"
|
||||
and "user"."deletedAt" is null
|
||||
where
|
||||
"album"."id" in ($1)
|
||||
@@ -81,11 +81,11 @@ select
|
||||
"asset"."livePhotoVideoId"
|
||||
from
|
||||
"album"
|
||||
inner join "album_asset" as "albumAssets" on "album"."id" = "albumAssets"."albumsId"
|
||||
inner join "asset" on "asset"."id" = "albumAssets"."assetsId"
|
||||
inner join "album_asset" as "albumAssets" on "album"."id" = "albumAssets"."albumId"
|
||||
inner join "asset" on "asset"."id" = "albumAssets"."assetId"
|
||||
and "asset"."deletedAt" is null
|
||||
left join "album_user" as "albumUsers" on "albumUsers"."albumsId" = "album"."id"
|
||||
left join "user" on "user"."id" = "albumUsers"."usersId"
|
||||
left join "album_user" as "albumUsers" on "albumUsers"."albumId" = "album"."id"
|
||||
left join "user" on "user"."id" = "albumUsers"."userId"
|
||||
and "user"."deletedAt" is null
|
||||
cross join "target"
|
||||
where
|
||||
@@ -136,11 +136,11 @@ from
|
||||
"shared_link"
|
||||
left join "album" on "album"."id" = "shared_link"."albumId"
|
||||
and "album"."deletedAt" is null
|
||||
left join "shared_link_asset" on "shared_link_asset"."sharedLinksId" = "shared_link"."id"
|
||||
left join "asset" on "asset"."id" = "shared_link_asset"."assetsId"
|
||||
left join "shared_link_asset" on "shared_link_asset"."sharedLinkId" = "shared_link"."id"
|
||||
left join "asset" on "asset"."id" = "shared_link_asset"."assetId"
|
||||
and "asset"."deletedAt" is null
|
||||
left join "album_asset" on "album_asset"."albumsId" = "album"."id"
|
||||
left join "asset" as "albumAssets" on "albumAssets"."id" = "album_asset"."assetsId"
|
||||
left join "album_asset" on "album_asset"."albumId" = "album"."id"
|
||||
left join "asset" as "albumAssets" on "albumAssets"."id" = "album_asset"."assetId"
|
||||
and "albumAssets"."deletedAt" is null
|
||||
where
|
||||
"shared_link"."id" = $1
|
||||
|
||||
@@ -43,13 +43,13 @@ select
|
||||
from
|
||||
"user"
|
||||
where
|
||||
"user"."id" = "album_user"."usersId"
|
||||
"user"."id" = "album_user"."userId"
|
||||
) as obj
|
||||
) as "user"
|
||||
from
|
||||
"album_user"
|
||||
where
|
||||
"album_user"."albumsId" = "album"."id"
|
||||
"album_user"."albumId" = "album"."id"
|
||||
) as agg
|
||||
) as "albumUsers",
|
||||
(
|
||||
@@ -76,9 +76,9 @@ select
|
||||
from
|
||||
"asset"
|
||||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
inner join "album_asset" on "album_asset"."assetsId" = "asset"."id"
|
||||
inner join "album_asset" on "album_asset"."assetId" = "asset"."id"
|
||||
where
|
||||
"album_asset"."albumsId" = "album"."id"
|
||||
"album_asset"."albumId" = "album"."id"
|
||||
and "asset"."deletedAt" is null
|
||||
and "asset"."visibility" in ('archive', 'timeline')
|
||||
order by
|
||||
@@ -134,18 +134,18 @@ select
|
||||
from
|
||||
"user"
|
||||
where
|
||||
"user"."id" = "album_user"."usersId"
|
||||
"user"."id" = "album_user"."userId"
|
||||
) as obj
|
||||
) as "user"
|
||||
from
|
||||
"album_user"
|
||||
where
|
||||
"album_user"."albumsId" = "album"."id"
|
||||
"album_user"."albumId" = "album"."id"
|
||||
) as agg
|
||||
) as "albumUsers"
|
||||
from
|
||||
"album"
|
||||
inner join "album_asset" on "album_asset"."albumsId" = "album"."id"
|
||||
inner join "album_asset" on "album_asset"."albumId" = "album"."id"
|
||||
where
|
||||
(
|
||||
"album"."ownerId" = $1
|
||||
@@ -154,11 +154,11 @@ where
|
||||
from
|
||||
"album_user"
|
||||
where
|
||||
"album_user"."albumsId" = "album"."id"
|
||||
and "album_user"."usersId" = $2
|
||||
"album_user"."albumId" = "album"."id"
|
||||
and "album_user"."userId" = $2
|
||||
)
|
||||
)
|
||||
and "album_asset"."assetsId" = $3
|
||||
and "album_asset"."assetId" = $3
|
||||
and "album"."deletedAt" is null
|
||||
order by
|
||||
"album"."createdAt" desc,
|
||||
@@ -166,7 +166,7 @@ order by
|
||||
|
||||
-- AlbumRepository.getMetadataForIds
|
||||
select
|
||||
"album_asset"."albumsId" as "albumId",
|
||||
"album_asset"."albumId" as "albumId",
|
||||
min(
|
||||
("asset"."localDateTime" AT TIME ZONE 'UTC'::text)::date
|
||||
) as "startDate",
|
||||
@@ -177,13 +177,13 @@ select
|
||||
count("asset"."id")::int as "assetCount"
|
||||
from
|
||||
"asset"
|
||||
inner join "album_asset" on "album_asset"."assetsId" = "asset"."id"
|
||||
inner join "album_asset" on "album_asset"."assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."visibility" in ('archive', 'timeline')
|
||||
and "album_asset"."albumsId" in ($1)
|
||||
and "album_asset"."albumId" in ($1)
|
||||
and "asset"."deletedAt" is null
|
||||
group by
|
||||
"album_asset"."albumsId"
|
||||
"album_asset"."albumId"
|
||||
|
||||
-- AlbumRepository.getOwned
|
||||
select
|
||||
@@ -228,13 +228,13 @@ select
|
||||
from
|
||||
"user"
|
||||
where
|
||||
"user"."id" = "album_user"."usersId"
|
||||
"user"."id" = "album_user"."userId"
|
||||
) as obj
|
||||
) as "user"
|
||||
from
|
||||
"album_user"
|
||||
where
|
||||
"album_user"."albumsId" = "album"."id"
|
||||
"album_user"."albumId" = "album"."id"
|
||||
) as agg
|
||||
) as "albumUsers",
|
||||
(
|
||||
@@ -283,13 +283,13 @@ select
|
||||
from
|
||||
"user"
|
||||
where
|
||||
"user"."id" = "album_user"."usersId"
|
||||
"user"."id" = "album_user"."userId"
|
||||
) as obj
|
||||
) as "user"
|
||||
from
|
||||
"album_user"
|
||||
where
|
||||
"album_user"."albumsId" = "album"."id"
|
||||
"album_user"."albumId" = "album"."id"
|
||||
) as agg
|
||||
) as "albumUsers",
|
||||
(
|
||||
@@ -332,10 +332,10 @@ where
|
||||
from
|
||||
"album_user"
|
||||
where
|
||||
"album_user"."albumsId" = "album"."id"
|
||||
"album_user"."albumId" = "album"."id"
|
||||
and (
|
||||
"album"."ownerId" = $1
|
||||
or "album_user"."usersId" = $2
|
||||
or "album_user"."userId" = $2
|
||||
)
|
||||
)
|
||||
or exists (
|
||||
@@ -382,7 +382,7 @@ where
|
||||
from
|
||||
"album_user"
|
||||
where
|
||||
"album_user"."albumsId" = "album"."id"
|
||||
"album_user"."albumId" = "album"."id"
|
||||
)
|
||||
and not exists (
|
||||
select
|
||||
@@ -397,7 +397,7 @@ order by
|
||||
-- AlbumRepository.removeAssetsFromAll
|
||||
delete from "album_asset"
|
||||
where
|
||||
"album_asset"."assetsId" in ($1)
|
||||
"album_asset"."assetId" in ($1)
|
||||
|
||||
-- AlbumRepository.getAssetIds
|
||||
select
|
||||
@@ -405,8 +405,8 @@ select
|
||||
from
|
||||
"album_asset"
|
||||
where
|
||||
"album_asset"."albumsId" = $1
|
||||
and "album_asset"."assetsId" in ($2)
|
||||
"album_asset"."albumId" = $1
|
||||
and "album_asset"."assetId" in ($2)
|
||||
|
||||
-- AlbumRepository.getContributorCounts
|
||||
select
|
||||
@@ -414,10 +414,10 @@ select
|
||||
count(*) as "assetCount"
|
||||
from
|
||||
"album_asset"
|
||||
inner join "asset" on "asset"."id" = "assetsId"
|
||||
inner join "asset" on "asset"."id" = "assetId"
|
||||
where
|
||||
"asset"."deletedAt" is null
|
||||
and "album_asset"."albumsId" = $1
|
||||
and "album_asset"."albumId" = $1
|
||||
group by
|
||||
"asset"."ownerId"
|
||||
order by
|
||||
@@ -427,10 +427,10 @@ order by
|
||||
insert into
|
||||
"album_asset"
|
||||
select
|
||||
"album_asset"."albumsId",
|
||||
$1 as "assetsId"
|
||||
"album_asset"."albumId",
|
||||
$1 as "assetId"
|
||||
from
|
||||
"album_asset"
|
||||
where
|
||||
"album_asset"."assetsId" = $2
|
||||
"album_asset"."assetId" = $2
|
||||
on conflict do nothing
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
-- AlbumUserRepository.create
|
||||
insert into
|
||||
"album_user" ("usersId", "albumsId")
|
||||
"album_user" ("userId", "albumId")
|
||||
values
|
||||
($1, $2)
|
||||
returning
|
||||
"usersId",
|
||||
"albumsId",
|
||||
"userId",
|
||||
"albumId",
|
||||
"role"
|
||||
|
||||
-- AlbumUserRepository.update
|
||||
@@ -15,13 +15,13 @@ update "album_user"
|
||||
set
|
||||
"role" = $1
|
||||
where
|
||||
"usersId" = $2
|
||||
and "albumsId" = $3
|
||||
"userId" = $2
|
||||
and "albumId" = $3
|
||||
returning
|
||||
*
|
||||
|
||||
-- AlbumUserRepository.delete
|
||||
delete from "album_user"
|
||||
where
|
||||
"usersId" = $1
|
||||
and "albumsId" = $2
|
||||
"userId" = $1
|
||||
and "albumId" = $2
|
||||
|
||||
@@ -14,6 +14,7 @@ from
|
||||
left join "smart_search" on "asset"."id" = "smart_search"."assetId"
|
||||
where
|
||||
"asset"."id" = $1::uuid
|
||||
and "asset"."status" != 'partial'
|
||||
limit
|
||||
$2
|
||||
|
||||
@@ -31,15 +32,16 @@ select
|
||||
"tag"."value"
|
||||
from
|
||||
"tag"
|
||||
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagsId"
|
||||
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId"
|
||||
where
|
||||
"asset"."id" = "tag_asset"."assetsId"
|
||||
"asset"."id" = "tag_asset"."assetId"
|
||||
) as agg
|
||||
) as "tags"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $1::uuid
|
||||
and "asset"."status" != 'partial'
|
||||
limit
|
||||
$2
|
||||
|
||||
@@ -52,6 +54,7 @@ from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $1::uuid
|
||||
and "asset"."status" != 'partial'
|
||||
limit
|
||||
$2
|
||||
|
||||
@@ -78,7 +81,8 @@ from
|
||||
"asset"
|
||||
inner join "asset_job_status" on "asset_job_status"."assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."deletedAt" is null
|
||||
"asset"."status" != 'partial'
|
||||
and "asset"."deletedAt" is null
|
||||
and "asset"."visibility" != $1
|
||||
and (
|
||||
"asset_job_status"."previewAt" is null
|
||||
@@ -110,6 +114,7 @@ from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.getForGenerateThumbnailJob
|
||||
select
|
||||
@@ -141,6 +146,7 @@ from
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.getForMetadataExtraction
|
||||
select
|
||||
@@ -178,6 +184,7 @@ from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.getAlbumThumbnailFiles
|
||||
select
|
||||
@@ -198,7 +205,8 @@ from
|
||||
inner join "smart_search" on "asset"."id" = "smart_search"."assetId"
|
||||
inner join "asset_job_status" as "job_status" on "job_status"."assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."deletedAt" is null
|
||||
"asset"."status" != 'partial'
|
||||
and "asset"."deletedAt" is null
|
||||
and "asset"."visibility" in ('archive', 'timeline')
|
||||
and "job_status"."duplicatesDetectedAt" is null
|
||||
|
||||
@@ -210,6 +218,7 @@ from
|
||||
inner join "asset_job_status" as "job_status" on "assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."visibility" != $1
|
||||
and "asset"."status" != 'partial'
|
||||
and "asset"."deletedAt" is null
|
||||
and "job_status"."previewAt" is not null
|
||||
and not exists (
|
||||
@@ -244,6 +253,7 @@ from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $2
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.getForDetectFacesJob
|
||||
select
|
||||
@@ -284,6 +294,7 @@ from
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."id" = $2
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.getForOcr
|
||||
select
|
||||
@@ -385,6 +396,7 @@ from
|
||||
) as "stacked_assets" on "stack"."id" is not null
|
||||
where
|
||||
"asset"."id" = $2
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.streamForVideoConversion
|
||||
select
|
||||
@@ -398,6 +410,7 @@ where
|
||||
or "asset"."encodedVideoPath" = $2
|
||||
)
|
||||
and "asset"."visibility" != $3
|
||||
and "asset"."status" != 'partial'
|
||||
and "asset"."deletedAt" is null
|
||||
|
||||
-- AssetJobRepository.getForVideoConversion
|
||||
@@ -411,6 +424,7 @@ from
|
||||
where
|
||||
"asset"."id" = $1
|
||||
and "asset"."type" = $2
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.streamForMetadataExtraction
|
||||
select
|
||||
@@ -423,6 +437,7 @@ where
|
||||
"asset_job_status"."metadataExtractedAt" is null
|
||||
or "asset_job_status"."assetId" is null
|
||||
)
|
||||
and "asset"."status" != 'partial'
|
||||
and "asset"."deletedAt" is null
|
||||
|
||||
-- AssetJobRepository.getForStorageTemplateJob
|
||||
@@ -443,7 +458,8 @@ from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."deletedAt" is null
|
||||
"asset"."status" != 'partial'
|
||||
and "asset"."deletedAt" is null
|
||||
and "asset"."id" = $1
|
||||
|
||||
-- AssetJobRepository.streamForStorageTemplateJob
|
||||
@@ -464,7 +480,8 @@ from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."deletedAt" is null
|
||||
"asset"."status" != 'partial'
|
||||
and "asset"."deletedAt" is null
|
||||
|
||||
-- AssetJobRepository.streamForDeletedJob
|
||||
select
|
||||
@@ -474,6 +491,7 @@ from
|
||||
"asset"
|
||||
where
|
||||
"asset"."deletedAt" <= $1
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.streamForSidecar
|
||||
select
|
||||
@@ -486,6 +504,7 @@ where
|
||||
or "asset"."sidecarPath" is null
|
||||
)
|
||||
and "asset"."visibility" != $2
|
||||
and "asset"."status" != 'partial'
|
||||
|
||||
-- AssetJobRepository.streamForDetectFacesJob
|
||||
select
|
||||
@@ -495,8 +514,10 @@ from
|
||||
inner join "asset_job_status" as "job_status" on "assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."visibility" != $1
|
||||
and "asset"."status" != 'partial'
|
||||
and "asset"."deletedAt" is null
|
||||
and "job_status"."previewAt" is not null
|
||||
and "asset"."status" != 'partial'
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
|
||||
@@ -517,4 +538,14 @@ select
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."deletedAt" is null
|
||||
"asset"."status" != 'partial'
|
||||
and "asset"."deletedAt" is null
|
||||
|
||||
-- AssetJobRepository.streamForPartialAssetCleanupJob
|
||||
select
|
||||
"id"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."status" = 'partial'
|
||||
and "asset"."createdAt" < $1
|
||||
|
||||
@@ -46,6 +46,68 @@ where
|
||||
"assetId" = $1
|
||||
and "key" = $2
|
||||
|
||||
-- AssetRepository.getCompletionMetadata
|
||||
select
|
||||
"originalPath" as "path",
|
||||
"status",
|
||||
"fileModifiedAt",
|
||||
"createdAt",
|
||||
"checksum",
|
||||
"fileSizeInByte" as "size"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"id" = $1
|
||||
and "ownerId" = $2
|
||||
|
||||
-- AssetRepository.setComplete
|
||||
update "asset" as "complete_asset"
|
||||
set
|
||||
"status" = 'active',
|
||||
"visibility" = case
|
||||
when (
|
||||
"complete_asset"."type" = 'VIDEO'
|
||||
and exists (
|
||||
select
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"complete_asset"."id" = "asset"."livePhotoVideoId"
|
||||
)
|
||||
) then 'hidden'::asset_visibility_enum
|
||||
else 'timeline'::asset_visibility_enum
|
||||
end
|
||||
where
|
||||
"id" = $1
|
||||
and "status" = 'partial'
|
||||
|
||||
-- AssetRepository.removeAndDecrementQuota
|
||||
with
|
||||
"asset_exif" as (
|
||||
select
|
||||
"fileSizeInByte"
|
||||
from
|
||||
"asset_exif"
|
||||
where
|
||||
"assetId" = $1
|
||||
),
|
||||
"asset" as (
|
||||
delete from "asset"
|
||||
where
|
||||
"id" = $2
|
||||
returning
|
||||
"ownerId"
|
||||
)
|
||||
update "user"
|
||||
set
|
||||
"quotaUsageInBytes" = "quotaUsageInBytes" - "fileSizeInByte"
|
||||
from
|
||||
"asset_exif",
|
||||
"asset"
|
||||
where
|
||||
"user"."id" = "asset"."ownerId"
|
||||
|
||||
-- AssetRepository.getByDayOfYear
|
||||
with
|
||||
"res" as (
|
||||
@@ -160,9 +222,9 @@ select
|
||||
"tag"."parentId"
|
||||
from
|
||||
"tag"
|
||||
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagsId"
|
||||
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId"
|
||||
where
|
||||
"asset"."id" = "tag_asset"."assetsId"
|
||||
"asset"."id" = "tag_asset"."assetId"
|
||||
) as agg
|
||||
) as "tags",
|
||||
to_json("asset_exif") as "exifInfo"
|
||||
@@ -258,7 +320,9 @@ where
|
||||
|
||||
-- AssetRepository.getUploadAssetIdByChecksum
|
||||
select
|
||||
"id"
|
||||
"id",
|
||||
"status",
|
||||
"createdAt"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
|
||||
@@ -23,8 +23,8 @@ where
|
||||
from
|
||||
"album_asset"
|
||||
where
|
||||
"asset"."id" = "album_asset"."assetsId"
|
||||
and "album_asset"."albumsId" in ($3)
|
||||
"asset"."id" = "album_asset"."assetId"
|
||||
and "album_asset"."albumId" in ($3)
|
||||
)
|
||||
)
|
||||
order by
|
||||
|
||||
@@ -37,7 +37,7 @@ select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetsId"
|
||||
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetId"
|
||||
where
|
||||
"memory_asset"."memoriesId" = "memory"."id"
|
||||
and "asset"."visibility" = 'timeline'
|
||||
@@ -66,7 +66,7 @@ select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetsId"
|
||||
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetId"
|
||||
where
|
||||
"memory_asset"."memoriesId" = "memory"."id"
|
||||
and "asset"."visibility" = 'timeline'
|
||||
@@ -104,7 +104,7 @@ select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetsId"
|
||||
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetId"
|
||||
where
|
||||
"memory_asset"."memoriesId" = "memory"."id"
|
||||
and "asset"."visibility" = 'timeline'
|
||||
@@ -137,7 +137,7 @@ select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetsId"
|
||||
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetId"
|
||||
where
|
||||
"memory_asset"."memoriesId" = "memory"."id"
|
||||
and "asset"."visibility" = 'timeline'
|
||||
@@ -159,15 +159,15 @@ where
|
||||
|
||||
-- MemoryRepository.getAssetIds
|
||||
select
|
||||
"assetsId"
|
||||
"assetId"
|
||||
from
|
||||
"memory_asset"
|
||||
where
|
||||
"memoriesId" = $1
|
||||
and "assetsId" in ($2)
|
||||
and "assetId" in ($2)
|
||||
|
||||
-- MemoryRepository.addAssetIds
|
||||
insert into
|
||||
"memory_asset" ("memoriesId", "assetsId")
|
||||
"memory_asset" ("memoriesId", "assetId")
|
||||
values
|
||||
($1, $2)
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
insert into
|
||||
"shared_link_asset"
|
||||
select
|
||||
$1 as "assetsId",
|
||||
"shared_link_asset"."sharedLinksId"
|
||||
$1 as "assetId",
|
||||
"shared_link_asset"."sharedLinkId"
|
||||
from
|
||||
"shared_link_asset"
|
||||
where
|
||||
"shared_link_asset"."assetsId" = $2
|
||||
"shared_link_asset"."assetId" = $2
|
||||
on conflict do nothing
|
||||
|
||||
@@ -19,7 +19,7 @@ from
|
||||
to_json("exifInfo") as "exifInfo"
|
||||
from
|
||||
"shared_link_asset"
|
||||
inner join "asset" on "asset"."id" = "shared_link_asset"."assetsId"
|
||||
inner join "asset" on "asset"."id" = "shared_link_asset"."assetId"
|
||||
inner join lateral (
|
||||
select
|
||||
"asset_exif".*
|
||||
@@ -29,7 +29,7 @@ from
|
||||
"asset_exif"."assetId" = "asset"."id"
|
||||
) as "exifInfo" on true
|
||||
where
|
||||
"shared_link"."id" = "shared_link_asset"."sharedLinksId"
|
||||
"shared_link"."id" = "shared_link_asset"."sharedLinkId"
|
||||
and "asset"."deletedAt" is null
|
||||
order by
|
||||
"asset"."fileCreatedAt" asc
|
||||
@@ -51,7 +51,7 @@ from
|
||||
to_json("owner") as "owner"
|
||||
from
|
||||
"album"
|
||||
left join "album_asset" on "album_asset"."albumsId" = "album"."id"
|
||||
left join "album_asset" on "album_asset"."albumId" = "album"."id"
|
||||
left join lateral (
|
||||
select
|
||||
"asset".*,
|
||||
@@ -67,7 +67,7 @@ from
|
||||
"asset_exif"."assetId" = "asset"."id"
|
||||
) as "exifInfo" on true
|
||||
where
|
||||
"album_asset"."assetsId" = "asset"."id"
|
||||
"album_asset"."assetId" = "asset"."id"
|
||||
and "asset"."deletedAt" is null
|
||||
order by
|
||||
"asset"."fileCreatedAt" asc
|
||||
@@ -108,14 +108,14 @@ select distinct
|
||||
to_json("album") as "album"
|
||||
from
|
||||
"shared_link"
|
||||
left join "shared_link_asset" on "shared_link_asset"."sharedLinksId" = "shared_link"."id"
|
||||
left join "shared_link_asset" on "shared_link_asset"."sharedLinkId" = "shared_link"."id"
|
||||
left join lateral (
|
||||
select
|
||||
json_agg("asset") as "assets"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = "shared_link_asset"."assetsId"
|
||||
"asset"."id" = "shared_link_asset"."assetId"
|
||||
and "asset"."deletedAt" is null
|
||||
) as "assets" on true
|
||||
left join lateral (
|
||||
|
||||
@@ -89,9 +89,9 @@ select
|
||||
"tag"."parentId"
|
||||
from
|
||||
"tag"
|
||||
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagsId"
|
||||
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId"
|
||||
where
|
||||
"tag_asset"."assetsId" = "asset"."id"
|
||||
"tag_asset"."assetId" = "asset"."id"
|
||||
) as agg
|
||||
) as "tags",
|
||||
to_json("exifInfo") as "exifInfo"
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
-- SyncRepository.album.getCreatedAfter
|
||||
select
|
||||
"albumsId" as "id",
|
||||
"albumId" as "id",
|
||||
"createId"
|
||||
from
|
||||
"album_user"
|
||||
where
|
||||
"usersId" = $1
|
||||
"userId" = $1
|
||||
and "createId" >= $2
|
||||
and "createId" < $3
|
||||
order by
|
||||
@@ -40,13 +40,13 @@ select distinct
|
||||
"album"."updateId"
|
||||
from
|
||||
"album" as "album"
|
||||
left join "album_user" as "album_users" on "album"."id" = "album_users"."albumsId"
|
||||
left join "album_user" as "album_users" on "album"."id" = "album_users"."albumId"
|
||||
where
|
||||
"album"."updateId" < $1
|
||||
and "album"."updateId" > $2
|
||||
and (
|
||||
"album"."ownerId" = $3
|
||||
or "album_users"."usersId" = $4
|
||||
or "album_users"."userId" = $4
|
||||
)
|
||||
order by
|
||||
"album"."updateId" asc
|
||||
@@ -72,12 +72,12 @@ select
|
||||
"album_asset"."updateId"
|
||||
from
|
||||
"album_asset" as "album_asset"
|
||||
inner join "asset" on "asset"."id" = "album_asset"."assetsId"
|
||||
inner join "asset" on "asset"."id" = "album_asset"."assetId"
|
||||
where
|
||||
"album_asset"."updateId" < $1
|
||||
and "album_asset"."updateId" <= $2
|
||||
and "album_asset"."updateId" >= $3
|
||||
and "album_asset"."albumsId" = $4
|
||||
and "album_asset"."albumId" = $4
|
||||
order by
|
||||
"album_asset"."updateId" asc
|
||||
|
||||
@@ -102,16 +102,16 @@ select
|
||||
"asset"."updateId"
|
||||
from
|
||||
"asset" as "asset"
|
||||
inner join "album_asset" on "album_asset"."assetsId" = "asset"."id"
|
||||
inner join "album" on "album"."id" = "album_asset"."albumsId"
|
||||
left join "album_user" on "album_user"."albumsId" = "album_asset"."albumsId"
|
||||
inner join "album_asset" on "album_asset"."assetId" = "asset"."id"
|
||||
inner join "album" on "album"."id" = "album_asset"."albumId"
|
||||
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
|
||||
where
|
||||
"asset"."updateId" < $1
|
||||
and "asset"."updateId" > $2
|
||||
and "album_asset"."updateId" <= $3
|
||||
and (
|
||||
"album"."ownerId" = $4
|
||||
or "album_user"."usersId" = $5
|
||||
or "album_user"."userId" = $5
|
||||
)
|
||||
order by
|
||||
"asset"."updateId" asc
|
||||
@@ -137,15 +137,15 @@ select
|
||||
"asset"."libraryId"
|
||||
from
|
||||
"album_asset" as "album_asset"
|
||||
inner join "asset" on "asset"."id" = "album_asset"."assetsId"
|
||||
inner join "album" on "album"."id" = "album_asset"."albumsId"
|
||||
left join "album_user" on "album_user"."albumsId" = "album_asset"."albumsId"
|
||||
inner join "asset" on "asset"."id" = "album_asset"."assetId"
|
||||
inner join "album" on "album"."id" = "album_asset"."albumId"
|
||||
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
|
||||
where
|
||||
"album_asset"."updateId" < $1
|
||||
and "album_asset"."updateId" > $2
|
||||
and (
|
||||
"album"."ownerId" = $3
|
||||
or "album_user"."usersId" = $4
|
||||
or "album_user"."userId" = $4
|
||||
)
|
||||
order by
|
||||
"album_asset"."updateId" asc
|
||||
@@ -180,12 +180,12 @@ select
|
||||
"album_asset"."updateId"
|
||||
from
|
||||
"album_asset" as "album_asset"
|
||||
inner join "asset_exif" on "asset_exif"."assetId" = "album_asset"."assetsId"
|
||||
inner join "asset_exif" on "asset_exif"."assetId" = "album_asset"."assetId"
|
||||
where
|
||||
"album_asset"."updateId" < $1
|
||||
and "album_asset"."updateId" <= $2
|
||||
and "album_asset"."updateId" >= $3
|
||||
and "album_asset"."albumsId" = $4
|
||||
and "album_asset"."albumId" = $4
|
||||
order by
|
||||
"album_asset"."updateId" asc
|
||||
|
||||
@@ -219,16 +219,16 @@ select
|
||||
"asset_exif"."updateId"
|
||||
from
|
||||
"asset_exif" as "asset_exif"
|
||||
inner join "album_asset" on "album_asset"."assetsId" = "asset_exif"."assetId"
|
||||
inner join "album" on "album"."id" = "album_asset"."albumsId"
|
||||
left join "album_user" on "album_user"."albumsId" = "album_asset"."albumsId"
|
||||
inner join "album_asset" on "album_asset"."assetId" = "asset_exif"."assetId"
|
||||
inner join "album" on "album"."id" = "album_asset"."albumId"
|
||||
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
|
||||
where
|
||||
"asset_exif"."updateId" < $1
|
||||
and "asset_exif"."updateId" > $2
|
||||
and "album_asset"."updateId" <= $3
|
||||
and (
|
||||
"album"."ownerId" = $4
|
||||
or "album_user"."usersId" = $5
|
||||
or "album_user"."userId" = $5
|
||||
)
|
||||
order by
|
||||
"asset_exif"."updateId" asc
|
||||
@@ -263,23 +263,23 @@ select
|
||||
"asset_exif"."fps"
|
||||
from
|
||||
"album_asset" as "album_asset"
|
||||
inner join "asset_exif" on "asset_exif"."assetId" = "album_asset"."assetsId"
|
||||
inner join "album" on "album"."id" = "album_asset"."albumsId"
|
||||
left join "album_user" on "album_user"."albumsId" = "album_asset"."albumsId"
|
||||
inner join "asset_exif" on "asset_exif"."assetId" = "album_asset"."assetId"
|
||||
inner join "album" on "album"."id" = "album_asset"."albumId"
|
||||
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
|
||||
where
|
||||
"album_asset"."updateId" < $1
|
||||
and "album_asset"."updateId" > $2
|
||||
and (
|
||||
"album"."ownerId" = $3
|
||||
or "album_user"."usersId" = $4
|
||||
or "album_user"."userId" = $4
|
||||
)
|
||||
order by
|
||||
"album_asset"."updateId" asc
|
||||
|
||||
-- SyncRepository.albumToAsset.getBackfill
|
||||
select
|
||||
"album_asset"."assetsId" as "assetId",
|
||||
"album_asset"."albumsId" as "albumId",
|
||||
"album_asset"."assetId" as "assetId",
|
||||
"album_asset"."albumId" as "albumId",
|
||||
"album_asset"."updateId"
|
||||
from
|
||||
"album_asset" as "album_asset"
|
||||
@@ -287,7 +287,7 @@ where
|
||||
"album_asset"."updateId" < $1
|
||||
and "album_asset"."updateId" <= $2
|
||||
and "album_asset"."updateId" >= $3
|
||||
and "album_asset"."albumsId" = $4
|
||||
and "album_asset"."albumId" = $4
|
||||
order by
|
||||
"album_asset"."updateId" asc
|
||||
|
||||
@@ -311,11 +311,11 @@ where
|
||||
union
|
||||
(
|
||||
select
|
||||
"album_user"."albumsId" as "id"
|
||||
"album_user"."albumId" as "id"
|
||||
from
|
||||
"album_user"
|
||||
where
|
||||
"album_user"."usersId" = $4
|
||||
"album_user"."userId" = $4
|
||||
)
|
||||
)
|
||||
order by
|
||||
@@ -323,27 +323,27 @@ order by
|
||||
|
||||
-- SyncRepository.albumToAsset.getUpserts
|
||||
select
|
||||
"album_asset"."assetsId" as "assetId",
|
||||
"album_asset"."albumsId" as "albumId",
|
||||
"album_asset"."assetId" as "assetId",
|
||||
"album_asset"."albumId" as "albumId",
|
||||
"album_asset"."updateId"
|
||||
from
|
||||
"album_asset" as "album_asset"
|
||||
inner join "album" on "album"."id" = "album_asset"."albumsId"
|
||||
left join "album_user" on "album_user"."albumsId" = "album_asset"."albumsId"
|
||||
inner join "album" on "album"."id" = "album_asset"."albumId"
|
||||
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
|
||||
where
|
||||
"album_asset"."updateId" < $1
|
||||
and "album_asset"."updateId" > $2
|
||||
and (
|
||||
"album"."ownerId" = $3
|
||||
or "album_user"."usersId" = $4
|
||||
or "album_user"."userId" = $4
|
||||
)
|
||||
order by
|
||||
"album_asset"."updateId" asc
|
||||
|
||||
-- SyncRepository.albumUser.getBackfill
|
||||
select
|
||||
"album_user"."albumsId" as "albumId",
|
||||
"album_user"."usersId" as "userId",
|
||||
"album_user"."albumId" as "albumId",
|
||||
"album_user"."userId" as "userId",
|
||||
"album_user"."role",
|
||||
"album_user"."updateId"
|
||||
from
|
||||
@@ -352,7 +352,7 @@ where
|
||||
"album_user"."updateId" < $1
|
||||
and "album_user"."updateId" <= $2
|
||||
and "album_user"."updateId" >= $3
|
||||
and "albumsId" = $4
|
||||
and "albumId" = $4
|
||||
order by
|
||||
"album_user"."updateId" asc
|
||||
|
||||
@@ -376,11 +376,11 @@ where
|
||||
union
|
||||
(
|
||||
select
|
||||
"album_user"."albumsId" as "id"
|
||||
"album_user"."albumId" as "id"
|
||||
from
|
||||
"album_user"
|
||||
where
|
||||
"album_user"."usersId" = $4
|
||||
"album_user"."userId" = $4
|
||||
)
|
||||
)
|
||||
order by
|
||||
@@ -388,8 +388,8 @@ order by
|
||||
|
||||
-- SyncRepository.albumUser.getUpserts
|
||||
select
|
||||
"album_user"."albumsId" as "albumId",
|
||||
"album_user"."usersId" as "userId",
|
||||
"album_user"."albumId" as "albumId",
|
||||
"album_user"."userId" as "userId",
|
||||
"album_user"."role",
|
||||
"album_user"."updateId"
|
||||
from
|
||||
@@ -397,7 +397,7 @@ from
|
||||
where
|
||||
"album_user"."updateId" < $1
|
||||
and "album_user"."updateId" > $2
|
||||
and "album_user"."albumsId" in (
|
||||
and "album_user"."albumId" in (
|
||||
select
|
||||
"id"
|
||||
from
|
||||
@@ -407,11 +407,11 @@ where
|
||||
union
|
||||
(
|
||||
select
|
||||
"albumUsers"."albumsId" as "id"
|
||||
"albumUsers"."albumId" as "id"
|
||||
from
|
||||
"album_user" as "albumUsers"
|
||||
where
|
||||
"albumUsers"."usersId" = $4
|
||||
"albumUsers"."userId" = $4
|
||||
)
|
||||
)
|
||||
order by
|
||||
@@ -656,7 +656,7 @@ order by
|
||||
-- SyncRepository.memoryToAsset.getUpserts
|
||||
select
|
||||
"memoriesId" as "memoryId",
|
||||
"assetsId" as "assetId",
|
||||
"assetId" as "assetId",
|
||||
"updateId"
|
||||
from
|
||||
"memory_asset" as "memory_asset"
|
||||
|
||||
@@ -84,19 +84,19 @@ where
|
||||
|
||||
-- TagRepository.addAssetIds
|
||||
insert into
|
||||
"tag_asset" ("tagsId", "assetsId")
|
||||
"tag_asset" ("tagId", "assetId")
|
||||
values
|
||||
($1, $2)
|
||||
|
||||
-- TagRepository.removeAssetIds
|
||||
delete from "tag_asset"
|
||||
where
|
||||
"tagsId" = $1
|
||||
and "assetsId" in ($2)
|
||||
"tagId" = $1
|
||||
and "assetId" in ($2)
|
||||
|
||||
-- TagRepository.upsertAssetIds
|
||||
insert into
|
||||
"tag_asset" ("assetId", "tagsIds")
|
||||
"tag_asset" ("assetId", "tagIds")
|
||||
values
|
||||
($1, $2)
|
||||
on conflict do nothing
|
||||
@@ -107,9 +107,9 @@ returning
|
||||
begin
|
||||
delete from "tag_asset"
|
||||
where
|
||||
"assetsId" = $1
|
||||
"assetId" = $1
|
||||
insert into
|
||||
"tag_asset" ("tagsId", "assetsId")
|
||||
"tag_asset" ("tagId", "assetId")
|
||||
values
|
||||
($1, $2)
|
||||
on conflict do nothing
|
||||
|
||||
@@ -52,8 +52,8 @@ class ActivityAccess {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.select('album.id')
|
||||
.leftJoin('album_user as albumUsers', 'albumUsers.albumsId', 'album.id')
|
||||
.leftJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.usersId').on('user.deletedAt', 'is', null))
|
||||
.leftJoin('album_user as albumUsers', 'albumUsers.albumId', 'album.id')
|
||||
.leftJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.userId').on('user.deletedAt', 'is', null))
|
||||
.where('album.id', 'in', [...albumIds])
|
||||
.where('album.isActivityEnabled', '=', true)
|
||||
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('user.id', '=', userId)]))
|
||||
@@ -96,8 +96,8 @@ class AlbumAccess {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.select('album.id')
|
||||
.leftJoin('album_user', 'album_user.albumsId', 'album.id')
|
||||
.leftJoin('user', (join) => join.onRef('user.id', '=', 'album_user.usersId').on('user.deletedAt', 'is', null))
|
||||
.leftJoin('album_user', 'album_user.albumId', 'album.id')
|
||||
.leftJoin('user', (join) => join.onRef('user.id', '=', 'album_user.userId').on('user.deletedAt', 'is', null))
|
||||
.where('album.id', 'in', [...albumIds])
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.where('user.id', '=', userId)
|
||||
@@ -138,12 +138,12 @@ class AssetAccess {
|
||||
return this.db
|
||||
.with('target', (qb) => qb.selectNoFrom(sql`array[${sql.join([...assetIds])}]::uuid[]`.as('ids')))
|
||||
.selectFrom('album')
|
||||
.innerJoin('album_asset as albumAssets', 'album.id', 'albumAssets.albumsId')
|
||||
.innerJoin('album_asset as albumAssets', 'album.id', 'albumAssets.albumId')
|
||||
.innerJoin('asset', (join) =>
|
||||
join.onRef('asset.id', '=', 'albumAssets.assetsId').on('asset.deletedAt', 'is', null),
|
||||
join.onRef('asset.id', '=', 'albumAssets.assetId').on('asset.deletedAt', 'is', null),
|
||||
)
|
||||
.leftJoin('album_user as albumUsers', 'albumUsers.albumsId', 'album.id')
|
||||
.leftJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.usersId').on('user.deletedAt', 'is', null))
|
||||
.leftJoin('album_user as albumUsers', 'albumUsers.albumId', 'album.id')
|
||||
.leftJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.userId').on('user.deletedAt', 'is', null))
|
||||
.crossJoin('target')
|
||||
.select(['asset.id', 'asset.livePhotoVideoId'])
|
||||
.where((eb) =>
|
||||
@@ -223,13 +223,13 @@ class AssetAccess {
|
||||
return this.db
|
||||
.selectFrom('shared_link')
|
||||
.leftJoin('album', (join) => join.onRef('album.id', '=', 'shared_link.albumId').on('album.deletedAt', 'is', null))
|
||||
.leftJoin('shared_link_asset', 'shared_link_asset.sharedLinksId', 'shared_link.id')
|
||||
.leftJoin('shared_link_asset', 'shared_link_asset.sharedLinkId', 'shared_link.id')
|
||||
.leftJoin('asset', (join) =>
|
||||
join.onRef('asset.id', '=', 'shared_link_asset.assetsId').on('asset.deletedAt', 'is', null),
|
||||
join.onRef('asset.id', '=', 'shared_link_asset.assetId').on('asset.deletedAt', 'is', null),
|
||||
)
|
||||
.leftJoin('album_asset', 'album_asset.albumsId', 'album.id')
|
||||
.leftJoin('album_asset', 'album_asset.albumId', 'album.id')
|
||||
.leftJoin('asset as albumAssets', (join) =>
|
||||
join.onRef('albumAssets.id', '=', 'album_asset.assetsId').on('albumAssets.deletedAt', 'is', null),
|
||||
join.onRef('albumAssets.id', '=', 'album_asset.assetId').on('albumAssets.deletedAt', 'is', null),
|
||||
)
|
||||
.select([
|
||||
'asset.id as assetId',
|
||||
|
||||
@@ -7,36 +7,36 @@ import { DB } from 'src/schema';
|
||||
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
|
||||
|
||||
export type AlbumPermissionId = {
|
||||
albumsId: string;
|
||||
usersId: string;
|
||||
albumId: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AlbumUserRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }] })
|
||||
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] })
|
||||
create(albumUser: Insertable<AlbumUserTable>) {
|
||||
return this.db
|
||||
.insertInto('album_user')
|
||||
.values(albumUser)
|
||||
.returning(['usersId', 'albumsId', 'role'])
|
||||
.returning(['userId', 'albumId', 'role'])
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }, { role: AlbumUserRole.Viewer }] })
|
||||
update({ usersId, albumsId }: AlbumPermissionId, dto: Updateable<AlbumUserTable>) {
|
||||
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }, { role: AlbumUserRole.Viewer }] })
|
||||
update({ userId, albumId }: AlbumPermissionId, dto: Updateable<AlbumUserTable>) {
|
||||
return this.db
|
||||
.updateTable('album_user')
|
||||
.set(dto)
|
||||
.where('usersId', '=', usersId)
|
||||
.where('albumsId', '=', albumsId)
|
||||
.where('userId', '=', userId)
|
||||
.where('albumId', '=', albumId)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }] })
|
||||
async delete({ usersId, albumsId }: AlbumPermissionId): Promise<void> {
|
||||
await this.db.deleteFrom('album_user').where('usersId', '=', usersId).where('albumsId', '=', albumsId).execute();
|
||||
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] })
|
||||
async delete({ userId, albumId }: AlbumPermissionId): Promise<void> {
|
||||
await this.db.deleteFrom('album_user').where('userId', '=', userId).where('albumId', '=', albumId).execute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +33,11 @@ const withAlbumUsers = (eb: ExpressionBuilder<DB, 'album'>) => {
|
||||
.selectFrom('album_user')
|
||||
.select('album_user.role')
|
||||
.select((eb) =>
|
||||
jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album_user.usersId'))
|
||||
jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album_user.userId'))
|
||||
.$notNull()
|
||||
.as('user'),
|
||||
)
|
||||
.whereRef('album_user.albumsId', '=', 'album.id'),
|
||||
.whereRef('album_user.albumId', '=', 'album.id'),
|
||||
)
|
||||
.$notNull()
|
||||
.as('albumUsers');
|
||||
@@ -57,8 +57,8 @@ const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
|
||||
.selectAll('asset')
|
||||
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.select((eb) => eb.table('asset_exif').$castTo<Exif>().as('exifInfo'))
|
||||
.innerJoin('album_asset', 'album_asset.assetsId', 'asset.id')
|
||||
.whereRef('album_asset.albumsId', '=', 'album.id')
|
||||
.innerJoin('album_asset', 'album_asset.assetId', 'asset.id')
|
||||
.whereRef('album_asset.albumId', '=', 'album.id')
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.$call(withDefaultVisibility)
|
||||
.orderBy('asset.fileCreatedAt', 'desc')
|
||||
@@ -92,19 +92,19 @@ export class AlbumRepository {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.innerJoin('album_asset', 'album_asset.albumsId', 'album.id')
|
||||
.innerJoin('album_asset', 'album_asset.albumId', 'album.id')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('album.ownerId', '=', ownerId),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('album_user')
|
||||
.whereRef('album_user.albumsId', '=', 'album.id')
|
||||
.where('album_user.usersId', '=', ownerId),
|
||||
.whereRef('album_user.albumId', '=', 'album.id')
|
||||
.where('album_user.userId', '=', ownerId),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.where('album_asset.assetsId', '=', assetId)
|
||||
.where('album_asset.assetId', '=', assetId)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.orderBy('album.createdAt', 'desc')
|
||||
.select(withOwner)
|
||||
@@ -125,16 +125,16 @@ export class AlbumRepository {
|
||||
this.db
|
||||
.selectFrom('asset')
|
||||
.$call(withDefaultVisibility)
|
||||
.innerJoin('album_asset', 'album_asset.assetsId', 'asset.id')
|
||||
.select('album_asset.albumsId as albumId')
|
||||
.innerJoin('album_asset', 'album_asset.assetId', 'asset.id')
|
||||
.select('album_asset.albumId as albumId')
|
||||
.select((eb) => eb.fn.min(sql<Date>`("asset"."localDateTime" AT TIME ZONE 'UTC'::text)::date`).as('startDate'))
|
||||
.select((eb) => eb.fn.max(sql<Date>`("asset"."localDateTime" AT TIME ZONE 'UTC'::text)::date`).as('endDate'))
|
||||
// lastModifiedAssetTimestamp is only used in mobile app, please remove if not need
|
||||
.select((eb) => eb.fn.max('asset.updatedAt').as('lastModifiedAssetTimestamp'))
|
||||
.select((eb) => sql<number>`${eb.fn.count('asset.id')}::int`.as('assetCount'))
|
||||
.where('album_asset.albumsId', 'in', ids)
|
||||
.where('album_asset.albumId', 'in', ids)
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.groupBy('album_asset.albumsId')
|
||||
.groupBy('album_asset.albumId')
|
||||
.execute()
|
||||
);
|
||||
}
|
||||
@@ -166,8 +166,8 @@ export class AlbumRepository {
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('album_user')
|
||||
.whereRef('album_user.albumsId', '=', 'album.id')
|
||||
.where((eb) => eb.or([eb('album.ownerId', '=', ownerId), eb('album_user.usersId', '=', ownerId)])),
|
||||
.whereRef('album_user.albumId', '=', 'album.id')
|
||||
.where((eb) => eb.or([eb('album.ownerId', '=', ownerId), eb('album_user.userId', '=', ownerId)])),
|
||||
),
|
||||
eb.exists(
|
||||
eb
|
||||
@@ -195,7 +195,7 @@ export class AlbumRepository {
|
||||
.selectAll('album')
|
||||
.where('album.ownerId', '=', ownerId)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.where((eb) => eb.not(eb.exists(eb.selectFrom('album_user').whereRef('album_user.albumsId', '=', 'album.id'))))
|
||||
.where((eb) => eb.not(eb.exists(eb.selectFrom('album_user').whereRef('album_user.albumId', '=', 'album.id'))))
|
||||
.where((eb) => eb.not(eb.exists(eb.selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id'))))
|
||||
.select(withOwner)
|
||||
.orderBy('album.createdAt', 'desc')
|
||||
@@ -217,7 +217,7 @@ export class AlbumRepository {
|
||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||
@Chunked()
|
||||
async removeAssetsFromAll(assetIds: string[]): Promise<void> {
|
||||
await this.db.deleteFrom('album_asset').where('album_asset.assetsId', 'in', assetIds).execute();
|
||||
await this.db.deleteFrom('album_asset').where('album_asset.assetId', 'in', assetIds).execute();
|
||||
}
|
||||
|
||||
@Chunked({ paramIndex: 1 })
|
||||
@@ -228,8 +228,8 @@ export class AlbumRepository {
|
||||
|
||||
await this.db
|
||||
.deleteFrom('album_asset')
|
||||
.where('album_asset.albumsId', '=', albumId)
|
||||
.where('album_asset.assetsId', 'in', assetIds)
|
||||
.where('album_asset.albumId', '=', albumId)
|
||||
.where('album_asset.assetId', 'in', assetIds)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -250,10 +250,10 @@ export class AlbumRepository {
|
||||
return this.db
|
||||
.selectFrom('album_asset')
|
||||
.selectAll()
|
||||
.where('album_asset.albumsId', '=', albumId)
|
||||
.where('album_asset.assetsId', 'in', assetIds)
|
||||
.where('album_asset.albumId', '=', albumId)
|
||||
.where('album_asset.assetId', 'in', assetIds)
|
||||
.execute()
|
||||
.then((results) => new Set(results.map(({ assetsId }) => assetsId)));
|
||||
.then((results) => new Set(results.map(({ assetId }) => assetId)));
|
||||
}
|
||||
|
||||
async addAssetIds(albumId: string, assetIds: string[]): Promise<void> {
|
||||
@@ -276,7 +276,7 @@ export class AlbumRepository {
|
||||
await tx
|
||||
.insertInto('album_user')
|
||||
.values(
|
||||
albumUsers.map((albumUser) => ({ albumsId: newAlbum.id, usersId: albumUser.userId, role: albumUser.role })),
|
||||
albumUsers.map((albumUser) => ({ albumId: newAlbum.id, userId: albumUser.userId, role: albumUser.role })),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
@@ -317,12 +317,12 @@ export class AlbumRepository {
|
||||
|
||||
await db
|
||||
.insertInto('album_asset')
|
||||
.values(assetIds.map((assetId) => ({ albumsId: albumId, assetsId: assetId })))
|
||||
.values(assetIds.map((assetId) => ({ albumId, assetId })))
|
||||
.execute();
|
||||
}
|
||||
|
||||
@Chunked({ chunkSize: 30_000 })
|
||||
async addAssetIdsToAlbums(values: { albumsId: string; assetsId: string }[]): Promise<void> {
|
||||
async addAssetIdsToAlbums(values: { albumId: string; assetId: string }[]): Promise<void> {
|
||||
if (values.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -344,7 +344,7 @@ export class AlbumRepository {
|
||||
.updateTable('album')
|
||||
.set((eb) => ({
|
||||
albumThumbnailAssetId: this.updateThumbnailBuilder(eb)
|
||||
.select('album_asset.assetsId')
|
||||
.select('album_asset.assetId')
|
||||
.orderBy('asset.fileCreatedAt', 'desc')
|
||||
.limit(1),
|
||||
}))
|
||||
@@ -360,7 +360,7 @@ export class AlbumRepository {
|
||||
eb.exists(
|
||||
this.updateThumbnailBuilder(eb)
|
||||
.select(sql`1`.as('1'))
|
||||
.whereRef('album.albumThumbnailAssetId', '=', 'album_asset.assetsId'), // Has invalid assets
|
||||
.whereRef('album.albumThumbnailAssetId', '=', 'album_asset.assetId'), // Has invalid assets
|
||||
),
|
||||
),
|
||||
]),
|
||||
@@ -375,9 +375,9 @@ export class AlbumRepository {
|
||||
return eb
|
||||
.selectFrom('album_asset')
|
||||
.innerJoin('asset', (join) =>
|
||||
join.onRef('album_asset.assetsId', '=', 'asset.id').on('asset.deletedAt', 'is', null),
|
||||
join.onRef('album_asset.assetId', '=', 'asset.id').on('asset.deletedAt', 'is', null),
|
||||
)
|
||||
.whereRef('album_asset.albumsId', '=', 'album.id');
|
||||
.whereRef('album_asset.albumId', '=', 'album.id');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -388,9 +388,9 @@ export class AlbumRepository {
|
||||
getContributorCounts(id: string) {
|
||||
return this.db
|
||||
.selectFrom('album_asset')
|
||||
.innerJoin('asset', 'asset.id', 'assetsId')
|
||||
.innerJoin('asset', 'asset.id', 'assetId')
|
||||
.where('asset.deletedAt', 'is', sql.lit(null))
|
||||
.where('album_asset.albumsId', '=', id)
|
||||
.where('album_asset.albumId', '=', id)
|
||||
.select('asset.ownerId as userId')
|
||||
.select((eb) => eb.fn.countAll<number>().as('assetCount'))
|
||||
.groupBy('asset.ownerId')
|
||||
@@ -405,8 +405,8 @@ export class AlbumRepository {
|
||||
.expression((eb) =>
|
||||
eb
|
||||
.selectFrom('album_asset')
|
||||
.select((eb) => ['album_asset.albumsId', eb.val(targetAssetId).as('assetsId')])
|
||||
.where('album_asset.assetsId', '=', sourceAssetId),
|
||||
.select((eb) => ['album_asset.albumId', eb.val(targetAssetId).as('assetId')])
|
||||
.where('album_asset.assetId', '=', sourceAssetId),
|
||||
)
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { Kysely, sql } from 'kysely';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { Asset, columns } from 'src/database';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AssetFileType, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { StorageAsset } from 'src/types';
|
||||
import {
|
||||
@@ -29,6 +29,7 @@ export class AssetJobRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.where('asset.id', '=', asUuid(id))
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.leftJoin('smart_search', 'asset.id', 'smart_search.assetId')
|
||||
.select(['id', 'type', 'ownerId', 'duplicateId', 'stackId', 'visibility', 'smart_search.embedding'])
|
||||
.limit(1)
|
||||
@@ -40,14 +41,15 @@ export class AssetJobRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.where('asset.id', '=', asUuid(id))
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.select(['id', 'sidecarPath', 'originalPath'])
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('tag')
|
||||
.select(['tag.value'])
|
||||
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagsId')
|
||||
.whereRef('asset.id', '=', 'tag_asset.assetsId'),
|
||||
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId')
|
||||
.whereRef('asset.id', '=', 'tag_asset.assetId'),
|
||||
).as('tags'),
|
||||
)
|
||||
.limit(1)
|
||||
@@ -59,6 +61,7 @@ export class AssetJobRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.where('asset.id', '=', asUuid(id))
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.select(['id', 'sidecarPath', 'originalPath'])
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
@@ -70,6 +73,7 @@ export class AssetJobRepository {
|
||||
.selectFrom('asset')
|
||||
.select(['asset.id', 'asset.thumbhash'])
|
||||
.select(withFiles)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.where('asset.visibility', '!=', AssetVisibility.Hidden)
|
||||
.$if(!force, (qb) =>
|
||||
@@ -94,6 +98,7 @@ export class AssetJobRepository {
|
||||
.select(['asset.id', 'asset.ownerId', 'asset.encodedVideoPath'])
|
||||
.select(withFiles)
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -113,6 +118,7 @@ export class AssetJobRepository {
|
||||
.select(withFiles)
|
||||
.$call(withExifInner)
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -123,6 +129,7 @@ export class AssetJobRepository {
|
||||
.select(columns.asset)
|
||||
.select(withFaces)
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -140,6 +147,7 @@ export class AssetJobRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.where('asset.visibility', '!=', AssetVisibility.Hidden)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.innerJoin('asset_job_status as job_status', 'assetId', 'asset.id')
|
||||
.where('job_status.previewAt', 'is not', null);
|
||||
@@ -150,6 +158,7 @@ export class AssetJobRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.id'])
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.innerJoin('smart_search', 'asset.id', 'smart_search.assetId')
|
||||
.$call(withDefaultVisibility)
|
||||
@@ -178,6 +187,7 @@ export class AssetJobRepository {
|
||||
.select(['asset.id', 'asset.visibility'])
|
||||
.select((eb) => withFiles(eb, AssetFileType.Preview))
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -190,6 +200,7 @@ export class AssetJobRepository {
|
||||
.select((eb) => withFaces(eb, true))
|
||||
.select((eb) => withFiles(eb, AssetFileType.Preview))
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -251,6 +262,7 @@ export class AssetJobRepository {
|
||||
)
|
||||
.select((eb) => toJson(eb, 'stacked_assets').as('stack'))
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -265,6 +277,7 @@ export class AssetJobRepository {
|
||||
.where((eb) => eb.or([eb('asset.encodedVideoPath', 'is', null), eb('asset.encodedVideoPath', '=', '')]))
|
||||
.where('asset.visibility', '!=', AssetVisibility.Hidden),
|
||||
)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.stream();
|
||||
}
|
||||
@@ -276,6 +289,7 @@ export class AssetJobRepository {
|
||||
.select(['asset.id', 'asset.ownerId', 'asset.originalPath', 'asset.encodedVideoPath'])
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.type', '=', AssetType.Video)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -291,6 +305,7 @@ export class AssetJobRepository {
|
||||
eb.or([eb('asset_job_status.metadataExtractedAt', 'is', null), eb('asset_job_status.assetId', 'is', null)]),
|
||||
),
|
||||
)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.stream();
|
||||
}
|
||||
@@ -313,6 +328,7 @@ export class AssetJobRepository {
|
||||
'asset_exif.timeZone',
|
||||
'asset_exif.fileSizeInByte',
|
||||
])
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.where('asset.deletedAt', 'is', null);
|
||||
}
|
||||
|
||||
@@ -334,6 +350,7 @@ export class AssetJobRepository {
|
||||
.selectFrom('asset')
|
||||
.select(['id', 'isOffline'])
|
||||
.where('asset.deletedAt', '<=', trashedBefore)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.stream();
|
||||
}
|
||||
|
||||
@@ -346,6 +363,7 @@ export class AssetJobRepository {
|
||||
qb.where((eb) => eb.or([eb('asset.sidecarPath', '=', ''), eb('asset.sidecarPath', 'is', null)])),
|
||||
)
|
||||
.where('asset.visibility', '!=', AssetVisibility.Hidden)
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.stream();
|
||||
}
|
||||
|
||||
@@ -354,6 +372,7 @@ export class AssetJobRepository {
|
||||
return this.assetsWithPreviews()
|
||||
.$if(force === false, (qb) => qb.where('job_status.facesRecognizedAt', 'is', null))
|
||||
.select(['asset.id'])
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.orderBy('asset.fileCreatedAt', 'desc')
|
||||
.stream();
|
||||
}
|
||||
@@ -375,6 +394,31 @@ export class AssetJobRepository {
|
||||
|
||||
@GenerateSql({ params: [DummyValue.DATE], stream: true })
|
||||
streamForMigrationJob() {
|
||||
return this.db.selectFrom('asset').select(['id']).where('asset.deletedAt', 'is', null).stream();
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['id'])
|
||||
.where('asset.status', '!=', sql.lit(AssetStatus.Partial))
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.stream();
|
||||
}
|
||||
|
||||
getForPartialAssetCleanupJob(assetId: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.select(['originalPath as path', 'fileSizeInByte as size', 'checksum', 'fileModifiedAt'])
|
||||
.where('id', '=', assetId)
|
||||
.where('status', '=', sql.lit(AssetStatus.Partial))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.DATE], stream: true })
|
||||
streamForPartialAssetCleanupJob(createdBefore: Date) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['id'])
|
||||
.where('asset.status', '=', sql.lit(AssetStatus.Partial))
|
||||
.where('asset.createdAt', '<', createdBefore)
|
||||
.stream();
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user