Compare commits

...

57 Commits

Author SHA1 Message Date
mertalev
7ba458668b remove upload-length from conventional upload e2e 2025-11-06 12:15:54 -05:00
mertalev
ea034f21bc linting 2025-11-06 12:15:54 -05:00
mertalev
a68513247d redundant check 2025-11-06 12:15:54 -05:00
mertalev
59f7f3c23e update api 2025-11-06 12:15:54 -05:00
mertalev
c88bde3cab lint 2025-11-06 12:15:54 -05:00
mertalev
818bd51036 support conventional uploads 2025-11-06 12:15:54 -05:00
mertalev
3c72409712 require header for incomplete uploads 2025-11-06 12:15:54 -05:00
mertalev
8d1a8b9465 reject empty file 2025-11-06 12:15:54 -05:00
mertalev
d880e7baed infer upload length when possible 2025-11-06 12:15:54 -05:00
mertalev
42801ace35 update api 2025-11-06 12:15:54 -05:00
mertalev
838b8e9126 set max-age limit 2025-11-06 12:15:19 -05:00
mertalev
9da5a48bdd add live photo e2e 2025-11-06 12:15:19 -05:00
mertalev
27f126bd58 better abort check 2025-11-06 12:15:19 -05:00
mertalev
a238c6a70d unnecessary change 2025-11-06 12:15:19 -05:00
mertalev
7222d7af30 configurable cleanup 2025-11-06 12:15:19 -05:00
mertalev
d660ab2218 handle live photos 2025-11-06 12:13:33 -05:00
mertalev
69ffbcd5cf tweak types 2025-11-06 12:13:33 -05:00
mertalev
bc84486668 MUST NOT validation 2025-11-06 12:13:33 -05:00
mertalev
2666ee2b4f remove log 2025-11-06 12:12:27 -05:00
mertalev
72ea7799c0 lint 2025-11-06 12:12:27 -05:00
mertalev
98c8c28b62 test interruption + abort 2025-11-06 12:12:27 -05:00
mertalev
6b1d26d3a2 more content length test inputs 2025-11-06 12:12:27 -05:00
mertalev
5e07976288 fix abortion return 2025-11-06 12:12:27 -05:00
mertalev
3f1133f9b7 typo 2025-11-06 12:12:27 -05:00
mertalev
3a087ed2cd proactive abortion 2025-11-06 12:12:27 -05:00
mertalev
c723a9ac78 better content length handling 2025-11-06 12:09:27 -05:00
mertalev
550460891d add timeout 2025-11-06 12:09:27 -05:00
mertalev
e3e8da168f tidying 2025-11-06 12:09:27 -05:00
mertalev
de117ebe7a listen to upload event in e2e
test resume with real image
2025-11-06 12:09:27 -05:00
mertalev
3d507015e0 add service tests 2025-11-06 12:09:26 -05:00
mertalev
fe71662d24 add controller tests, move validation testing from e2e
revert unnecessary change

update mocks

add structured-headers to e2e deps
2025-11-06 12:09:26 -05:00
mertalev
81a66350f6 add note about RFC 9651
authdto

remove excess logs

use structured dictionary
2025-11-06 12:07:34 -05:00
mertalev
c33e65362a clean up stale uploads
stale upload cleanup

try/catch file check
2025-11-06 12:07:34 -05:00
mertalev
bb5519036a unnecessary quota check 2025-11-06 12:07:34 -05:00
mertalev
177c997d96 interim+500
interim+500

interim+500
2025-11-06 12:07:34 -05:00
mertalev
2d6a2dc77b more e2e tests
consistent e2e sections

decrement quota on cancel
2025-11-06 12:07:34 -05:00
mertalev
e193cb3a5b tweaks
shared pipe method

shared pipe method

require size upfront

make length optional for patch requests
2025-11-06 12:07:34 -05:00
mertalev
4b63d3d055 ensure stream is closed before releasing lock 2025-11-06 12:07:34 -05:00
mertalev
4ed92f5df5 dto refactor
add logging

handle metadata
2025-11-06 12:07:34 -05:00
mertalev
6f61bf04e4 backward compatibility 2025-11-06 12:07:34 -05:00
mertalev
b21d0a1c53 working e2e 2025-11-06 12:07:34 -05:00
mertalev
f80326872e interop v8 compliance 2025-11-06 12:07:34 -05:00
mertalev
7561c5e1c4 chunked upload controller 2025-11-06 12:05:56 -05:00
Snowknight26
2c50f2e244 fix(web): add URLs to results in large files utility (#23617)
fix(web): add URLs to results in large files
2025-11-06 09:24:47 -05:00
shenlong
365abd8906 fix: check if unmetered instead of wifi (#23380)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-05 13:27:38 -06:00
Alex
25fb43bbe3 fix: fully sync local library on app restart (#23323) 2025-11-05 14:09:50 +00:00
bo0tzz
125e8cee01 chore: update config.json example (#23471)
* chore: update config.json example

closes #23465

* fix: format, for real this time
2025-11-05 08:05:53 -06:00
Arnaud Wery
c15e9bfa72 fix(web): "select all" button in trash and permanently deleted count (#23594) 2025-11-05 14:05:48 +00:00
Dmitry
35e188e6e7 docs: sync ru docs with main README.md (#23627) 2025-11-05 08:05:03 -06:00
Sergey Katsubo
3cc9dd126c fix(web): fix timezone dropdown for timestamps lacking milliseconds (#23615)
Fix timezone selector for timestamps without milliseconds
2025-11-05 08:03:55 -06:00
Jason Rasmussen
aa69d89b9f fix: bad merge (#23610) 2025-11-04 16:22:45 -05:00
Jason Rasmussen
29c14a3f58 refactor: database column names (#23356) 2025-11-04 16:03:21 -05:00
Jason Rasmussen
0df70365d7 feat: exif medium tests (#23561) 2025-11-04 16:03:02 -05:00
Mees Frensel
c34be73d81 fix(web): consistently use mdiMotionPauseOutline icon (#23595) 2025-11-04 12:12:47 +01:00
renovate[bot]
f396e9e374 chore(deps): update prom/prometheus docker digest to 4921475 (#23578)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-04 11:49:12 +01:00
renovate[bot]
821a9d4691 chore(deps): update redis:6.2-alpine docker digest to 37e0024 (#23579)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-04 11:48:21 +01:00
renovate[bot]
cad654586f chore(deps): update dependency @types/node to ^22.18.13 (#23581)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-04 11:47:54 +01:00
108 changed files with 5162 additions and 1216 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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;
```

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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;
@@ -1140,16 +1105,6 @@ describe('/asset', () => {
},
},
},
{
input: 'metadata/gps-position/empty_gps.jpg',
expected: {
type: AssetTypeEnum.Image,
exifInfo: {
latitude: null,
longitude: null,
},
},
},
];
it.each(tests)(`should upload and generate a thumbnail for different file types`, async ({ input, expected }) => {
@@ -1259,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, {

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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");

View File

@@ -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),
]);

View File

@@ -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)

View File

@@ -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
View 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;
}
}

View File

@@ -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':

View File

@@ -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',
};
}

View File

@@ -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',
};

View 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',
};
}

View 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',
};
}

View File

@@ -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",
@@ -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": {

View File

@@ -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": {

View File

@@ -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
View File

@@ -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

View File

@@ -94,7 +94,7 @@
| LivePhoto/MotionPhoto воспроизведение и бекап | Да | Да |
| Отображение 360° изображений | Нет | Да |
| Настраиваемая структура хранилища | Да | Да |
| Общий доступ к контенту | Нет | Да |
| Общий доступ к контенту | Да | Да |
| Архив и избранное | Да | Да |
| Мировая карта | Да | Да |
| Совместное использование | Да | Да |
@@ -104,7 +104,7 @@
| Галереи только для просмотра | Да | Да |
| Коллажи | Да | Да |
| Метки (теги) | Нет | Да |
| Просмотр папкой | Нет | Да |
| Просмотр папкой | Да | Да |
## Перевод

View File

@@ -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",

View File

@@ -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,

View 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);
});
});
});

View 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);
}
}

View File

@@ -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,

View File

@@ -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'],

View 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');
}
}

View File

@@ -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 {

View File

@@ -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',

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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 (

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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',

View File

@@ -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();
}
}

View File

@@ -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();

View File

@@ -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();
}
}

View File

@@ -255,6 +255,96 @@ export class AssetRepository {
return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
}
createWithMetadata(asset: Insertable<AssetTable> & { id: string }, size: number, metadata?: AssetMetadataItem[]) {
let query = this.db;
if (asset.livePhotoVideoId) {
(query as any) = query.with('motion_asset', (qb) =>
qb
.updateTable('asset')
.set({ visibility: AssetVisibility.Hidden })
.where('id', '=', asset.livePhotoVideoId!)
.where('type', '=', sql.lit(AssetType.Video))
.where('ownerId', '=', asset.ownerId)
.returning('id'),
);
}
(query as any) = query
.with('asset', (qb) =>
qb
.insertInto('asset')
.values(
asset.livePhotoVideoId ? { ...asset, livePhotoVideoId: sql<string>`(select id from motion_asset)` } : asset,
)
.returning(['id', 'ownerId']),
)
.with('exif', (qb) =>
qb
.insertInto('asset_exif')
.columns(['assetId', 'fileSizeInByte'])
.expression((eb) => eb.selectFrom('asset').select(['asset.id', eb.val(size).as('fileSizeInByte')])),
);
if (metadata && metadata.length > 0) {
(query as any) = query.with('metadata', (qb) =>
qb.insertInto('asset_metadata').values(metadata.map(({ key, value }) => ({ assetId: asset.id, key, value }))),
);
}
return query
.updateTable('user')
.from('asset')
.set({ quotaUsageInBytes: sql`"quotaUsageInBytes" + ${size}` })
.whereRef('user.id', '=', 'asset.ownerId')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getCompletionMetadata(assetId: string, ownerId: string) {
return this.db
.selectFrom('asset')
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.select(['originalPath as path', 'status', 'fileModifiedAt', 'createdAt', 'checksum', 'fileSizeInByte as size'])
.where('id', '=', assetId)
.where('ownerId', '=', ownerId)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
async setComplete(assetId: string) {
await this.db
.updateTable('asset as complete_asset')
.set((eb) => ({
status: sql.lit(AssetStatus.Active),
visibility: eb
.case()
.when(
eb.and([
eb('complete_asset.type', '=', sql.lit(AssetType.Video)),
eb.exists(eb.selectFrom('asset').whereRef('complete_asset.id', '=', 'asset.livePhotoVideoId')),
]),
)
.then(sql<AssetVisibility>`'hidden'::asset_visibility_enum`)
.else(sql<AssetVisibility>`'timeline'::asset_visibility_enum`)
.end(),
}))
.where('id', '=', assetId)
.where('status', '=', sql.lit(AssetStatus.Partial))
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async removeAndDecrementQuota(id: string): Promise<void> {
await this.db
.with('asset_exif', (qb) => qb.selectFrom('asset_exif').where('assetId', '=', id).select('fileSizeInByte'))
.with('asset', (qb) => qb.deleteFrom('asset').where('id', '=', id).returning('ownerId'))
.updateTable('user')
.from(['asset_exif', 'asset'])
.set({ quotaUsageInBytes: sql`"quotaUsageInBytes" - "fileSizeInByte"` })
.whereRef('user.id', '=', 'asset.ownerId')
.execute();
}
createAll(assets: Insertable<AssetTable>[]) {
return this.db.insertInto('asset').values(assets).returningAll().execute();
}
@@ -494,17 +584,15 @@ export class AssetRepository {
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] })
async getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise<string | undefined> {
const asset = await this.db
getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer) {
return this.db
.selectFrom('asset')
.select('id')
.select(['id', 'status', 'createdAt'])
.where('ownerId', '=', asUuid(ownerId))
.where('checksum', '=', checksum)
.where('libraryId', 'is', null)
.limit(1)
.executeTakeFirst();
return asset?.id;
}
findLivePhotoMatch(options: LivePhotoSearchOptions) {
@@ -563,8 +651,8 @@ export class AssetRepository {
.$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!))
.$if(!!options.albumId, (qb) =>
qb
.innerJoin('album_asset', 'asset.id', 'album_asset.assetsId')
.where('album_asset.albumsId', '=', asUuid(options.albumId!)),
.innerJoin('album_asset', 'asset.id', 'album_asset.assetId')
.where('album_asset.albumId', '=', asUuid(options.albumId!)),
)
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
.$if(!!options.withStacked, (qb) =>
@@ -641,8 +729,8 @@ export class AssetRepository {
eb.exists(
eb
.selectFrom('album_asset')
.whereRef('album_asset.assetsId', '=', 'asset.id')
.where('album_asset.albumsId', '=', asUuid(options.albumId!)),
.whereRef('album_asset.assetId', '=', 'asset.id')
.where('album_asset.albumId', '=', asUuid(options.albumId!)),
),
),
)

View File

@@ -451,6 +451,20 @@ export class DatabaseRepository {
return res as R;
}
async withUuidLock<R>(uuid: string, callback: () => Promise<R>): Promise<R> {
let res;
await this.db.connection().execute(async (connection) => {
try {
await this.acquireUuidLock(uuid, connection);
res = await callback();
} finally {
await this.releaseUuidLock(uuid, connection);
}
});
return res as R;
}
tryLock(lock: DatabaseLock): Promise<boolean> {
return this.db.connection().execute(async (connection) => this.acquireTryLock(lock, connection));
}
@@ -467,6 +481,10 @@ export class DatabaseRepository {
await sql`SELECT pg_advisory_lock(${lock})`.execute(connection);
}
private async acquireUuidLock(uuid: string, connection: Kysely<DB>): Promise<void> {
await sql`SELECT pg_advisory_lock(uuid_hash_extended(${uuid}, 0))`.execute(connection);
}
private async acquireTryLock(lock: DatabaseLock, connection: Kysely<DB>): Promise<boolean> {
const { rows } = await sql<{
pg_try_advisory_lock: boolean;
@@ -477,4 +495,8 @@ export class DatabaseRepository {
private async releaseLock(lock: DatabaseLock, connection: Kysely<DB>): Promise<void> {
await sql`SELECT pg_advisory_unlock(${lock})`.execute(connection);
}
private async releaseUuidLock(uuid: string, connection: Kysely<DB>): Promise<void> {
await sql`SELECT pg_advisory_unlock(uuid_hash_extended(${uuid}, 0))`.execute(connection);
}
}

View File

@@ -26,8 +26,8 @@ export class DownloadRepository {
downloadAlbumId(albumId: string) {
return builder(this.db)
.innerJoin('album_asset', 'asset.id', 'album_asset.assetsId')
.where('album_asset.albumsId', '=', albumId)
.innerJoin('album_asset', 'asset.id', 'album_asset.assetId')
.where('album_asset.albumId', '=', albumId)
.stream();
}

View File

@@ -79,6 +79,9 @@ type EventMap = {
// stack bulk events
StackDeleteAll: [{ stackIds: string[]; userId: string }];
// upload events
UploadAbort: [{ assetId: string; abortTime: Date }];
// user events
UserSignup: [{ notify: boolean; id: string; password?: string }];
UserCreate: [UserEvent];

View File

@@ -126,8 +126,8 @@ export class MapRepository {
eb.exists((eb) =>
eb
.selectFrom('album_asset')
.whereRef('asset.id', '=', 'album_asset.assetsId')
.where('album_asset.albumsId', 'in', albumIds),
.whereRef('asset.id', '=', 'album_asset.assetId')
.where('album_asset.albumId', 'in', albumIds),
),
);
}

View File

@@ -18,7 +18,7 @@ export class MemoryRepository implements IBulkAsset {
await this.db
.deleteFrom('memory_asset')
.using('asset')
.whereRef('memory_asset.assetsId', '=', 'asset.id')
.whereRef('memory_asset.assetId', '=', 'asset.id')
.where('asset.visibility', '!=', AssetVisibility.Timeline)
.execute();
@@ -64,7 +64,7 @@ export class MemoryRepository implements IBulkAsset {
eb
.selectFrom('asset')
.selectAll('asset')
.innerJoin('memory_asset', 'asset.id', 'memory_asset.assetsId')
.innerJoin('memory_asset', 'asset.id', 'memory_asset.assetId')
.whereRef('memory_asset.memoriesId', '=', 'memory.id')
.orderBy('asset.fileCreatedAt', 'asc')
.where('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
@@ -86,7 +86,7 @@ export class MemoryRepository implements IBulkAsset {
const { id } = await tx.insertInto('memory').values(memory).returning('id').executeTakeFirstOrThrow();
if (assetIds.size > 0) {
const values = [...assetIds].map((assetId) => ({ memoriesId: id, assetsId: assetId }));
const values = [...assetIds].map((assetId) => ({ memoriesId: id, assetId }));
await tx.insertInto('memory_asset').values(values).execute();
}
@@ -116,12 +116,12 @@ export class MemoryRepository implements IBulkAsset {
const results = await this.db
.selectFrom('memory_asset')
.select(['assetsId'])
.select(['assetId'])
.where('memoriesId', '=', id)
.where('assetsId', 'in', assetIds)
.where('assetId', 'in', assetIds)
.execute();
return new Set(results.map(({ assetsId }) => assetsId));
return new Set(results.map(({ assetId }) => assetId));
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
@@ -132,7 +132,7 @@ export class MemoryRepository implements IBulkAsset {
await this.db
.insertInto('memory_asset')
.values(assetIds.map((assetId) => ({ memoriesId: id, assetsId: assetId })))
.values(assetIds.map((assetId) => ({ memoriesId: id, assetId })))
.execute();
}
@@ -143,7 +143,7 @@ export class MemoryRepository implements IBulkAsset {
return;
}
await this.db.deleteFrom('memory_asset').where('memoriesId', '=', id).where('assetsId', 'in', assetIds).execute();
await this.db.deleteFrom('memory_asset').where('memoriesId', '=', id).where('assetId', 'in', assetIds).execute();
}
private getByIdBuilder(id: string) {
@@ -155,7 +155,7 @@ export class MemoryRepository implements IBulkAsset {
eb
.selectFrom('asset')
.selectAll('asset')
.innerJoin('memory_asset', 'asset.id', 'memory_asset.assetsId')
.innerJoin('memory_asset', 'asset.id', 'memory_asset.assetId')
.whereRef('memory_asset.memoriesId', '=', 'memory.id')
.orderBy('asset.fileCreatedAt', 'asc')
.where('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))

View File

@@ -6,15 +6,15 @@ import { DB } from 'src/schema';
export class SharedLinkAssetRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
async remove(sharedLinkId: string, assetsId: string[]) {
async remove(sharedLinkId: string, assetId: string[]) {
const deleted = await this.db
.deleteFrom('shared_link_asset')
.where('shared_link_asset.sharedLinksId', '=', sharedLinkId)
.where('shared_link_asset.assetsId', 'in', assetsId)
.returning('assetsId')
.where('shared_link_asset.sharedLinkId', '=', sharedLinkId)
.where('shared_link_asset.assetId', 'in', assetId)
.returning('assetId')
.execute();
return deleted.map((row) => row.assetsId);
return deleted.map((row) => row.assetId);
}
@GenerateSql({ params: [{ sourceAssetId: DummyValue.UUID, targetAssetId: DummyValue.UUID }] })
@@ -24,8 +24,8 @@ export class SharedLinkAssetRepository {
.expression((eb) =>
eb
.selectFrom('shared_link_asset')
.select((eb) => [eb.val(targetAssetId).as('assetsId'), 'shared_link_asset.sharedLinksId'])
.where('shared_link_asset.assetsId', '=', sourceAssetId),
.select((eb) => [eb.val(targetAssetId).as('assetId'), 'shared_link_asset.sharedLinkId'])
.where('shared_link_asset.assetId', '=', sourceAssetId),
)
.onConflict((oc) => oc.doNothing())
.execute();

View File

@@ -28,8 +28,8 @@ export class SharedLinkRepository {
(eb) =>
eb
.selectFrom('shared_link_asset')
.whereRef('shared_link.id', '=', 'shared_link_asset.sharedLinksId')
.innerJoin('asset', 'asset.id', 'shared_link_asset.assetsId')
.whereRef('shared_link.id', '=', 'shared_link_asset.sharedLinkId')
.innerJoin('asset', 'asset.id', 'shared_link_asset.assetId')
.where('asset.deletedAt', 'is', null)
.selectAll('asset')
.innerJoinLateral(
@@ -53,13 +53,13 @@ export class SharedLinkRepository {
.selectAll('album')
.whereRef('album.id', '=', 'shared_link.albumId')
.where('album.deletedAt', 'is', null)
.leftJoin('album_asset', 'album_asset.albumsId', 'album.id')
.leftJoin('album_asset', 'album_asset.albumId', 'album.id')
.leftJoinLateral(
(eb) =>
eb
.selectFrom('asset')
.selectAll('asset')
.whereRef('album_asset.assetsId', '=', 'asset.id')
.whereRef('album_asset.assetId', '=', 'asset.id')
.where('asset.deletedAt', 'is', null)
.innerJoinLateral(
(eb) =>
@@ -123,13 +123,13 @@ export class SharedLinkRepository {
.selectFrom('shared_link')
.selectAll('shared_link')
.where('shared_link.userId', '=', userId)
.leftJoin('shared_link_asset', 'shared_link_asset.sharedLinksId', 'shared_link.id')
.leftJoin('shared_link_asset', 'shared_link_asset.sharedLinkId', 'shared_link.id')
.leftJoinLateral(
(eb) =>
eb
.selectFrom('asset')
.select((eb) => eb.fn.jsonAgg('asset').as('assets'))
.whereRef('asset.id', '=', 'shared_link_asset.assetsId')
.whereRef('asset.id', '=', 'shared_link_asset.assetId')
.where('asset.deletedAt', 'is', null)
.as('assets'),
(join) => join.onTrue(),
@@ -215,7 +215,7 @@ export class SharedLinkRepository {
if (entity.assetIds && entity.assetIds.length > 0) {
await this.db
.insertInto('shared_link_asset')
.values(entity.assetIds!.map((assetsId) => ({ assetsId, sharedLinksId: id })))
.values(entity.assetIds!.map((assetId) => ({ assetId, sharedLinkId: id })))
.execute();
}
@@ -233,7 +233,7 @@ export class SharedLinkRepository {
if (entity.assetIds && entity.assetIds.length > 0) {
await this.db
.insertInto('shared_link_asset')
.values(entity.assetIds!.map((assetsId) => ({ assetsId, sharedLinksId: id })))
.values(entity.assetIds!.map((assetId) => ({ assetId, sharedLinkId: id })))
.execute();
}
@@ -249,12 +249,12 @@ export class SharedLinkRepository {
.selectFrom('shared_link')
.selectAll('shared_link')
.where('shared_link.id', '=', id)
.leftJoin('shared_link_asset', 'shared_link_asset.sharedLinksId', 'shared_link.id')
.leftJoin('shared_link_asset', 'shared_link_asset.sharedLinkId', 'shared_link.id')
.leftJoinLateral(
(eb) =>
eb
.selectFrom('asset')
.whereRef('asset.id', '=', 'shared_link_asset.assetsId')
.whereRef('asset.id', '=', 'shared_link_asset.assetId')
.selectAll('asset')
.innerJoinLateral(
(eb) =>

View File

@@ -33,8 +33,8 @@ const withAssets = (eb: ExpressionBuilder<DB, 'stack'>, withTags = false) => {
eb
.selectFrom('tag')
.select(columns.tag)
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagsId')
.whereRef('tag_asset.assetsId', '=', 'asset.id'),
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId')
.whereRef('tag_asset.assetId', '=', 'asset.id'),
).as('tags'),
),
)

View File

@@ -62,7 +62,11 @@ export class StorageRepository {
}
createWriteStream(filepath: string): Writable {
return createWriteStream(filepath, { flags: 'w' });
return createWriteStream(filepath, { flags: 'w', highWaterMark: 1024 * 1024 });
}
createOrAppendWriteStream(filepath: string): Writable {
return createWriteStream(filepath, { flags: 'a', highWaterMark: 1024 * 1024 });
}
createOrOverwriteFile(filepath: string, buffer: Buffer) {
@@ -156,10 +160,13 @@ export class StorageRepository {
}
}
mkdir(filepath: string): Promise<string | undefined> {
return fs.mkdir(filepath, { recursive: true });
}
mkdirSync(filepath: string): void {
if (!existsSync(filepath)) {
mkdirSync(filepath, { recursive: true });
}
// does not throw an error if the folder already exists
mkdirSync(filepath, { recursive: true });
}
existsSync(filepath: string) {

View File

@@ -143,8 +143,8 @@ class AlbumSync extends BaseSync {
getCreatedAfter({ nowId, userId, afterCreateId }: SyncCreatedAfterOptions) {
return this.db
.selectFrom('album_user')
.select(['albumsId as id', 'createId'])
.where('usersId', '=', userId)
.select(['albumId as id', 'createId'])
.where('userId', '=', userId)
.$if(!!afterCreateId, (qb) => qb.where('createId', '>=', afterCreateId!))
.where('createId', '<', nowId)
.orderBy('createId', 'asc')
@@ -168,8 +168,8 @@ class AlbumSync extends BaseSync {
const userId = options.userId;
return this.upsertQuery('album', options)
.distinctOn(['album.id', 'album.updateId'])
.leftJoin('album_user as album_users', 'album.id', 'album_users.albumsId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_users.usersId', '=', userId)]))
.leftJoin('album_user as album_users', 'album.id', 'album_users.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_users.userId', '=', userId)]))
.select([
'album.id',
'album.ownerId',
@@ -190,10 +190,10 @@ class AlbumAssetSync extends BaseSync {
@GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID], stream: true })
getBackfill(options: SyncBackfillOptions, albumId: string) {
return this.backfillQuery('album_asset', options)
.innerJoin('asset', 'asset.id', 'album_asset.assetsId')
.innerJoin('asset', 'asset.id', 'album_asset.assetId')
.select(columns.syncAsset)
.select('album_asset.updateId')
.where('album_asset.albumsId', '=', albumId)
.where('album_asset.albumId', '=', albumId)
.stream();
}
@@ -201,13 +201,13 @@ class AlbumAssetSync extends BaseSync {
getUpdates(options: SyncQueryOptions, albumToAssetAck: SyncAck) {
const userId = options.userId;
return this.upsertQuery('asset', options)
.innerJoin('album_asset', 'album_asset.assetsId', 'asset.id')
.innerJoin('album_asset', 'album_asset.assetId', 'asset.id')
.select(columns.syncAsset)
.select('asset.updateId')
.where('album_asset.updateId', '<=', albumToAssetAck.updateId) // Ensure we only send updates for assets that the client already knows about
.innerJoin('album', 'album.id', 'album_asset.albumsId')
.leftJoin('album_user', 'album_user.albumsId', 'album_asset.albumsId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.usersId', '=', userId)]))
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.stream();
}
@@ -216,11 +216,11 @@ class AlbumAssetSync extends BaseSync {
const userId = options.userId;
return this.upsertQuery('album_asset', options)
.select('album_asset.updateId')
.innerJoin('asset', 'asset.id', 'album_asset.assetsId')
.innerJoin('asset', 'asset.id', 'album_asset.assetId')
.select(columns.syncAsset)
.innerJoin('album', 'album.id', 'album_asset.albumsId')
.leftJoin('album_user', 'album_user.albumsId', 'album_asset.albumsId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.usersId', '=', userId)]))
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.stream();
}
}
@@ -229,10 +229,10 @@ class AlbumAssetExifSync extends BaseSync {
@GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID], stream: true })
getBackfill(options: SyncBackfillOptions, albumId: string) {
return this.backfillQuery('album_asset', options)
.innerJoin('asset_exif', 'asset_exif.assetId', 'album_asset.assetsId')
.innerJoin('asset_exif', 'asset_exif.assetId', 'album_asset.assetId')
.select(columns.syncAssetExif)
.select('album_asset.updateId')
.where('album_asset.albumsId', '=', albumId)
.where('album_asset.albumId', '=', albumId)
.stream();
}
@@ -240,13 +240,13 @@ class AlbumAssetExifSync extends BaseSync {
getUpdates(options: SyncQueryOptions, albumToAssetAck: SyncAck) {
const userId = options.userId;
return this.upsertQuery('asset_exif', options)
.innerJoin('album_asset', 'album_asset.assetsId', 'asset_exif.assetId')
.innerJoin('album_asset', 'album_asset.assetId', 'asset_exif.assetId')
.select(columns.syncAssetExif)
.select('asset_exif.updateId')
.where('album_asset.updateId', '<=', albumToAssetAck.updateId) // Ensure we only send exif updates for assets that the client already knows about
.innerJoin('album', 'album.id', 'album_asset.albumsId')
.leftJoin('album_user', 'album_user.albumsId', 'album_asset.albumsId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.usersId', '=', userId)]))
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.stream();
}
@@ -255,11 +255,11 @@ class AlbumAssetExifSync extends BaseSync {
const userId = options.userId;
return this.upsertQuery('album_asset', options)
.select('album_asset.updateId')
.innerJoin('asset_exif', 'asset_exif.assetId', 'album_asset.assetsId')
.innerJoin('asset_exif', 'asset_exif.assetId', 'album_asset.assetId')
.select(columns.syncAssetExif)
.innerJoin('album', 'album.id', 'album_asset.albumsId')
.leftJoin('album_user', 'album_user.albumsId', 'album_asset.albumsId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.usersId', '=', userId)]))
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.stream();
}
}
@@ -268,8 +268,8 @@ class AlbumToAssetSync extends BaseSync {
@GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID], stream: true })
getBackfill(options: SyncBackfillOptions, albumId: string) {
return this.backfillQuery('album_asset', options)
.select(['album_asset.assetsId as assetId', 'album_asset.albumsId as albumId', 'album_asset.updateId'])
.where('album_asset.albumsId', '=', albumId)
.select(['album_asset.assetId as assetId', 'album_asset.albumId as albumId', 'album_asset.updateId'])
.where('album_asset.albumId', '=', albumId)
.stream();
}
@@ -290,8 +290,8 @@ class AlbumToAssetSync extends BaseSync {
eb.parens(
eb
.selectFrom('album_user')
.select(['album_user.albumsId as id'])
.where('album_user.usersId', '=', userId),
.select(['album_user.albumId as id'])
.where('album_user.userId', '=', userId),
),
),
),
@@ -307,10 +307,10 @@ class AlbumToAssetSync extends BaseSync {
getUpserts(options: SyncQueryOptions) {
const userId = options.userId;
return this.upsertQuery('album_asset', options)
.select(['album_asset.assetsId as assetId', 'album_asset.albumsId as albumId', 'album_asset.updateId'])
.innerJoin('album', 'album.id', 'album_asset.albumsId')
.leftJoin('album_user', 'album_user.albumsId', 'album_asset.albumsId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.usersId', '=', userId)]))
.select(['album_asset.assetId as assetId', 'album_asset.albumId as albumId', 'album_asset.updateId'])
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.stream();
}
}
@@ -321,7 +321,7 @@ class AlbumUserSync extends BaseSync {
return this.backfillQuery('album_user', options)
.select(columns.syncAlbumUser)
.select('album_user.updateId')
.where('albumsId', '=', albumId)
.where('albumId', '=', albumId)
.stream();
}
@@ -342,8 +342,8 @@ class AlbumUserSync extends BaseSync {
eb.parens(
eb
.selectFrom('album_user')
.select(['album_user.albumsId as id'])
.where('album_user.usersId', '=', userId),
.select(['album_user.albumId as id'])
.where('album_user.userId', '=', userId),
),
),
),
@@ -363,7 +363,7 @@ class AlbumUserSync extends BaseSync {
.select('album_user.updateId')
.where((eb) =>
eb(
'album_user.albumsId',
'album_user.albumId',
'in',
eb
.selectFrom('album')
@@ -373,8 +373,8 @@ class AlbumUserSync extends BaseSync {
eb.parens(
eb
.selectFrom('album_user as albumUsers')
.select(['albumUsers.albumsId as id'])
.where('albumUsers.usersId', '=', userId),
.select(['albumUsers.albumId as id'])
.where('albumUsers.userId', '=', userId),
),
),
),
@@ -550,7 +550,7 @@ class MemoryToAssetSync extends BaseSync {
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) {
return this.upsertQuery('memory_asset', options)
.select(['memoriesId as memoryId', 'assetsId as assetId'])
.select(['memoriesId as memoryId', 'assetId as assetId'])
.select('updateId')
.where('memoriesId', 'in', (eb) => eb.selectFrom('memory').select('id').where('ownerId', '=', options.userId))
.stream();

View File

@@ -97,9 +97,9 @@ export class TagRepository {
const results = await this.db
.selectFrom('tag_asset')
.select(['assetsId as assetId'])
.where('tagsId', '=', tagId)
.where('assetsId', 'in', assetIds)
.select(['assetId as assetId'])
.where('tagId', '=', tagId)
.where('assetId', 'in', assetIds)
.execute();
return new Set(results.map(({ assetId }) => assetId));
@@ -114,7 +114,7 @@ export class TagRepository {
await this.db
.insertInto('tag_asset')
.values(assetIds.map((assetId) => ({ tagsId: tagId, assetsId: assetId })))
.values(assetIds.map((assetId) => ({ tagId, assetId })))
.execute();
}
@@ -125,10 +125,10 @@ export class TagRepository {
return;
}
await this.db.deleteFrom('tag_asset').where('tagsId', '=', tagId).where('assetsId', 'in', assetIds).execute();
await this.db.deleteFrom('tag_asset').where('tagId', '=', tagId).where('assetId', 'in', assetIds).execute();
}
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, tagsIds: [DummyValue.UUID] }]] })
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, tagIds: DummyValue.UUID }]] })
@Chunked()
upsertAssetIds(items: Insertable<TagAssetTable>[]) {
if (items.length === 0) {
@@ -147,7 +147,7 @@ export class TagRepository {
@Chunked({ paramIndex: 1 })
replaceAssetTags(assetId: string, tagIds: string[]) {
return this.db.transaction().execute(async (tx) => {
await tx.deleteFrom('tag_asset').where('assetsId', '=', assetId).execute();
await tx.deleteFrom('tag_asset').where('assetId', '=', assetId).execute();
if (tagIds.length === 0) {
return;
@@ -155,7 +155,7 @@ export class TagRepository {
return tx
.insertInto('tag_asset')
.values(tagIds.map((tagId) => ({ tagsId: tagId, assetsId: assetId })))
.values(tagIds.map((tagId) => ({ tagId, assetId })))
.onConflict((oc) => oc.doNothing())
.returningAll()
.execute();
@@ -170,7 +170,7 @@ export class TagRepository {
exists(
selectFrom('tag_closure')
.whereRef('tag.id', '=', 'tag_closure.id_ancestor')
.innerJoin('tag_asset', 'tag_closure.id_descendant', 'tag_asset.tagsId'),
.innerJoin('tag_asset', 'tag_closure.id_descendant', 'tag_asset.tagId'),
),
),
)

View File

@@ -16,7 +16,7 @@ import { ArgsOf, EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { handlePromiseError } from 'src/utils/misc';
export const serverEvents = ['ConfigUpdate'] as const;
export const serverEvents = ['ConfigUpdate', 'UploadAbort'] as const;
export type ServerEvents = (typeof serverEvents)[number];
export interface ClientEventMap {

View File

@@ -29,7 +29,7 @@ export const album_user_after_insert = registerFunction({
body: `
BEGIN
UPDATE album SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp())
WHERE "id" IN (SELECT DISTINCT "albumsId" FROM inserted_rows);
WHERE "id" IN (SELECT DISTINCT "albumId" FROM inserted_rows);
RETURN NULL;
END`,
});
@@ -139,8 +139,8 @@ export const album_asset_delete_audit = registerFunction({
body: `
BEGIN
INSERT INTO album_asset_audit ("albumId", "assetId")
SELECT "albumsId", "assetsId" FROM OLD
WHERE "albumsId" IN (SELECT "id" FROM album WHERE "id" IN (SELECT "albumsId" FROM OLD));
SELECT "albumId", "assetId" FROM OLD
WHERE "albumId" IN (SELECT "id" FROM album WHERE "id" IN (SELECT "albumId" FROM OLD));
RETURN NULL;
END`,
});
@@ -152,12 +152,12 @@ export const album_user_delete_audit = registerFunction({
body: `
BEGIN
INSERT INTO album_audit ("albumId", "userId")
SELECT "albumsId", "usersId"
SELECT "albumId", "userId"
FROM OLD;
IF pg_trigger_depth() = 1 THEN
INSERT INTO album_user_audit ("albumId", "userId")
SELECT "albumsId", "usersId"
SELECT "albumId", "userId"
FROM OLD;
END IF;
@@ -185,7 +185,7 @@ export const memory_asset_delete_audit = registerFunction({
body: `
BEGIN
INSERT INTO memory_asset_audit ("memoryId", "assetId")
SELECT "memoriesId", "assetsId" FROM OLD
SELECT "memoriesId", "assetId" FROM OLD
WHERE "memoriesId" IN (SELECT "id" FROM memory WHERE "id" IN (SELECT "memoriesId" FROM OLD));
RETURN NULL;
END`,

View File

@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TYPE "assets_status_enum" ADD VALUE IF NOT EXISTS 'partial'`.execute(db);
}
export async function down(): Promise<void> {
// Cannot remove enum values in PostgreSQL
}

View File

@@ -0,0 +1,99 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// rename columns
await sql`ALTER TABLE "album_asset" RENAME COLUMN "albumsId" TO "albumId";`.execute(db);
await sql`ALTER TABLE "album_asset" RENAME COLUMN "assetsId" TO "assetId";`.execute(db);
await sql`ALTER TABLE "album_user" RENAME COLUMN "albumsId" TO "albumId";`.execute(db);
await sql`ALTER TABLE "album_user" RENAME COLUMN "usersId" TO "userId";`.execute(db);
await sql`ALTER TABLE "memory_asset" RENAME COLUMN "assetsId" TO "assetId";`.execute(db);
await sql`ALTER TABLE "shared_link_asset" RENAME COLUMN "assetsId" TO "assetId";`.execute(db);
await sql`ALTER TABLE "shared_link_asset" RENAME COLUMN "sharedLinksId" TO "sharedLinkId";`.execute(db);
await sql`ALTER TABLE "tag_asset" RENAME COLUMN "assetsId" TO "assetId";`.execute(db);
await sql`ALTER TABLE "tag_asset" RENAME COLUMN "tagsId" TO "tagId";`.execute(db);
// rename constraints
await sql`ALTER TABLE "album_asset" RENAME CONSTRAINT "album_asset_albumsId_fkey" TO "album_asset_albumId_fkey";`.execute(db);
await sql`ALTER TABLE "album_asset" RENAME CONSTRAINT "album_asset_assetsId_fkey" TO "album_asset_assetId_fkey";`.execute(db);
await sql`ALTER TABLE "album_user" RENAME CONSTRAINT "album_user_albumsId_fkey" TO "album_user_albumId_fkey";`.execute(db);
await sql`ALTER TABLE "album_user" RENAME CONSTRAINT "album_user_usersId_fkey" TO "album_user_userId_fkey";`.execute(db);
await sql`ALTER TABLE "memory_asset" RENAME CONSTRAINT "memory_asset_assetsId_fkey" TO "memory_asset_assetId_fkey";`.execute(db);
await sql`ALTER TABLE "shared_link_asset" RENAME CONSTRAINT "shared_link_asset_assetsId_fkey" TO "shared_link_asset_assetId_fkey";`.execute(db);
await sql`ALTER TABLE "shared_link_asset" RENAME CONSTRAINT "shared_link_asset_sharedLinksId_fkey" TO "shared_link_asset_sharedLinkId_fkey";`.execute(db);
await sql`ALTER TABLE "tag_asset" RENAME CONSTRAINT "tag_asset_assetsId_fkey" TO "tag_asset_assetId_fkey";`.execute(db);
await sql`ALTER TABLE "tag_asset" RENAME CONSTRAINT "tag_asset_tagsId_fkey" TO "tag_asset_tagId_fkey";`.execute(db);
// rename indexes
await sql`ALTER INDEX "album_asset_albumsId_idx" RENAME TO "album_asset_albumId_idx";`.execute(db);
await sql`ALTER INDEX "album_asset_assetsId_idx" RENAME TO "album_asset_assetId_idx";`.execute(db);
await sql`ALTER INDEX "album_user_usersId_idx" RENAME TO "album_user_userId_idx";`.execute(db);
await sql`ALTER INDEX "album_user_albumsId_idx" RENAME TO "album_user_albumId_idx";`.execute(db);
await sql`ALTER INDEX "memory_asset_assetsId_idx" RENAME TO "memory_asset_assetId_idx";`.execute(db);
await sql`ALTER INDEX "shared_link_asset_sharedLinksId_idx" RENAME TO "shared_link_asset_sharedLinkId_idx";`.execute(db);
await sql`ALTER INDEX "shared_link_asset_assetsId_idx" RENAME TO "shared_link_asset_assetId_idx";`.execute(db);
await sql`ALTER INDEX "tag_asset_assetsId_idx" RENAME TO "tag_asset_assetId_idx";`.execute(db);
await sql`ALTER INDEX "tag_asset_tagsId_idx" RENAME TO "tag_asset_tagId_idx";`.execute(db);
await sql`ALTER INDEX "tag_asset_assetsId_tagsId_idx" RENAME TO "tag_asset_assetId_tagId_idx";`.execute(db);
// update triggers and functions
await sql`CREATE OR REPLACE FUNCTION album_user_after_insert()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
UPDATE album SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp())
WHERE "id" IN (SELECT DISTINCT "albumId" FROM inserted_rows);
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE OR REPLACE FUNCTION album_asset_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO album_asset_audit ("albumId", "assetId")
SELECT "albumId", "assetId" FROM OLD
WHERE "albumId" IN (SELECT "id" FROM album WHERE "id" IN (SELECT "albumId" FROM OLD));
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE OR REPLACE FUNCTION album_user_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO album_audit ("albumId", "userId")
SELECT "albumId", "userId"
FROM OLD;
IF pg_trigger_depth() = 1 THEN
INSERT INTO album_user_audit ("albumId", "userId")
SELECT "albumId", "userId"
FROM OLD;
END IF;
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE OR REPLACE FUNCTION memory_asset_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO memory_asset_audit ("memoryId", "assetId")
SELECT "memoriesId", "assetId" FROM OLD
WHERE "memoriesId" IN (SELECT "id" FROM memory WHERE "id" IN (SELECT "memoriesId" FROM OLD));
RETURN NULL;
END
$$;`.execute(db);
// update overrides
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"album_user_after_insert","sql":"CREATE OR REPLACE FUNCTION album_user_after_insert()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE album SET \\"updatedAt\\" = clock_timestamp(), \\"updateId\\" = immich_uuid_v7(clock_timestamp())\\n WHERE \\"id\\" IN (SELECT DISTINCT \\"albumId\\" FROM inserted_rows);\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_album_user_after_insert';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"album_asset_delete_audit","sql":"CREATE OR REPLACE FUNCTION album_asset_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO album_asset_audit (\\"albumId\\", \\"assetId\\")\\n SELECT \\"albumId\\", \\"assetId\\" FROM OLD\\n WHERE \\"albumId\\" IN (SELECT \\"id\\" FROM album WHERE \\"id\\" IN (SELECT \\"albumId\\" FROM OLD));\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_album_asset_delete_audit';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"album_user_delete_audit","sql":"CREATE OR REPLACE FUNCTION album_user_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO album_audit (\\"albumId\\", \\"userId\\")\\n SELECT \\"albumId\\", \\"userId\\"\\n FROM OLD;\\n\\n IF pg_trigger_depth() = 1 THEN\\n INSERT INTO album_user_audit (\\"albumId\\", \\"userId\\")\\n SELECT \\"albumId\\", \\"userId\\"\\n FROM OLD;\\n END IF;\\n\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_album_user_delete_audit';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"memory_asset_delete_audit","sql":"CREATE OR REPLACE FUNCTION memory_asset_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO memory_asset_audit (\\"memoryId\\", \\"assetId\\")\\n SELECT \\"memoriesId\\", \\"assetId\\" FROM OLD\\n WHERE \\"memoriesId\\" IN (SELECT \\"id\\" FROM memory WHERE \\"id\\" IN (SELECT \\"memoriesId\\" FROM OLD));\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_memory_asset_delete_audit';`.execute(db);
}
export function down() {
// not implemented
}

View File

@@ -32,7 +32,7 @@ import {
@ForeignKeyConstraint({
columns: ['albumId', 'assetId'],
referenceTable: () => AlbumAssetTable,
referenceColumns: ['albumsId', 'assetsId'],
referenceColumns: ['albumId', 'assetId'],
onUpdate: 'NO ACTION',
onDelete: 'CASCADE',
})

View File

@@ -22,10 +22,10 @@ import {
})
export class AlbumAssetTable {
@ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true })
albumsId!: string;
albumId!: string;
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true })
assetsId!: string;
assetId!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;

View File

@@ -37,7 +37,7 @@ export class AlbumUserTable {
nullable: false,
primary: true,
})
albumsId!: string;
albumId!: string;
@ForeignKeyColumn(() => UserTable, {
onDelete: 'CASCADE',
@@ -45,7 +45,7 @@ export class AlbumUserTable {
nullable: false,
primary: true,
})
usersId!: string;
userId!: string;
@Column({ type: 'character varying', default: AlbumUserRole.Editor })
role!: Generated<AlbumUserRole>;

View File

@@ -25,7 +25,7 @@ export class MemoryAssetTable {
memoriesId!: string;
@ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
assetsId!: string;
assetId!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;

View File

@@ -5,8 +5,8 @@ import { ForeignKeyColumn, Table } from 'src/sql-tools';
@Table('shared_link_asset')
export class SharedLinkAssetTable {
@ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
assetsId!: string;
assetId!: string;
@ForeignKeyColumn(() => SharedLinkTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
sharedLinksId!: string;
sharedLinkId!: string;
}

View File

@@ -2,12 +2,12 @@ import { AssetTable } from 'src/schema/tables/asset.table';
import { TagTable } from 'src/schema/tables/tag.table';
import { ForeignKeyColumn, Index, Table } from 'src/sql-tools';
@Index({ columns: ['assetsId', 'tagsId'] })
@Index({ columns: ['assetId', 'tagId'] })
@Table('tag_asset')
export class TagAssetTable {
@ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true, index: true })
assetsId!: string;
assetId!: string;
@ForeignKeyColumn(() => TagTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true, index: true })
tagsId!: string;
tagId!: string;
}

View File

@@ -402,16 +402,16 @@ describe(AlbumService.name, () => {
mocks.album.update.mockResolvedValue(albumStub.sharedWithAdmin);
mocks.user.get.mockResolvedValue(userStub.user2);
mocks.albumUser.create.mockResolvedValue({
usersId: userStub.user2.id,
albumsId: albumStub.sharedWithAdmin.id,
userId: userStub.user2.id,
albumId: albumStub.sharedWithAdmin.id,
role: AlbumUserRole.Editor,
});
await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, {
albumUsers: [{ userId: authStub.user2.user.id }],
});
expect(mocks.albumUser.create).toHaveBeenCalledWith({
usersId: authStub.user2.user.id,
albumsId: albumStub.sharedWithAdmin.id,
userId: authStub.user2.user.id,
albumId: albumStub.sharedWithAdmin.id,
});
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', {
id: albumStub.sharedWithAdmin.id,
@@ -439,8 +439,8 @@ describe(AlbumService.name, () => {
expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1);
expect(mocks.albumUser.delete).toHaveBeenCalledWith({
albumsId: albumStub.sharedWithUser.id,
usersId: userStub.user1.id,
albumId: albumStub.sharedWithUser.id,
userId: userStub.user1.id,
});
expect(mocks.album.getById).toHaveBeenCalledWith(albumStub.sharedWithUser.id, { withAssets: false });
});
@@ -467,8 +467,8 @@ describe(AlbumService.name, () => {
expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1);
expect(mocks.albumUser.delete).toHaveBeenCalledWith({
albumsId: albumStub.sharedWithUser.id,
usersId: authStub.user1.user.id,
albumId: albumStub.sharedWithUser.id,
userId: authStub.user1.user.id,
});
});
@@ -480,8 +480,8 @@ describe(AlbumService.name, () => {
expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1);
expect(mocks.albumUser.delete).toHaveBeenCalledWith({
albumsId: albumStub.sharedWithUser.id,
usersId: authStub.user1.user.id,
albumId: albumStub.sharedWithUser.id,
userId: authStub.user1.user.id,
});
});
@@ -515,7 +515,7 @@ describe(AlbumService.name, () => {
role: AlbumUserRole.Editor,
});
expect(mocks.albumUser.update).toHaveBeenCalledWith(
{ albumsId: albumStub.sharedWithAdmin.id, usersId: userStub.admin.id },
{ albumId: albumStub.sharedWithAdmin.id, userId: userStub.admin.id },
{ role: AlbumUserRole.Editor },
);
});
@@ -804,12 +804,12 @@ describe(AlbumService.name, () => {
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumsId: 'album-123', assetsId: 'asset-1' },
{ albumsId: 'album-123', assetsId: 'asset-2' },
{ albumsId: 'album-123', assetsId: 'asset-3' },
{ albumsId: 'album-321', assetsId: 'asset-1' },
{ albumsId: 'album-321', assetsId: 'asset-2' },
{ albumsId: 'album-321', assetsId: 'asset-3' },
{ albumId: 'album-123', assetId: 'asset-1' },
{ albumId: 'album-123', assetId: 'asset-2' },
{ albumId: 'album-123', assetId: 'asset-3' },
{ albumId: 'album-321', assetId: 'asset-1' },
{ albumId: 'album-321', assetId: 'asset-2' },
{ albumId: 'album-321', assetId: 'asset-3' },
]);
});
@@ -840,12 +840,12 @@ describe(AlbumService.name, () => {
albumThumbnailAssetId: 'asset-id',
});
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumsId: 'album-123', assetsId: 'asset-1' },
{ albumsId: 'album-123', assetsId: 'asset-2' },
{ albumsId: 'album-123', assetsId: 'asset-3' },
{ albumsId: 'album-321', assetsId: 'asset-1' },
{ albumsId: 'album-321', assetsId: 'asset-2' },
{ albumsId: 'album-321', assetsId: 'asset-3' },
{ albumId: 'album-123', assetId: 'asset-1' },
{ albumId: 'album-123', assetId: 'asset-2' },
{ albumId: 'album-123', assetId: 'asset-3' },
{ albumId: 'album-321', assetId: 'asset-1' },
{ albumId: 'album-321', assetId: 'asset-2' },
{ albumId: 'album-321', assetId: 'asset-3' },
]);
});
@@ -876,12 +876,12 @@ describe(AlbumService.name, () => {
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumsId: 'album-123', assetsId: 'asset-1' },
{ albumsId: 'album-123', assetsId: 'asset-2' },
{ albumsId: 'album-123', assetsId: 'asset-3' },
{ albumsId: 'album-321', assetsId: 'asset-1' },
{ albumsId: 'album-321', assetsId: 'asset-2' },
{ albumsId: 'album-321', assetsId: 'asset-3' },
{ albumId: 'album-123', assetId: 'asset-1' },
{ albumId: 'album-123', assetId: 'asset-2' },
{ albumId: 'album-123', assetId: 'asset-3' },
{ albumId: 'album-321', assetId: 'asset-1' },
{ albumId: 'album-321', assetId: 'asset-2' },
{ albumId: 'album-321', assetId: 'asset-3' },
]);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: 'album-123',
@@ -936,9 +936,9 @@ describe(AlbumService.name, () => {
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumsId: 'album-123', assetsId: 'asset-1' },
{ albumsId: 'album-123', assetsId: 'asset-2' },
{ albumsId: 'album-123', assetsId: 'asset-3' },
{ albumId: 'album-123', assetId: 'asset-1' },
{ albumId: 'album-123', assetId: 'asset-2' },
{ albumId: 'album-123', assetId: 'asset-3' },
]);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: 'album-123',
@@ -977,12 +977,12 @@ describe(AlbumService.name, () => {
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumsId: 'album-123', assetsId: 'asset-1' },
{ albumsId: 'album-123', assetsId: 'asset-2' },
{ albumsId: 'album-123', assetsId: 'asset-3' },
{ albumsId: 'album-321', assetsId: 'asset-1' },
{ albumsId: 'album-321', assetsId: 'asset-2' },
{ albumsId: 'album-321', assetsId: 'asset-3' },
{ albumId: 'album-123', assetId: 'asset-1' },
{ albumId: 'album-123', assetId: 'asset-2' },
{ albumId: 'album-123', assetId: 'asset-3' },
{ albumId: 'album-321', assetId: 'asset-1' },
{ albumId: 'album-321', assetId: 'asset-2' },
{ albumId: 'album-321', assetId: 'asset-3' },
]);
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
@@ -1014,9 +1014,9 @@ describe(AlbumService.name, () => {
albumThumbnailAssetId: 'asset-1',
});
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumsId: 'album-321', assetsId: 'asset-1' },
{ albumsId: 'album-321', assetsId: 'asset-2' },
{ albumsId: 'album-321', assetsId: 'asset-3' },
{ albumId: 'album-321', assetId: 'asset-1' },
{ albumId: 'album-321', assetId: 'asset-2' },
{ albumId: 'album-321', assetId: 'asset-3' },
]);
});

View File

@@ -215,7 +215,7 @@ export class AlbumService extends BaseService {
return results;
}
const albumAssetValues: { albumsId: string; assetsId: string }[] = [];
const albumAssetValues: { albumId: string; assetId: string }[] = [];
const events: { id: string; recipients: string[] }[] = [];
for (const albumId of allowedAlbumIds) {
const existingAssetIds = await this.albumRepository.getAssetIds(albumId, [...allowedAssetIds]);
@@ -228,7 +228,7 @@ export class AlbumService extends BaseService {
results.success = true;
for (const assetId of notPresentAssetIds) {
albumAssetValues.push({ albumsId: albumId, assetsId: assetId });
albumAssetValues.push({ albumId, assetId });
}
await this.albumRepository.update(albumId, {
id: albumId,
@@ -289,7 +289,7 @@ export class AlbumService extends BaseService {
throw new BadRequestException('User not found');
}
await this.albumUserRepository.create({ usersId: userId, albumsId: id, role });
await this.albumUserRepository.create({ userId, albumId: id, role });
await this.eventRepository.emit('AlbumInvite', { id, userId });
}
@@ -317,12 +317,12 @@ export class AlbumService extends BaseService {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
}
await this.albumUserRepository.delete({ albumsId: id, usersId: userId });
await this.albumUserRepository.delete({ albumId: id, userId });
}
async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
await this.albumUserRepository.update({ albumsId: id, usersId: userId }, { role: dto.role });
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
}
private async findOrFail(id: string, options: AlbumInfoOptions) {

View File

@@ -215,7 +215,11 @@ describe(AssetMediaService.name, () => {
});
it('should find an existing asset', async () => {
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue({
id: 'asset-id',
createdAt: new Date(),
status: AssetStatus.Active,
});
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toEqual({
id: 'asset-id',
status: AssetMediaStatus.DUPLICATE,
@@ -224,7 +228,11 @@ describe(AssetMediaService.name, () => {
});
it('should find an existing asset by base64', async () => {
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue({
id: 'asset-id',
createdAt: new Date(),
status: AssetStatus.Active,
});
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('base64'))).resolves.toEqual({
id: 'asset-id',
status: AssetMediaStatus.DUPLICATE,
@@ -378,7 +386,11 @@ describe(AssetMediaService.name, () => {
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
mocks.asset.create.mockRejectedValue(error);
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(assetEntity.id);
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue({
id: assetEntity.id,
createdAt: new Date(),
status: AssetStatus.Active,
});
await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({
id: 'id_1',
@@ -803,7 +815,11 @@ describe(AssetMediaService.name, () => {
mocks.asset.update.mockRejectedValue(error);
mocks.asset.getById.mockResolvedValueOnce(sidecarAsset);
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id);
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue({
id: sidecarAsset.id,
createdAt: new Date(),
status: AssetStatus.Active,
});
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
// this is the original file size
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);

View File

@@ -43,12 +43,12 @@ export class AssetMediaService extends BaseService {
return;
}
const assetId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, fromChecksum(checksum));
if (!assetId) {
const asset = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, fromChecksum(checksum));
if (!asset) {
return;
}
return { id: assetId, status: AssetMediaStatus.DUPLICATE };
return { id: asset.id, status: AssetMediaStatus.DUPLICATE };
}
canUploadFile({ auth, fieldName, file }: UploadRequest): true {
@@ -165,6 +165,10 @@ export class AssetMediaService extends BaseService {
throw new Error('Asset not found');
}
if (asset.status === AssetStatus.Partial) {
throw new BadRequestException('Cannot replace a partial asset');
}
this.requireQuota(auth, file.size);
await this.replaceFileData(asset.id, dto, file, sidecarFile?.originalPath);
@@ -313,12 +317,12 @@ export class AssetMediaService extends BaseService {
// handle duplicates with a success response
if (isAssetChecksumConstraint(error)) {
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
if (!duplicateId) {
const duplicate = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
if (!duplicate) {
this.logger.error(`Error locating duplicate for checksum constraint`);
throw new InternalServerErrorException();
}
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
return { status: AssetMediaStatus.DUPLICATE, id: duplicate.id };
}
this.logger.error(`Error uploading file ${error}`, error?.stack);

View File

@@ -0,0 +1,456 @@
import { BadRequestException, InternalServerErrorException } from '@nestjs/common';
import { AssetMetadataKey, AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
import { AssetUploadService } from 'src/services/asset-upload.service';
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
import { authStub } from 'test/fixtures/auth.stub';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
describe(AssetUploadService.name, () => {
let sut: AssetUploadService;
let mocks: ServiceMocks;
beforeEach(() => {
({ sut, mocks } = newTestService(AssetUploadService));
});
describe('onStart', () => {
const mockDto = {
assetData: {
filename: 'test.jpg',
deviceAssetId: 'device-asset-1',
deviceId: 'device-1',
fileCreatedAt: new Date('2025-01-01T00:00:00Z'),
fileModifiedAt: new Date('2025-01-01T12:00:00Z'),
isFavorite: false,
iCloudId: '',
},
checksum: Buffer.from('checksum'),
uploadLength: 1024,
uploadComplete: true,
contentLength: 1024,
isComplete: true,
version: 8,
};
it('should create a new asset and return upload metadata', async () => {
const assetId = factory.uuid();
mocks.crypto.randomUUID.mockReturnValue(assetId);
const result = await sut.onStart(authStub.user1, mockDto);
expect(result).toEqual({
id: assetId,
path: expect.stringContaining(assetId),
status: AssetStatus.Partial,
isDuplicate: false,
});
expect(mocks.asset.createWithMetadata).toHaveBeenCalledWith(
expect.objectContaining({
id: assetId,
ownerId: authStub.user1.user.id,
checksum: mockDto.checksum,
deviceAssetId: mockDto.assetData.deviceAssetId,
deviceId: mockDto.assetData.deviceId,
fileCreatedAt: mockDto.assetData.fileCreatedAt,
fileModifiedAt: mockDto.assetData.fileModifiedAt,
type: AssetType.Image,
isFavorite: false,
status: AssetStatus.Partial,
visibility: AssetVisibility.Hidden,
originalFileName: 'test.jpg',
}),
1024,
undefined,
);
});
it('should determine asset type from filename extension', async () => {
const videoDto = { ...mockDto, assetData: { ...mockDto.assetData, filename: 'video.mp4' } };
mocks.crypto.randomUUID.mockReturnValue(factory.uuid());
await sut.onStart(authStub.user1, videoDto);
expect(mocks.asset.createWithMetadata).toHaveBeenCalledWith(
expect.objectContaining({
type: AssetType.Video,
}),
expect.anything(),
undefined,
);
});
it('should throw BadRequestException for unsupported file types', async () => {
const unsupportedDto = { ...mockDto, assetData: { ...mockDto.assetData, filename: 'document.xyz' } };
await expect(sut.onStart(authStub.user1, unsupportedDto)).rejects.toThrow(BadRequestException);
await expect(sut.onStart(authStub.user1, unsupportedDto)).rejects.toThrow('unsupported file type');
});
it('should validate quota before creating asset', async () => {
const authWithQuota = {
...authStub.user1,
user: {
...authStub.user1.user,
quotaSizeInBytes: 2000,
quotaUsageInBytes: 1500,
},
};
await expect(sut.onStart(authWithQuota, mockDto)).rejects.toThrow(BadRequestException);
await expect(sut.onStart(authWithQuota, mockDto)).rejects.toThrow('Quota has been exceeded');
});
it('should allow upload when quota is null (unlimited)', async () => {
const authWithUnlimitedQuota = {
...authStub.user1,
user: {
...authStub.user1.user,
quotaSizeInBytes: null,
quotaUsageInBytes: 1000,
},
};
mocks.crypto.randomUUID.mockReturnValue(factory.uuid());
await expect(sut.onStart(authWithUnlimitedQuota, mockDto)).resolves.toBeDefined();
});
it('should allow upload when within quota', async () => {
const authWithQuota = {
...authStub.user1,
user: {
...authStub.user1.user,
quotaSizeInBytes: 5000,
quotaUsageInBytes: 1000,
},
};
mocks.crypto.randomUUID.mockReturnValue(factory.uuid());
const result = await sut.onStart(authWithQuota, mockDto);
expect(result.isDuplicate).toBe(false);
});
it('should handle duplicate detection via checksum constraint', async () => {
const existingAssetId = factory.uuid();
const checksumError = new Error('duplicate key value violates unique constraint');
(checksumError as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
mocks.asset.createWithMetadata.mockRejectedValue(checksumError);
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue({
id: existingAssetId,
status: AssetStatus.Partial,
createdAt: new Date(),
});
const result = await sut.onStart(authStub.user1, mockDto);
expect(result).toEqual({
id: existingAssetId,
path: expect.any(String),
status: AssetStatus.Partial,
isDuplicate: true,
});
expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.user1.user.id, mockDto.checksum);
});
it('should throw InternalServerErrorException if duplicate lookup fails', async () => {
const checksumError = new Error('duplicate key value violates unique constraint');
(checksumError as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
mocks.asset.createWithMetadata.mockRejectedValue(checksumError);
// eslint-disable-next-line unicorn/no-useless-undefined
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(undefined);
await expect(sut.onStart(authStub.user1, mockDto)).rejects.toThrow(InternalServerErrorException);
});
it('should throw InternalServerErrorException for non-checksum errors', async () => {
const genericError = new Error('database connection failed');
mocks.asset.createWithMetadata.mockRejectedValue(genericError);
await expect(sut.onStart(authStub.user1, mockDto)).rejects.toThrow(InternalServerErrorException);
});
it('should include iCloud metadata when provided', async () => {
const dtoWithICloud = {
...mockDto,
assetData: {
...mockDto.assetData,
iCloudId: 'icloud-123',
},
};
mocks.crypto.randomUUID.mockReturnValue(factory.uuid());
await sut.onStart(authStub.user1, dtoWithICloud);
expect(mocks.asset.createWithMetadata).toHaveBeenCalledWith(expect.anything(), expect.anything(), [
{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'icloud-123' } },
]);
});
it('should set isFavorite when true', async () => {
const favoriteDto = {
...mockDto,
assetData: {
...mockDto.assetData,
isFavorite: true,
},
};
mocks.crypto.randomUUID.mockReturnValue(factory.uuid());
await sut.onStart(authStub.user1, favoriteDto);
expect(mocks.asset.createWithMetadata).toHaveBeenCalledWith(
expect.objectContaining({
isFavorite: true,
}),
expect.anything(),
undefined,
);
});
});
describe('onComplete', () => {
const assetId = factory.uuid();
const path = `/upload/${assetId}/file.jpg`;
const fileModifiedAt = new Date('2025-01-01T12:00:00Z');
it('should mark asset as complete and queue metadata extraction job', async () => {
await sut.onComplete({ id: assetId, path, fileModifiedAt });
expect(mocks.asset.setComplete).toHaveBeenCalledWith(assetId);
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.AssetExtractMetadata,
data: { id: assetId, source: 'upload' },
});
});
it('should update file modification time', async () => {
await sut.onComplete({ id: assetId, path, fileModifiedAt });
expect(mocks.storage.utimes).toHaveBeenCalledWith(path, expect.any(Date), fileModifiedAt);
});
it('should handle utimes failure gracefully', async () => {
mocks.storage.utimes.mockRejectedValue(new Error('Permission denied'));
await expect(sut.onComplete({ id: assetId, path, fileModifiedAt })).resolves.toBeUndefined();
// Should still complete asset and queue job
expect(mocks.asset.setComplete).toHaveBeenCalled();
expect(mocks.job.queue).toHaveBeenCalled();
});
it('should retry setComplete on transient failures', async () => {
mocks.asset.setComplete
.mockRejectedValueOnce(new Error('Transient error'))
.mockRejectedValueOnce(new Error('Transient error'))
.mockResolvedValue();
await sut.onComplete({ id: assetId, path, fileModifiedAt });
expect(mocks.asset.setComplete).toHaveBeenCalledTimes(3);
});
it('should retry job queueing on transient failures', async () => {
mocks.job.queue.mockRejectedValueOnce(new Error('Transient error')).mockResolvedValue();
await sut.onComplete({ id: assetId, path, fileModifiedAt });
expect(mocks.job.queue).toHaveBeenCalledTimes(2);
});
});
describe('onCancel', () => {
const assetId = factory.uuid();
const path = `/upload/${assetId}/file.jpg`;
it('should delete file and remove asset record', async () => {
await sut.onCancel(assetId, path);
expect(mocks.storage.unlink).toHaveBeenCalledWith(path);
expect(mocks.asset.removeAndDecrementQuota).toHaveBeenCalledWith(assetId);
});
it('should retry unlink on transient failures', async () => {
mocks.storage.unlink.mockRejectedValueOnce(new Error('Transient error')).mockResolvedValue();
await sut.onCancel(assetId, path);
expect(mocks.storage.unlink).toHaveBeenCalledTimes(2);
});
it('should retry removeAndDecrementQuota on transient failures', async () => {
mocks.asset.removeAndDecrementQuota.mockRejectedValueOnce(new Error('Transient error')).mockResolvedValue();
await sut.onCancel(assetId, path);
expect(mocks.asset.removeAndDecrementQuota).toHaveBeenCalledTimes(2);
});
});
describe('removeStaleUploads', () => {
it('should queue cleanup jobs for stale partial assets', async () => {
const staleAssets = [{ id: factory.uuid() }, { id: factory.uuid() }, { id: factory.uuid() }];
mocks.assetJob.streamForPartialAssetCleanupJob.mockReturnValue(
// eslint-disable-next-line @typescript-eslint/require-await
(async function* () {
for (const asset of staleAssets) {
yield asset;
}
})(),
);
await sut.removeStaleUploads();
expect(mocks.assetJob.streamForPartialAssetCleanupJob).toHaveBeenCalledWith(expect.any(Date));
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ name: JobName.PartialAssetCleanup, data: staleAssets[0] },
{ name: JobName.PartialAssetCleanup, data: staleAssets[1] },
{ name: JobName.PartialAssetCleanup, data: staleAssets[2] },
]);
});
it('should batch cleanup jobs', async () => {
const assets = Array.from({ length: 1500 }, () => ({ id: factory.uuid() }));
mocks.assetJob.streamForPartialAssetCleanupJob.mockReturnValue(
// eslint-disable-next-line @typescript-eslint/require-await
(async function* () {
for (const asset of assets) {
yield asset;
}
})(),
);
await sut.removeStaleUploads();
// Should be called twice: once for 1000, once for 500
expect(mocks.job.queueAll).toHaveBeenCalledTimes(2);
});
it('should handle empty stream', async () => {
mocks.assetJob.streamForPartialAssetCleanupJob.mockReturnValue((async function* () {})());
await sut.removeStaleUploads();
expect(mocks.job.queueAll).toHaveBeenCalledWith([]);
});
});
describe('removeStaleUpload', () => {
const assetId = factory.uuid();
const path = `/upload/${assetId}/file.jpg`;
it('should skip if asset not found', async () => {
// eslint-disable-next-line unicorn/no-useless-undefined
mocks.assetJob.getForPartialAssetCleanupJob.mockResolvedValue(undefined);
const result = await sut.removeStaleUpload({ id: assetId });
expect(result).toBe(JobStatus.Skipped);
expect(mocks.storage.stat).not.toHaveBeenCalled();
});
it('should complete asset if file matches expected state', async () => {
const checksum = Buffer.from('checksum');
const fileModifiedAt = new Date();
mocks.assetJob.getForPartialAssetCleanupJob.mockResolvedValue({
path,
checksum,
fileModifiedAt,
size: 1024,
});
mocks.storage.stat.mockResolvedValue({ size: 1024 } as any);
mocks.crypto.hashFile.mockResolvedValue(checksum);
const result = await sut.removeStaleUpload({ id: assetId });
expect(result).toBe(JobStatus.Success);
expect(mocks.asset.setComplete).toHaveBeenCalledWith(assetId);
expect(mocks.storage.unlink).not.toHaveBeenCalled();
});
it('should cancel asset if file size does not match', async () => {
mocks.assetJob.getForPartialAssetCleanupJob.mockResolvedValue({
path,
checksum: Buffer.from('checksum'),
fileModifiedAt: new Date(),
size: 1024,
});
mocks.storage.stat.mockResolvedValue({ size: 512 } as any);
const result = await sut.removeStaleUpload({ id: assetId });
expect(result).toBe(JobStatus.Success);
expect(mocks.storage.unlink).toHaveBeenCalledWith(path);
expect(mocks.asset.removeAndDecrementQuota).toHaveBeenCalledWith(assetId);
});
it('should cancel asset if checksum does not match', async () => {
mocks.assetJob.getForPartialAssetCleanupJob.mockResolvedValue({
path,
checksum: Buffer.from('expected-checksum'),
fileModifiedAt: new Date(),
size: 1024,
});
mocks.storage.stat.mockResolvedValue({ size: 1024 } as any);
mocks.crypto.hashFile.mockResolvedValue(Buffer.from('actual-checksum'));
const result = await sut.removeStaleUpload({ id: assetId });
expect(result).toBe(JobStatus.Success);
expect(mocks.storage.unlink).toHaveBeenCalledWith(path);
expect(mocks.asset.removeAndDecrementQuota).toHaveBeenCalledWith(assetId);
});
it('should cancel asset if file does not exist', async () => {
mocks.assetJob.getForPartialAssetCleanupJob.mockResolvedValue({
path,
checksum: Buffer.from('checksum'),
fileModifiedAt: new Date(),
size: 1024,
});
const error = new Error('File not found') as NodeJS.ErrnoException;
error.code = 'ENOENT';
mocks.storage.stat.mockRejectedValue(error);
const result = await sut.removeStaleUpload({ id: assetId });
expect(result).toBe(JobStatus.Success);
expect(mocks.asset.removeAndDecrementQuota).toHaveBeenCalledWith(assetId);
});
it('should cancel asset if stat fails with permission error', async () => {
mocks.assetJob.getForPartialAssetCleanupJob.mockResolvedValue({
path,
checksum: Buffer.from('checksum'),
fileModifiedAt: new Date(),
size: 1024,
});
const error = new Error('Permission denied') as NodeJS.ErrnoException;
error.code = 'EACCES';
mocks.storage.stat.mockRejectedValue(error);
const result = await sut.removeStaleUpload({ id: assetId });
expect(result).toBe(JobStatus.Success);
expect(mocks.asset.removeAndDecrementQuota).toHaveBeenCalledWith(assetId);
});
});
});

View File

@@ -0,0 +1,454 @@
import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { Response } from 'express';
import { DateTime } from 'luxon';
import { createHash } from 'node:crypto';
import { dirname, extname, join } from 'node:path';
import { Readable, Writable } from 'node:stream';
import { SystemConfig } from 'src/config';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators';
import { GetUploadStatusDto, ResumeUploadDto, StartUploadDto } from 'src/dtos/asset-upload.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetMetadataKey,
AssetStatus,
AssetType,
AssetVisibility,
ImmichWorker,
JobName,
JobStatus,
QueueName,
StorageFolder,
} from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types';
import { isAssetChecksumConstraint } from 'src/utils/database';
import { mimeTypes } from 'src/utils/mime-types';
import { withRetry } from 'src/utils/misc';
export const MAX_RUFH_INTEROP_VERSION = 8;
@Injectable()
export class AssetUploadService extends BaseService {
// This is used to proactively abort previous requests for the same asset
// when a new one arrives. The previous request still holds the asset lock
// and will prevent the new request from proceeding until the previous one
// times out. As normal client behavior will not have concurrent requests,
// we can assume the previous request has already failed on the client end.
private activeRequests = new Map<string, { req: Readable; startTime: Date }>();
@OnEvent({ name: 'UploadAbort', workers: [ImmichWorker.Api], server: true })
onUploadAbort({ assetId, abortTime }: ArgOf<'UploadAbort'>) {
const entry = this.activeRequests.get(assetId);
if (!entry) {
return false;
}
if (abortTime > entry.startTime) {
entry.req.destroy();
this.activeRequests.delete(assetId);
}
return true;
}
async startUpload(auth: AuthDto, req: Readable, res: Response, dto: StartUploadDto): Promise<void> {
this.logger.verboseFn(() => `Starting upload: ${JSON.stringify(dto)}`);
const { uploadComplete, assetData, uploadLength, contentLength, version } = dto;
const isComplete = uploadComplete !== false;
const isResumable = version && uploadComplete !== undefined;
const { backup } = await this.getConfig({ withCache: true });
const asset = await this.onStart(auth, dto);
if (asset.isDuplicate) {
if (asset.status !== AssetStatus.Partial) {
return this.sendAlreadyCompleted(res);
}
const location = `/api/upload/${asset.id}`;
if (isResumable) {
this.sendInterimResponse(res, location, version, this.getUploadLimits(backup));
// this is a 5xx to indicate the client should do offset retrieval and resume
res.status(500).send('Incomplete asset already exists');
return;
}
}
if (isComplete && uploadLength !== contentLength) {
return this.sendInconsistentLength(res);
}
const location = `/api/upload/${asset.id}`;
if (isResumable) {
this.sendInterimResponse(res, location, version, this.getUploadLimits(backup));
}
this.addRequest(asset.id, req);
await this.databaseRepository.withUuidLock(asset.id, async () => {
// conventional upload, check status again with lock acquired before overwriting
if (asset.isDuplicate) {
const existingAsset = await this.assetRepository.getCompletionMetadata(asset.id, auth.user.id);
if (existingAsset?.status !== AssetStatus.Partial) {
return this.sendAlreadyCompleted(res);
}
}
await this.storageRepository.mkdir(dirname(asset.path));
let checksumBuffer: Buffer | undefined;
const writeStream = asset.isDuplicate
? this.storageRepository.createWriteStream(asset.path)
: this.storageRepository.createOrAppendWriteStream(asset.path);
this.pipe(req, writeStream, contentLength);
if (isComplete) {
const hash = createHash('sha1');
req.on('data', (data: Buffer) => hash.update(data));
writeStream.on('finish', () => (checksumBuffer = hash.digest()));
}
await new Promise((resolve, reject) => writeStream.on('close', resolve).on('error', reject));
if (isResumable) {
this.setCompleteHeader(res, version, uploadComplete);
}
if (!isComplete) {
res.status(201).set('Location', location).setHeader('Upload-Limit', this.getUploadLimits(backup)).send();
return;
}
if (dto.checksum.compare(checksumBuffer!) !== 0) {
return await this.sendChecksumMismatch(res, asset.id, asset.path);
}
await this.onComplete({ id: asset.id, path: asset.path, fileModifiedAt: assetData.fileModifiedAt });
res.status(200).send({ id: asset.id });
});
}
resumeUpload(auth: AuthDto, req: Readable, res: Response, id: string, dto: ResumeUploadDto): Promise<void> {
this.logger.verboseFn(() => `Resuming upload for ${id}: ${JSON.stringify(dto)}`);
const { uploadComplete, uploadLength, uploadOffset, contentLength, version } = dto;
this.setCompleteHeader(res, version, false);
this.addRequest(id, req);
return this.databaseRepository.withUuidLock(id, async () => {
const completionData = await this.assetRepository.getCompletionMetadata(id, auth.user.id);
if (!completionData) {
res.status(404).send('Asset not found');
return;
}
const { fileModifiedAt, path, status, checksum: providedChecksum, size } = completionData;
if (status !== AssetStatus.Partial) {
return this.sendAlreadyCompleted(res);
}
if (uploadLength && size && size !== uploadLength) {
return this.sendInconsistentLength(res);
}
const expectedOffset = await this.getCurrentOffset(path);
if (expectedOffset !== uploadOffset) {
return this.sendOffsetMismatch(res, expectedOffset, uploadOffset);
}
const newLength = uploadOffset + contentLength;
if (uploadLength !== undefined && newLength > uploadLength) {
res.status(400).send('Upload would exceed declared length');
return;
}
if (contentLength === 0 && !uploadComplete) {
res.status(204).setHeader('Upload-Offset', expectedOffset.toString()).send();
return;
}
const writeStream = this.storageRepository.createOrAppendWriteStream(path);
this.pipe(req, writeStream, contentLength);
await new Promise((resolve, reject) => writeStream.on('close', resolve).on('error', reject));
this.setCompleteHeader(res, version, uploadComplete);
if (!uploadComplete) {
try {
const offset = await this.getCurrentOffset(path);
res.status(204).setHeader('Upload-Offset', offset.toString()).send();
} catch {
this.logger.error(`Failed to get current offset for ${path} after write`);
res.status(500).send();
}
return;
}
const checksum = await this.cryptoRepository.hashFile(path);
if (providedChecksum.compare(checksum) !== 0) {
return await this.sendChecksumMismatch(res, id, path);
}
await this.onComplete({ id, path, fileModifiedAt });
res.status(200).send({ id });
});
}
cancelUpload(auth: AuthDto, assetId: string, res: Response): Promise<void> {
this.abortExistingRequest(assetId);
return this.databaseRepository.withUuidLock(assetId, async () => {
const asset = await this.assetRepository.getCompletionMetadata(assetId, auth.user.id);
if (!asset) {
res.status(404).send('Asset not found');
return;
}
if (asset.status !== AssetStatus.Partial) {
return this.sendAlreadyCompleted(res);
}
await this.onCancel(assetId, asset.path);
res.status(204).send();
});
}
async getUploadStatus(auth: AuthDto, res: Response, id: string, { version }: GetUploadStatusDto): Promise<void> {
this.logger.verboseFn(() => `Getting upload status for ${id} with version ${version}`);
const { backup } = await this.getConfig({ withCache: true });
this.abortExistingRequest(id);
return this.databaseRepository.withUuidLock(id, async () => {
const asset = await this.assetRepository.getCompletionMetadata(id, auth.user.id);
if (!asset) {
res.status(404).send('Asset not found');
return;
}
const offset = await this.getCurrentOffset(asset.path);
this.setCompleteHeader(res, version, asset.status !== AssetStatus.Partial);
res
.status(204)
.setHeader('Upload-Offset', offset.toString())
.setHeader('Cache-Control', 'no-store')
.setHeader('Upload-Limit', this.getUploadLimits(backup))
.send();
});
}
async getUploadOptions(res: Response): Promise<void> {
const { backup } = await this.getConfig({ withCache: true });
res.status(204).setHeader('Upload-Limit', this.getUploadLimits(backup)).send();
}
@OnJob({ name: JobName.PartialAssetCleanupQueueAll, queue: QueueName.BackgroundTask })
async removeStaleUploads(): Promise<void> {
const config = await this.getConfig({ withCache: false });
const createdBefore = DateTime.now().minus({ hours: config.backup.upload.maxAgeHours }).toJSDate();
let jobs: JobItem[] = [];
const assets = this.assetJobRepository.streamForPartialAssetCleanupJob(createdBefore);
for await (const asset of assets) {
jobs.push({ name: JobName.PartialAssetCleanup, data: asset });
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
await this.jobRepository.queueAll(jobs);
jobs = [];
}
}
await this.jobRepository.queueAll(jobs);
}
@OnJob({ name: JobName.PartialAssetCleanup, queue: QueueName.BackgroundTask })
removeStaleUpload({ id }: JobOf<JobName.PartialAssetCleanup>): Promise<JobStatus> {
return this.databaseRepository.withUuidLock(id, async () => {
const asset = await this.assetJobRepository.getForPartialAssetCleanupJob(id);
if (!asset) {
return JobStatus.Skipped;
}
const { checksum, fileModifiedAt, path, size } = asset;
try {
const stat = await this.storageRepository.stat(path);
if (size === stat.size && checksum === (await this.cryptoRepository.hashFile(path))) {
await this.onComplete({ id, path, fileModifiedAt });
return JobStatus.Success;
}
} catch (error: any) {
this.logger.debugFn(() => `Failed to check upload file ${path}: ${error.message}`);
}
await this.onCancel(id, path);
return JobStatus.Success;
});
}
async onStart(
auth: AuthDto,
{ assetData, checksum, uploadLength }: StartUploadDto,
): Promise<{ id: string; path: string; status: AssetStatus; isDuplicate: boolean }> {
const assetId = this.cryptoRepository.randomUUID();
const folder = StorageCore.getNestedFolder(StorageFolder.Upload, auth.user.id, assetId);
const extension = extname(assetData.filename);
const path = join(folder, `${assetId}${extension}`);
const type = mimeTypes.assetType(path);
if (type === AssetType.Other) {
throw new BadRequestException(`${assetData.filename} is an unsupported file type`);
}
this.validateQuota(auth, uploadLength);
try {
await this.assetRepository.createWithMetadata(
{
id: assetId,
ownerId: auth.user.id,
libraryId: null,
checksum,
originalPath: path,
deviceAssetId: assetData.deviceAssetId,
deviceId: assetData.deviceId,
fileCreatedAt: assetData.fileCreatedAt,
fileModifiedAt: assetData.fileModifiedAt,
localDateTime: assetData.fileCreatedAt,
type,
isFavorite: assetData.isFavorite,
livePhotoVideoId: assetData.livePhotoVideoId,
visibility: AssetVisibility.Hidden,
originalFileName: assetData.filename,
status: AssetStatus.Partial,
},
uploadLength,
assetData.iCloudId ? [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: assetData.iCloudId } }] : undefined,
);
} catch (error: any) {
if (!isAssetChecksumConstraint(error)) {
this.logger.error(`Error creating upload asset record: ${error.message}`);
throw new InternalServerErrorException('Error creating asset');
}
const duplicate = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, checksum);
if (!duplicate) {
throw new InternalServerErrorException('Error locating duplicate for checksum constraint');
}
return { id: duplicate.id, path, status: duplicate.status, isDuplicate: true };
}
return { id: assetId, path, status: AssetStatus.Partial, isDuplicate: false };
}
async onComplete({ id, path, fileModifiedAt }: { id: string; path: string; fileModifiedAt: Date }) {
this.logger.log('Completing upload for asset', id);
const jobData = { name: JobName.AssetExtractMetadata, data: { id, source: 'upload' } } as const;
await withRetry(() => this.assetRepository.setComplete(id));
try {
await withRetry(() => this.storageRepository.utimes(path, new Date(), fileModifiedAt));
} catch (error: any) {
this.logger.error(`Failed to update times for ${path}: ${error.message}`);
}
await withRetry(() => this.jobRepository.queue(jobData));
}
async onCancel(assetId: string, path: string): Promise<void> {
this.logger.log('Cancelling upload for asset', assetId);
await withRetry(() => this.storageRepository.unlink(path));
await withRetry(() => this.assetRepository.removeAndDecrementQuota(assetId));
}
private addRequest(assetId: string, req: Readable) {
const addTime = new Date();
const activeRequest = { req, startTime: addTime };
this.abortExistingRequest(assetId, addTime);
this.activeRequests.set(assetId, activeRequest);
req.on('close', () => {
if (this.activeRequests.get(assetId)?.req === req) {
this.activeRequests.delete(assetId);
}
});
}
private abortExistingRequest(assetId: string, abortTime = new Date()) {
const abortEvent = { assetId, abortTime };
// only emit if we didn't just abort it ourselves
if (!this.onUploadAbort(abortEvent)) {
this.websocketRepository.serverSend('UploadAbort', abortEvent);
}
}
private pipe(req: Readable, writeStream: Writable, size: number) {
let receivedLength = 0;
req.on('data', (data: Buffer) => {
receivedLength += data.length;
if (!writeStream.write(data)) {
req.pause();
writeStream.once('drain', () => req.resume());
}
});
req.on('close', () => {
if (receivedLength < size) {
writeStream.emit('error', new Error('Request closed before all data received'));
}
writeStream.end();
});
}
private sendInterimResponse({ socket }: Response, location: string, interopVersion: number, limits: string): void {
if (socket && !socket.destroyed) {
// Express doesn't understand interim responses, so write directly to socket
socket.write(
'HTTP/1.1 104 Upload Resumption Supported\r\n' +
`Location: ${location}\r\n` +
`Upload-Limit: ${limits}\r\n` +
`Upload-Draft-Interop-Version: ${interopVersion}\r\n\r\n`,
);
}
}
private sendInconsistentLength(res: Response): void {
res.status(400).contentType('application/problem+json').send({
type: 'https://iana.org/assignments/http-problem-types#inconsistent-upload-length',
title: 'inconsistent length values for upload',
});
}
private sendAlreadyCompleted(res: Response): void {
res.status(400).contentType('application/problem+json').send({
type: 'https://iana.org/assignments/http-problem-types#completed-upload',
title: 'upload is already completed',
});
}
private sendOffsetMismatch(res: Response, expected: number, actual: number): void {
res.status(409).contentType('application/problem+json').setHeader('Upload-Offset', expected.toString()).send({
type: 'https://iana.org/assignments/http-problem-types#mismatching-upload-offset',
title: 'offset from request does not match offset of resource',
'expected-offset': expected,
'provided-offset': actual,
});
}
private sendChecksumMismatch(res: Response, assetId: string, path: string) {
this.logger.warn(`Removing upload asset ${assetId} due to checksum mismatch`);
res.status(460).send('File on server does not match provided checksum');
return this.onCancel(assetId, path);
}
private validateQuota(auth: AuthDto, size: number): void {
const { quotaSizeInBytes: quotaLimit, quotaUsageInBytes: currentUsage } = auth.user;
if (quotaLimit === null) {
return;
}
if (quotaLimit < currentUsage + size) {
throw new BadRequestException('Quota has been exceeded!');
}
}
private async getCurrentOffset(path: string): Promise<number> {
try {
const stat = await this.storageRepository.stat(path);
return stat.size;
} catch (error: any) {
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
return 0;
}
throw error;
}
}
private setCompleteHeader(res: Response, interopVersion: number | undefined, isComplete: boolean): void {
if (interopVersion === undefined || interopVersion > 3) {
res.setHeader('Upload-Complete', isComplete ? '?1' : '?0');
} else {
res.setHeader('Upload-Incomplete', isComplete ? '?0' : '?1');
}
}
private getUploadLimits({ upload }: SystemConfig['backup']) {
return `min-size=1, max-age=${upload.maxAgeHours * 3600}`;
}
}

View File

@@ -3,6 +3,7 @@ import { AlbumService } from 'src/services/album.service';
import { ApiKeyService } from 'src/services/api-key.service';
import { ApiService } from 'src/services/api.service';
import { AssetMediaService } from 'src/services/asset-media.service';
import { AssetUploadService } from 'src/services/asset-upload.service';
import { AssetService } from 'src/services/asset.service';
import { AuditService } from 'src/services/audit.service';
import { AuthAdminService } from 'src/services/auth-admin.service';
@@ -49,6 +50,7 @@ export const services = [
AlbumService,
ApiService,
AssetMediaService,
AssetUploadService,
AssetService,
AuditService,
AuthService,

View File

@@ -48,6 +48,7 @@ describe(JobService.name, () => {
{ name: JobName.UserSyncUsage },
{ name: JobName.AssetGenerateThumbnailsQueueAll, data: { force: false } },
{ name: JobName.FacialRecognitionQueueAll, data: { force: false, nightly: true } },
{ name: JobName.PartialAssetCleanupQueueAll },
]);
});
});

View File

@@ -303,6 +303,10 @@ export class JobService extends BaseService {
jobs.push({ name: JobName.FacialRecognitionQueueAll, data: { force: false, nightly: true } });
}
if (config.nightlyTasks.removeStaleUploads) {
jobs.push({ name: JobName.PartialAssetCleanupQueueAll });
}
await this.jobRepository.queueAll(jobs);
}

View File

@@ -1,4 +1,5 @@
import { BinaryField, ExifDateTime } from 'exiftool-vendored';
import { DateTime } from 'luxon';
import { randomBytes } from 'node:crypto';
import { Stats } from 'node:fs';
import { defaults } from 'src/config';
@@ -231,7 +232,7 @@ describe(MetadataService.name, () => {
});
});
it('should account for the server being in a non-UTC timezone', async () => {
it('should determine dateTimeOriginal regardless of the server time zone', async () => {
process.env.TZ = 'America/Los_Angeles';
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar);
mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' });
@@ -239,7 +240,7 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({
dateTimeOriginal: new Date('2022-01-01T08:00:00.000Z'),
dateTimeOriginal: new Date('2022-01-01T00:00:00.000Z'),
}),
);
@@ -856,6 +857,7 @@ describe(MetadataService.name, () => {
tz: 'UTC-11:30',
Rating: 3,
};
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mockReadTags(tags);
@@ -897,7 +899,7 @@ describe(MetadataService.name, () => {
id: assetStub.image.id,
duration: null,
fileCreatedAt: dateForTest,
localDateTime: dateForTest,
localDateTime: DateTime.fromISO('1970-01-01T00:00:00.000Z').toJSDate(),
}),
);
});
@@ -1595,7 +1597,7 @@ describe(MetadataService.name, () => {
const result = firstDateTime(tags);
expect(result?.tag).toBe('SonyDateTime2');
expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-07-07T07:00:00.000Z');
expect(result?.dateTime?.toISOString()).toBe('2023-07-07T07:00:00');
});
it('should respect full priority order with all date tags present', () => {
@@ -1624,7 +1626,7 @@ describe(MetadataService.name, () => {
const result = firstDateTime(tags);
// Should use SubSecDateTimeOriginal as it has highest priority
expect(result?.tag).toBe('SubSecDateTimeOriginal');
expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-01-01T01:00:00.000Z');
expect(result?.dateTime?.toISOString()).toBe('2023-01-01T01:00:00');
});
it('should handle missing SubSec tags and use available date tags', () => {
@@ -1644,7 +1646,7 @@ describe(MetadataService.name, () => {
const result = firstDateTime(tags);
// Should use CreationDate when available
expect(result?.tag).toBe('CreationDate');
expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-07-07T07:00:00.000Z');
expect(result?.dateTime?.toISOString()).toBe('2023-07-07T07:00:00');
});
it('should handle invalid date formats gracefully', () => {
@@ -1658,7 +1660,7 @@ describe(MetadataService.name, () => {
const result = firstDateTime(tags);
// Should skip invalid dates and use the first valid one
expect(result?.tag).toBe('GPSDateTime');
expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-10-10T10:00:00.000Z');
expect(result?.dateTime?.toISOString()).toBe('2023-10-10T10:00:00');
});
it('should prefer CreationDate over CreateDate', () => {

View File

@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { ContainerDirectoryItem, ExifDateTime, Tags } from 'exiftool-vendored';
import { Insertable } from 'kysely';
import _ from 'lodash';
import { Duration } from 'luxon';
import { DateTime, Duration } from 'luxon';
import { Stats } from 'node:fs';
import { constants } from 'node:fs/promises';
import { join, parse } from 'node:path';
@@ -866,31 +866,40 @@ export class MetadataService extends BaseService {
this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`);
}
let dateTimeOriginal = dateTime?.toDate();
let localDateTime = dateTime?.toDateTime().setZone('UTC', { keepLocalTime: true }).toJSDate();
let dateTimeOriginal = dateTime?.toDateTime();
// do not let JavaScript use local timezone
if (dateTimeOriginal && !dateTime?.hasZone) {
dateTimeOriginal = dateTimeOriginal.setZone('UTC', { keepLocalTime: true });
}
// align with whatever timeZone we chose
dateTimeOriginal = dateTimeOriginal?.setZone(timeZone ?? 'UTC');
// store as "local time"
let localDateTime = dateTimeOriginal?.setZone('UTC', { keepLocalTime: true });
if (!localDateTime || !dateTimeOriginal) {
// FileCreateDate is not available on linux, likely because exiftool hasn't integrated the statx syscall yet
// birthtime is not available in Docker on macOS, so it appears as 0
const earliestDate = new Date(
const earliestDate = DateTime.fromMillis(
Math.min(
asset.fileCreatedAt.getTime(),
stats.birthtimeMs ? Math.min(stats.mtimeMs, stats.birthtimeMs) : stats.mtime.getTime(),
),
);
this.logger.debug(
`No exif date time found, falling back on ${earliestDate.toISOString()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`,
`No exif date time found, falling back on ${earliestDate.toISO()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`,
);
dateTimeOriginal = localDateTime = earliestDate;
}
this.logger.verbose(
`Found local date time ${localDateTime.toISOString()} for asset ${asset.id}: ${asset.originalPath}`,
);
this.logger.verbose(`Found local date time ${localDateTime.toISO()} for asset ${asset.id}: ${asset.originalPath}`);
return {
dateTimeOriginal,
timeZone,
localDateTime,
localDateTime: localDateTime.toJSDate(),
dateTimeOriginal: dateTimeOriginal.toJSDate(),
};
}

View File

@@ -47,6 +47,9 @@ const updatedConfig = Object.freeze<SystemConfig>({
cronExpression: '0 02 * * *',
keepLastAmount: 14,
},
upload: {
maxAgeHours: 72,
},
},
ffmpeg: {
crf: 30,
@@ -123,6 +126,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
missingThumbnails: true,
generateMemories: true,
syncQuotaUsage: true,
removeStaleUploads: true,
},
reverseGeocoding: {
enabled: true,

View File

@@ -192,12 +192,12 @@ describe(TagService.name, () => {
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.tag.upsertAssetIds.mockResolvedValue([
{ tagsId: 'tag-1', assetsId: 'asset-1' },
{ tagsId: 'tag-1', assetsId: 'asset-2' },
{ tagsId: 'tag-1', assetsId: 'asset-3' },
{ tagsId: 'tag-2', assetsId: 'asset-1' },
{ tagsId: 'tag-2', assetsId: 'asset-2' },
{ tagsId: 'tag-2', assetsId: 'asset-3' },
{ tagId: 'tag-1', assetId: 'asset-1' },
{ tagId: 'tag-1', assetId: 'asset-2' },
{ tagId: 'tag-1', assetId: 'asset-3' },
{ tagId: 'tag-2', assetId: 'asset-1' },
{ tagId: 'tag-2', assetId: 'asset-2' },
{ tagId: 'tag-2', assetId: 'asset-3' },
]);
await expect(
sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1', 'tag-2'], assetIds: ['asset-1', 'asset-2', 'asset-3'] }),
@@ -205,12 +205,12 @@ describe(TagService.name, () => {
count: 6,
});
expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([
{ tagsId: 'tag-1', assetsId: 'asset-1' },
{ tagsId: 'tag-1', assetsId: 'asset-2' },
{ tagsId: 'tag-1', assetsId: 'asset-3' },
{ tagsId: 'tag-2', assetsId: 'asset-1' },
{ tagsId: 'tag-2', assetsId: 'asset-2' },
{ tagsId: 'tag-2', assetsId: 'asset-3' },
{ tagId: 'tag-1', assetId: 'asset-1' },
{ tagId: 'tag-1', assetId: 'asset-2' },
{ tagId: 'tag-1', assetId: 'asset-3' },
{ tagId: 'tag-2', assetId: 'asset-1' },
{ tagId: 'tag-2', assetId: 'asset-2' },
{ tagId: 'tag-2', assetId: 'asset-3' },
]);
});
});

View File

@@ -82,14 +82,14 @@ export class TagService extends BaseService {
]);
const items: Insertable<TagAssetTable>[] = [];
for (const tagsId of tagIds) {
for (const assetsId of assetIds) {
items.push({ tagsId, assetsId });
for (const tagId of tagIds) {
for (const assetId of assetIds) {
items.push({ tagId, assetId });
}
}
const results = await this.tagRepository.upsertAssetIds(items);
for (const assetId of new Set(results.map((item) => item.assetsId))) {
for (const assetId of new Set(results.map((item) => item.assetId))) {
await this.eventRepository.emit('AssetTag', { assetId });
}

View File

@@ -352,6 +352,8 @@ export type JobItem =
| { name: JobName.PersonCleanup; data?: IBaseJob }
| { name: JobName.AssetDelete; data: IAssetDeleteJob }
| { name: JobName.AssetDeleteCheck; data?: IBaseJob }
| { name: JobName.PartialAssetCleanup; data: IEntityJob }
| { name: JobName.PartialAssetCleanupQueueAll; data?: IBaseJob }
// Library Management
| { name: JobName.LibrarySyncFiles; data: ILibraryFileJob }

View File

@@ -99,7 +99,7 @@ export const getKyselyConfig = (
}),
}),
log(event) {
if (event.level === 'error') {
if (event.level === 'error' && (event.error as PostgresError).constraint_name !== ASSET_CHECKSUM_CONSTRAINT) {
console.error('Query failed :', {
durationMs: event.queryDurationMillis,
error: event.error,
@@ -244,12 +244,12 @@ export function inAlbums<O>(qb: SelectQueryBuilder<DB, 'asset', O>, albumIds: st
(eb) =>
eb
.selectFrom('album_asset')
.select('assetsId')
.where('albumsId', '=', anyUuid(albumIds!))
.groupBy('assetsId')
.having((eb) => eb.fn.count('albumsId').distinct(), '=', albumIds.length)
.select('assetId')
.where('albumId', '=', anyUuid(albumIds!))
.groupBy('assetId')
.having((eb) => eb.fn.count('albumId').distinct(), '=', albumIds.length)
.as('has_album'),
(join) => join.onRef('has_album.assetsId', '=', 'asset.id'),
(join) => join.onRef('has_album.assetId', '=', 'asset.id'),
);
}
@@ -258,13 +258,13 @@ export function hasTags<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagIds: strin
(eb) =>
eb
.selectFrom('tag_asset')
.select('assetsId')
.innerJoin('tag_closure', 'tag_asset.tagsId', 'tag_closure.id_descendant')
.select('assetId')
.innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant')
.where('tag_closure.id_ancestor', '=', anyUuid(tagIds))
.groupBy('assetsId')
.groupBy('assetId')
.having((eb) => eb.fn.count('tag_closure.id_ancestor').distinct(), '>=', tagIds.length)
.as('has_tags'),
(join) => join.onRef('has_tags.assetsId', '=', 'asset.id'),
(join) => join.onRef('has_tags.assetId', '=', 'asset.id'),
);
}
@@ -285,8 +285,8 @@ export function withTags(eb: ExpressionBuilder<DB, 'asset'>) {
eb
.selectFrom('tag')
.select(columns.tag)
.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');
}
@@ -299,8 +299,8 @@ export function withTagId<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagId: stri
eb.exists(
eb
.selectFrom('tag_closure')
.innerJoin('tag_asset', 'tag_asset.tagsId', 'tag_closure.id_descendant')
.whereRef('tag_asset.assetsId', '=', 'asset.id')
.innerJoin('tag_asset', 'tag_asset.tagId', 'tag_closure.id_descendant')
.whereRef('tag_asset.assetId', '=', 'asset.id')
.where('tag_closure.id_ancestor', '=', tagId),
),
);
@@ -320,7 +320,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
.$if(!!options.albumIds && options.albumIds.length > 0, (qb) => inAlbums(qb, options.albumIds!))
.$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!))
.$if(options.tagIds === null, (qb) =>
qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('tag_asset').whereRef('assetsId', '=', 'asset.id')))),
qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('tag_asset').whereRef('assetId', '=', 'asset.id')))),
)
.$if(!!options.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!))
.$if(!!options.createdBefore, (qb) => qb.where('asset.createdAt', '<=', options.createdBefore!))
@@ -403,7 +403,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
qb.where('asset.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null),
)
.$if(!!options.isNotInAlbum && (!options.albumIds || options.albumIds.length === 0), (qb) =>
qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('album_asset').whereRef('assetsId', '=', 'asset.id')))),
qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('album_asset').whereRef('assetId', '=', 'asset.id')))),
)
.$if(!!options.withExif, withExifInner)
.$if(!!(options.withFaces || options.withPeople || options.personIds), (qb) => qb.select(withFacesAndPeople))

View File

@@ -14,6 +14,7 @@ import {
import _ from 'lodash';
import { writeFileSync } from 'node:fs';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import picomatch from 'picomatch';
import parse from 'picomatch/lib/parse';
import { SystemConfig } from 'src/config';
@@ -326,3 +327,18 @@ export const globToSqlPattern = (glob: string) => {
export function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
export async function withRetry<T>(operation: () => Promise<T>, retries: number = 2, delay: number = 100): Promise<T> {
let lastError: any;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await operation();
} catch (error: any) {
lastError = error;
}
if (attempt < retries) {
await setTimeout(delay);
}
}
throw lastError;
}

View File

@@ -1,3 +1,7 @@
import { BadRequestException } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { IncomingHttpHeaders } from 'node:http';
import { UAParser } from 'ua-parser-js';
@@ -20,3 +24,29 @@ export const getUserAgentDetails = (headers: IncomingHttpHeaders) => {
appVersion,
};
};
export function validateSyncOrReject<T extends object>(cls: new () => T, obj: any): T {
const dto = plainToInstance(cls, obj, { excludeExtraneousValues: true });
const errors = validateSync(dto);
if (errors.length === 0) {
return dto;
}
const constraints = [];
for (const error of errors) {
if (error.constraints) {
constraints.push(...Object.values(error.constraints));
}
if (!error.children) {
continue;
}
for (const child of error.children) {
if (child.constraints) {
constraints.push(...Object.values(child.constraints));
}
}
}
throw new BadRequestException(constraints);
}

View File

@@ -2,6 +2,7 @@
import { Insertable, Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { createHash, randomBytes } from 'node:crypto';
import { Stats } from 'node:fs';
import { Writable } from 'node:stream';
import { AssetFace } from 'src/database';
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto';
@@ -28,7 +29,9 @@ import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
import { MapRepository } from 'src/repositories/map.repository';
import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
@@ -61,7 +64,9 @@ import { TagAssetTable } from 'src/schema/tables/tag-asset.table';
import { TagTable } from 'src/schema/tables/tag.table';
import { UserTable } from 'src/schema/tables/user.table';
import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service';
import { MetadataService } from 'src/services/metadata.service';
import { SyncService } from 'src/services/sync.service';
import { mockEnvData } from 'test/repositories/config.repository.mock';
import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory';
import { automock, wait } from 'test/utils';
@@ -212,7 +217,7 @@ export class MediumTestContext<S extends BaseService = BaseService> {
async newAlbumUser(dto: { albumId: string; userId: string; role?: AlbumUserRole }) {
const { albumId, userId, role = AlbumUserRole.Editor } = dto;
const result = await this.get(AlbumUserRepository).create({ albumsId: albumId, usersId: userId, role });
const result = await this.get(AlbumUserRepository).create({ albumId, userId, role });
return { albumUser: { albumId, userId, role }, result };
}
@@ -255,9 +260,9 @@ export class MediumTestContext<S extends BaseService = BaseService> {
async newTagAsset(tagBulkAssets: { tagIds: string[]; assetIds: string[] }) {
const tagsAssets: Insertable<TagAssetTable>[] = [];
for (const tagsId of tagBulkAssets.tagIds) {
for (const assetsId of tagBulkAssets.assetIds) {
tagsAssets.push({ tagsId, assetsId });
for (const tagId of tagBulkAssets.tagIds) {
for (const assetId of tagBulkAssets.assetIds) {
tagsAssets.push({ tagId, assetId });
}
}
@@ -305,6 +310,63 @@ export class SyncTestContext extends MediumTestContext<SyncService> {
}
}
const mockDate = new Date('2024-06-01T12:00:00.000Z');
const mockStats = {
mtime: mockDate,
atime: mockDate,
ctime: mockDate,
birthtime: mockDate,
atimeMs: 0,
mtimeMs: 0,
ctimeMs: 0,
birthtimeMs: 0,
};
export class ExifTestContext extends MediumTestContext<MetadataService> {
constructor(database: Kysely<DB>) {
super(MetadataService, {
database,
real: [AssetRepository, AssetJobRepository, MetadataRepository, SystemMetadataRepository, TagRepository],
mock: [ConfigRepository, EventRepository, LoggingRepository, MapRepository, StorageRepository],
});
this.getMock(ConfigRepository).getEnv.mockReturnValue(mockEnvData({}));
this.getMock(EventRepository).emit.mockResolvedValue();
this.getMock(MapRepository).reverseGeocode.mockResolvedValue({ country: null, state: null, city: null });
this.getMock(StorageRepository).stat.mockResolvedValue(mockStats as Stats);
}
getMockStats() {
return mockStats;
}
getGps(assetId: string) {
return this.database
.selectFrom('asset_exif')
.select(['latitude', 'longitude'])
.where('assetId', '=', assetId)
.executeTakeFirstOrThrow();
}
getTags(assetId: string) {
return this.database
.selectFrom('tag')
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId')
.where('tag_asset.assetId', '=', assetId)
.selectAll()
.execute();
}
getDates(assetId: string) {
return this.database
.selectFrom('asset')
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.where('id', '=', assetId)
.select(['asset.fileCreatedAt', 'asset.localDateTime', 'asset_exif.dateTimeOriginal', 'asset_exif.timeZone'])
.executeTakeFirstOrThrow();
}
}
const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
switch (key) {
case AccessRepository:
@@ -344,6 +406,14 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
return new key(LoggingRepository.create());
}
case MetadataRepository: {
return new key(LoggingRepository.create());
}
case StorageRepository: {
return new key(LoggingRepository.create());
}
case TagRepository: {
return new key(db, LoggingRepository.create());
}
@@ -381,6 +451,10 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
return automock(key);
}
case MapRepository: {
return automock(MapRepository, { args: [undefined, undefined, { setContext: () => {} }] });
}
case TelemetryRepository: {
return newTelemetryRepositoryMock();
}

View File

@@ -0,0 +1,65 @@
import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { resolve } from 'node:path';
import { DB } from 'src/schema';
import { ExifTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let database: Kysely<DB>;
const setup = async (testAssetPath: string) => {
const ctx = new ExifTestContext(database);
const { user } = await ctx.newUser();
const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
return { ctx, sut: ctx.sut, asset };
};
beforeAll(async () => {
database = await getKyselyDB();
});
describe('exif date time', () => {
it('should prioritize DateTimeOriginal', async () => {
const { ctx, sut, asset } = await setup('metadata/dates/date-priority-test.jpg');
await sut.handleMetadataExtraction({ id: asset.id });
await expect(ctx.getDates(asset.id)).resolves.toEqual({
timeZone: null,
dateTimeOriginal: DateTime.fromISO('2023-02-02T02:00:00.000Z').toJSDate(),
localDateTime: DateTime.fromISO('2023-02-02T02:00:00.000Z').toJSDate(),
fileCreatedAt: DateTime.fromISO('2023-02-02T02:00:00.000Z').toJSDate(),
});
});
it('should extract GPSDateTime with GPS coordinates ', async () => {
const { ctx, sut, asset } = await setup('metadata/dates/gps-datetime.jpg');
await sut.handleMetadataExtraction({ id: asset.id });
await expect(ctx.getDates(asset.id)).resolves.toEqual({
timeZone: 'America/Los_Angeles',
dateTimeOriginal: DateTime.fromISO('2023-11-15T12:30:00.000Z').toJSDate(),
localDateTime: DateTime.fromISO('2023-11-15T04:30:00.000Z').toJSDate(),
fileCreatedAt: DateTime.fromISO('2023-11-15T12:30:00.000Z').toJSDate(),
});
});
it('should ignore the TimeCreated tag', async () => {
const { ctx, sut, asset } = await setup('metadata/dates/time-created.jpg');
await sut.handleMetadataExtraction({ id: asset.id });
const stats = ctx.getMockStats();
await expect(ctx.getDates(asset.id)).resolves.toEqual({
timeZone: null,
dateTimeOriginal: stats.mtime,
localDateTime: stats.mtime,
fileCreatedAt: stats.mtime,
});
});
});

View File

@@ -0,0 +1,31 @@
import { Kysely } from 'kysely';
import { resolve } from 'node:path';
import { DB } from 'src/schema';
import { ExifTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let database: Kysely<DB>;
const setup = async (testAssetPath: string) => {
const ctx = new ExifTestContext(database);
const { user } = await ctx.newUser();
const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
return { ctx, sut: ctx.sut, asset };
};
beforeAll(async () => {
database = await getKyselyDB();
});
describe('exif gps', () => {
it('should handle empty strings', async () => {
const { ctx, sut, asset } = await setup('metadata/gps-position/empty_gps.jpg');
await sut.handleMetadataExtraction({ id: asset.id });
await expect(ctx.getGps(asset.id)).resolves.toEqual({ latitude: null, longitude: null });
});
});

View File

@@ -0,0 +1,34 @@
import { Kysely } from 'kysely';
import { resolve } from 'node:path';
import { DB } from 'src/schema';
import { ExifTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let database: Kysely<DB>;
const setup = async (testAssetPath: string) => {
const ctx = new ExifTestContext(database);
const { user } = await ctx.newUser();
const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
return { ctx, sut: ctx.sut, asset };
};
beforeAll(async () => {
database = await getKyselyDB();
});
describe('exif tags', () => {
it('should detect and regular tags', async () => {
const { ctx, sut, asset } = await setup('metadata/tags/picasa.jpg');
await sut.handleMetadataExtraction({ id: asset.id });
await expect(ctx.getTags(asset.id)).resolves.toEqual([
expect.objectContaining({ assetId: asset.id, value: 'Frost', parentId: null }),
expect.objectContaining({ assetId: asset.id, value: 'Yard', parentId: null }),
]);
});
});

View File

@@ -65,42 +65,6 @@ describe(MetadataService.name, () => {
timeZone: null,
},
},
{
description: 'should handle no time zone information and server behind UTC',
serverTimeZone: 'America/Los_Angeles',
exifData: {
DateTimeOriginal: '2022:01:01 00:00:00',
},
expected: {
localDateTime: '2022-01-01T00:00:00.000Z',
dateTimeOriginal: '2022-01-01T08:00:00.000Z',
timeZone: null,
},
},
{
description: 'should handle no time zone information and server ahead of UTC',
serverTimeZone: 'Europe/Brussels',
exifData: {
DateTimeOriginal: '2022:01:01 00:00:00',
},
expected: {
localDateTime: '2022-01-01T00:00:00.000Z',
dateTimeOriginal: '2021-12-31T23:00:00.000Z',
timeZone: null,
},
},
{
description: 'should handle no time zone information and server ahead of UTC in the summer',
serverTimeZone: 'Europe/Brussels',
exifData: {
DateTimeOriginal: '2022:06:01 00:00:00',
},
expected: {
localDateTime: '2022-06-01T00:00:00.000Z',
dateTimeOriginal: '2022-05-31T22:00:00.000Z',
timeZone: null,
},
},
{
description: 'should handle a +13:00 time zone',
exifData: {

View File

@@ -74,7 +74,7 @@ describe(SyncRequestType.AlbumUsersV1, () => {
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
await albumUserRepo.update({ albumsId: album.id, usersId: user1.id }, { role: AlbumUserRole.Viewer });
await albumUserRepo.update({ albumId: album.id, userId: user1.id }, { role: AlbumUserRole.Viewer });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([
{
@@ -104,7 +104,7 @@ describe(SyncRequestType.AlbumUsersV1, () => {
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
await albumUserRepo.delete({ albumsId: album.id, usersId: user1.id });
await albumUserRepo.delete({ albumId: album.id, userId: user1.id });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([
{
@@ -171,7 +171,7 @@ describe(SyncRequestType.AlbumUsersV1, () => {
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
await albumUserRepo.update({ albumsId: album.id, usersId: user.id }, { role: AlbumUserRole.Viewer });
await albumUserRepo.update({ albumId: album.id, userId: user.id }, { role: AlbumUserRole.Viewer });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([
{
@@ -208,7 +208,7 @@ describe(SyncRequestType.AlbumUsersV1, () => {
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
await albumUserRepo.delete({ albumsId: album.id, usersId: user.id });
await albumUserRepo.delete({ albumId: album.id, userId: user.id });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([

View File

@@ -217,7 +217,7 @@ describe(SyncRequestType.AlbumsV1, () => {
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
await albumUserRepo.delete({ albumsId: album.id, usersId: auth.user.id });
await albumUserRepo.delete({ albumId: album.id, userId: auth.user.id });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
expect(newResponse).toEqual([
{

View File

@@ -45,5 +45,9 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
upsertMetadata: vitest.fn(),
getMetadataByKey: vitest.fn(),
deleteMetadataByKey: vitest.fn(),
getCompletionMetadata: vitest.fn(),
createWithMetadata: vitest.fn(),
removeAndDecrementQuota: vitest.fn(),
setComplete: vitest.fn(),
};
};

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