1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 15:11:58 +00:00

refactor: library type (#9525)

This commit is contained in:
Jason Rasmussen 2024-05-20 18:09:10 -04:00 committed by GitHub
parent 4353153fe6
commit 84d824d6a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 155 additions and 782 deletions

View file

@ -18,7 +18,7 @@ In any other situation, there are 3 different options that can appear:
- MATCHES - These files are matched by their checksums. - MATCHES - These files are matched by their checksums.
- OFFLINE PATHS - These files are the result of manually deleting files in the upload library or a failed file move in the past (losing track of a file). - OFFLINE PATHS - These files are the result of manually deleting files from immich or a failed file move in the past (losing track of a file).
- UNTRACKED FILES - These files are not tracked by the application. They can be the result of failed moves, interrupted uploads, or left behind due to a bug. - UNTRACKED FILES - These files are not tracked by the application. They can be the result of failed moves, interrupted uploads, or left behind due to a bug.

View file

@ -4,10 +4,6 @@
Immich supports the creation of libraries which is a top-level asset container. Currently, there are two types of libraries: traditional upload libraries that can sync with a mobile device, and external libraries, that keeps up to date with files on disk. Libraries are different from albums in that an asset can belong to multiple albums but only one library, and deleting a library deletes all assets contained within. As of August 2023, this is a new feature and libraries have a lot of potential for future development beyond what is documented here. This document attempts to describe the current state of libraries. Immich supports the creation of libraries which is a top-level asset container. Currently, there are two types of libraries: traditional upload libraries that can sync with a mobile device, and external libraries, that keeps up to date with files on disk. Libraries are different from albums in that an asset can belong to multiple albums but only one library, and deleting a library deletes all assets contained within. As of August 2023, this is a new feature and libraries have a lot of potential for future development beyond what is documented here. This document attempts to describe the current state of libraries.
## The Upload Library
Immich comes preconfigured with an upload library for each user. All assets uploaded to Immich are added to this library. This library can be renamed, but not deleted. The upload library is the only library that can be synced with a mobile device. No items in an upload library is allowed to have the same sha1 hash as another item in the same library in order to prevent duplicates.
## External Libraries ## External Libraries
External libraries tracks assets stored outside of Immich, i.e. in the file system. When the external library is scanned, Immich will read the metadata from the file and create an asset in the library for each image or video file. These items will then be shown in the main timeline, and they will look and behave like any other asset, including viewing on the map, adding to albums, etc. External libraries tracks assets stored outside of Immich, i.e. in the file system. When the external library is scanned, Immich will read the metadata from the file and create an asset in the library for each image or video file. These items will then be shown in the main timeline, and they will look and behave like any other asset, including viewing on the map, adding to albums, etc.

View file

@ -2,10 +2,8 @@ import {
AssetFileUploadResponseDto, AssetFileUploadResponseDto,
AssetResponseDto, AssetResponseDto,
AssetTypeEnum, AssetTypeEnum,
LibraryResponseDto,
LoginResponseDto, LoginResponseDto,
SharedLinkType, SharedLinkType,
getAllLibraries,
getAssetInfo, getAssetInfo,
updateAssets, updateAssets,
} from '@immich/sdk'; } from '@immich/sdk';
@ -819,25 +817,6 @@ describe('/asset', () => {
expect(duplicate).toBe(true); expect(duplicate).toBe(true);
}); });
it("should not upload to another user's library", async () => {
const libraries = await getAllLibraries({}, { headers: asBearerAuth(admin.accessToken) });
const library = libraries.find((library) => library.ownerId === user1.userId) as LibraryResponseDto;
const { body, status } = await request(app)
.post('/asset/upload')
.set('Authorization', `Bearer ${admin.accessToken}`)
.field('libraryId', library.id)
.field('deviceAssetId', 'example-image')
.field('deviceId', 'e2e')
.field('fileCreatedAt', new Date().toISOString())
.field('fileModifiedAt', new Date().toISOString())
.field('duration', '0:00:00.000000')
.attach('assetData', makeRandomImage(), 'example.png');
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not found or no asset.upload access'));
});
it('should update the used quota', async () => { it('should update the used quota', async () => {
const { body, status } = await request(app) const { body, status } = await request(app)
.post('/asset/upload') .post('/asset/upload')

View file

@ -1,11 +1,4 @@
import { import { LibraryResponseDto, LoginResponseDto, ScanLibraryDto, getAllLibraries, scanLibrary } from '@immich/sdk';
LibraryResponseDto,
LibraryType,
LoginResponseDto,
ScanLibraryDto,
getAllLibraries,
scanLibrary,
} from '@immich/sdk';
import { cpSync, existsSync } from 'node:fs'; import { cpSync, existsSync } from 'node:fs';
import { Socket } from 'socket.io-client'; import { Socket } from 'socket.io-client';
import { userDto, uuidDto } from 'src/fixtures'; import { userDto, uuidDto } from 'src/fixtures';
@ -29,7 +22,7 @@ describe('/library', () => {
admin = await utils.adminSetup(); admin = await utils.adminSetup();
await utils.resetAdminConfig(admin.accessToken); await utils.resetAdminConfig(admin.accessToken);
user = await utils.userSetup(admin.accessToken, userDto.user1); user = await utils.userSetup(admin.accessToken, userDto.user1);
library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, type: LibraryType.External }); library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId });
websocket = await utils.connectWebsocket(admin.accessToken); websocket = await utils.connectWebsocket(admin.accessToken);
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetA.png`); utils.createImageFile(`${testAssetDir}/temp/directoryA/assetA.png`);
utils.createImageFile(`${testAssetDir}/temp/directoryB/assetB.png`); utils.createImageFile(`${testAssetDir}/temp/directoryB/assetB.png`);
@ -50,24 +43,6 @@ describe('/library', () => {
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should start with a default upload library', async () => {
const { status, body } = await request(app).get('/library').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.Upload,
name: 'Default Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
]),
);
});
}); });
describe('POST /library', () => { describe('POST /library', () => {
@ -81,7 +56,7 @@ describe('/library', () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.post('/library') .post('/library')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.send({ ownerId: admin.userId, type: LibraryType.External }); .send({ ownerId: admin.userId });
expect(status).toBe(403); expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden); expect(body).toEqual(errorDto.forbidden);
@ -91,13 +66,12 @@ describe('/library', () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.post('/library') .post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ownerId: admin.userId, type: LibraryType.External }); .send({ ownerId: admin.userId });
expect(status).toBe(201); expect(status).toBe(201);
expect(body).toEqual( expect(body).toEqual(
expect.objectContaining({ expect.objectContaining({
ownerId: admin.userId, ownerId: admin.userId,
type: LibraryType.External,
name: 'New External Library', name: 'New External Library',
refreshedAt: null, refreshedAt: null,
assetCount: 0, assetCount: 0,
@ -113,7 +87,6 @@ describe('/library', () => {
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ .send({
ownerId: admin.userId, ownerId: admin.userId,
type: LibraryType.External,
name: 'My Awesome Library', name: 'My Awesome Library',
importPaths: ['/path/to/import'], importPaths: ['/path/to/import'],
exclusionPatterns: ['**/Raw/**'], exclusionPatterns: ['**/Raw/**'],
@ -134,7 +107,6 @@ describe('/library', () => {
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ .send({
ownerId: admin.userId, ownerId: admin.userId,
type: LibraryType.External,
name: 'My Awesome Library', name: 'My Awesome Library',
importPaths: ['/path', '/path'], importPaths: ['/path', '/path'],
exclusionPatterns: ['**/Raw/**'], exclusionPatterns: ['**/Raw/**'],
@ -150,7 +122,6 @@ describe('/library', () => {
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ .send({
ownerId: admin.userId, ownerId: admin.userId,
type: LibraryType.External,
name: 'My Awesome Library', name: 'My Awesome Library',
importPaths: ['/path/to/import'], importPaths: ['/path/to/import'],
exclusionPatterns: ['**/Raw/**', '**/Raw/**'], exclusionPatterns: ['**/Raw/**', '**/Raw/**'],
@ -159,60 +130,6 @@ describe('/library', () => {
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"])); expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"]));
}); });
it('should create an upload library with defaults', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ownerId: admin.userId, type: LibraryType.Upload });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.Upload,
name: 'New Upload Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
);
});
it('should create an upload library with options', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ownerId: admin.userId, type: LibraryType.Upload, name: 'My Awesome Library' });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
name: 'My Awesome Library',
}),
);
});
it('should not allow upload libraries to have import paths', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ownerId: admin.userId, type: LibraryType.Upload, importPaths: ['/path/to/import'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have import paths'));
});
it('should not allow upload libraries to have exclusion patterns', async () => {
const { status, body } = await request(app)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ownerId: admin.userId, type: LibraryType.Upload, exclusionPatterns: ['**/Raw/**'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have exclusion patterns'));
});
}); });
describe('PUT /library/:id', () => { describe('PUT /library/:id', () => {
@ -332,10 +249,7 @@ describe('/library', () => {
}); });
it('should get library by id', async () => { it('should get library by id', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId });
ownerId: admin.userId,
type: LibraryType.External,
});
const { status, body } = await request(app) const { status, body } = await request(app)
.get(`/library/${library.id}`) .get(`/library/${library.id}`)
@ -345,7 +259,6 @@ describe('/library', () => {
expect(body).toEqual( expect(body).toEqual(
expect.objectContaining({ expect.objectContaining({
ownerId: admin.userId, ownerId: admin.userId,
type: LibraryType.External,
name: 'New External Library', name: 'New External Library',
refreshedAt: null, refreshedAt: null,
assetCount: 0, assetCount: 0,
@ -373,24 +286,9 @@ describe('/library', () => {
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should not scan an upload library', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.Upload,
});
const { status, body } = await request(app)
.post(`/library/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Can only refresh external libraries'));
});
it('should scan external library', async () => { it('should scan external library', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId, ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp/directoryA`], importPaths: [`${testAssetDirInternal}/temp/directoryA`],
}); });
@ -406,7 +304,6 @@ describe('/library', () => {
it('should scan external library with exclusion pattern', async () => { it('should scan external library with exclusion pattern', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId, ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`], importPaths: [`${testAssetDirInternal}/temp`],
exclusionPatterns: ['**/directoryA'], exclusionPatterns: ['**/directoryA'],
}); });
@ -423,7 +320,6 @@ describe('/library', () => {
it('should scan multiple import paths', async () => { it('should scan multiple import paths', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId, ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`], importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`],
}); });
@ -440,7 +336,6 @@ describe('/library', () => {
it('should pick up new files', async () => { it('should pick up new files', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId, ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`], importPaths: [`${testAssetDirInternal}/temp`],
}); });
@ -466,7 +361,6 @@ describe('/library', () => {
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.png`);
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId, ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`], importPaths: [`${testAssetDirInternal}/temp`],
}); });
@ -493,7 +387,6 @@ describe('/library', () => {
it('should scan new files', async () => { it('should scan new files', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId, ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`], importPaths: [`${testAssetDirInternal}/temp`],
}); });
@ -521,7 +414,6 @@ describe('/library', () => {
it('should reimport modified files', async () => { it('should reimport modified files', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId, ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`], importPaths: [`${testAssetDirInternal}/temp`],
}); });
@ -549,7 +441,6 @@ describe('/library', () => {
it('should not reimport unmodified files', async () => { it('should not reimport unmodified files', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId, ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`], importPaths: [`${testAssetDirInternal}/temp`],
}); });
@ -579,7 +470,6 @@ describe('/library', () => {
it('should reimport all files', async () => { it('should reimport all files', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId, ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`], importPaths: [`${testAssetDirInternal}/temp`],
}); });
@ -617,7 +507,6 @@ describe('/library', () => {
it('should remove offline files', async () => { it('should remove offline files', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId, ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`], importPaths: [`${testAssetDirInternal}/temp`],
}); });
@ -658,7 +547,6 @@ describe('/library', () => {
it('should not remove online files', async () => { it('should not remove online files', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId, ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`], importPaths: [`${testAssetDirInternal}/temp`],
}); });
@ -737,37 +625,8 @@ describe('/library', () => {
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should not delete the last upload library', async () => {
const libraries = await getAllLibraries(
{ $type: LibraryType.Upload },
{ headers: asBearerAuth(admin.accessToken) },
);
const adminLibraries = libraries.filter((library) => library.ownerId === admin.userId);
expect(adminLibraries.length).toBeGreaterThanOrEqual(1);
const lastLibrary = adminLibraries.pop() as LibraryResponseDto;
// delete all but the last upload library
for (const library of adminLibraries) {
const { status } = await request(app)
.delete(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
}
const { status, body } = await request(app)
.delete(`/library/${lastLibrary.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual(errorDto.noDeleteUploadLibrary);
expect(status).toBe(400);
});
it('should delete an external library', async () => { it('should delete an external library', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId });
ownerId: admin.userId,
type: LibraryType.External,
});
const { status, body } = await request(app) const { status, body } = await request(app)
.delete(`/library/${library.id}`) .delete(`/library/${library.id}`)
@ -776,7 +635,7 @@ describe('/library', () => {
expect(status).toBe(204); expect(status).toBe(204);
expect(body).toEqual({}); expect(body).toEqual({});
const libraries = await getAllLibraries({}, { headers: asBearerAuth(admin.accessToken) }); const libraries = await getAllLibraries({ headers: asBearerAuth(admin.accessToken) });
expect(libraries).not.toEqual( expect(libraries).not.toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
@ -789,7 +648,6 @@ describe('/library', () => {
it('should delete an external library with assets', async () => { it('should delete an external library with assets', async () => {
const library = await utils.createLibrary(admin.accessToken, { const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId, ownerId: admin.userId,
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`], importPaths: [`${testAssetDirInternal}/temp`],
}); });
@ -803,7 +661,7 @@ describe('/library', () => {
expect(status).toBe(204); expect(status).toBe(204);
expect(body).toEqual({}); expect(body).toEqual({});
const libraries = await getAllLibraries({}, { headers: asBearerAuth(admin.accessToken) }); const libraries = await getAllLibraries({ headers: asBearerAuth(admin.accessToken) });
expect(libraries).not.toEqual( expect(libraries).not.toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({

View file

@ -51,11 +51,6 @@ export const errorDto = {
statusCode: 400, statusCode: 400,
message: 'The server already has an admin', message: 'The server already has an admin',
}, },
noDeleteUploadLibrary: {
error: 'Bad Request',
statusCode: 400,
message: 'Cannot delete the last upload library',
},
}; };
export const signupResponseDto = { export const signupResponseDto = {

View file

@ -92,7 +92,6 @@ doc/JobStatusDto.md
doc/LibraryApi.md doc/LibraryApi.md
doc/LibraryResponseDto.md doc/LibraryResponseDto.md
doc/LibraryStatsResponseDto.md doc/LibraryStatsResponseDto.md
doc/LibraryType.md
doc/LogLevel.md doc/LogLevel.md
doc/LoginCredentialDto.md doc/LoginCredentialDto.md
doc/LoginResponseDto.md doc/LoginResponseDto.md
@ -331,7 +330,6 @@ lib/model/job_settings_dto.dart
lib/model/job_status_dto.dart lib/model/job_status_dto.dart
lib/model/library_response_dto.dart lib/model/library_response_dto.dart
lib/model/library_stats_response_dto.dart lib/model/library_stats_response_dto.dart
lib/model/library_type.dart
lib/model/log_level.dart lib/model/log_level.dart
lib/model/login_credential_dto.dart lib/model/login_credential_dto.dart
lib/model/login_response_dto.dart lib/model/login_response_dto.dart
@ -531,7 +529,6 @@ test/job_status_dto_test.dart
test/library_api_test.dart test/library_api_test.dart
test/library_response_dto_test.dart test/library_response_dto_test.dart
test/library_stats_response_dto_test.dart test/library_stats_response_dto_test.dart
test/library_type_test.dart
test/log_level_test.dart test/log_level_test.dart
test/login_credential_dto_test.dart test/login_credential_dto_test.dart
test/login_response_dto_test.dart test/login_response_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -2439,16 +2439,7 @@
"/library": { "/library": {
"get": { "get": {
"operationId": "getAllLibraries", "operationId": "getAllLibraries",
"parameters": [ "parameters": [],
{
"name": "type",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/LibraryType"
}
}
],
"responses": { "responses": {
"200": { "200": {
"content": { "content": {
@ -7365,6 +7356,9 @@
"type": "boolean" "type": "boolean"
}, },
"libraryId": { "libraryId": {
"deprecated": true,
"description": "This property was deprecated in v1.106.0",
"nullable": true,
"type": "string" "type": "string"
}, },
"livePhotoVideoId": { "livePhotoVideoId": {
@ -7444,7 +7438,6 @@
"isFavorite", "isFavorite",
"isOffline", "isOffline",
"isTrashed", "isTrashed",
"libraryId",
"localDateTime", "localDateTime",
"originalFileName", "originalFileName",
"originalPath", "originalPath",
@ -7715,10 +7708,6 @@
"isVisible": { "isVisible": {
"type": "boolean" "type": "boolean"
}, },
"libraryId": {
"format": "uuid",
"type": "string"
},
"livePhotoData": { "livePhotoData": {
"format": "binary", "format": "binary",
"type": "string" "type": "string"
@ -7757,14 +7746,10 @@
"ownerId": { "ownerId": {
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
},
"type": {
"$ref": "#/components/schemas/LibraryType"
} }
}, },
"required": [ "required": [
"ownerId", "ownerId"
"type"
], ],
"type": "object" "type": "object"
}, },
@ -8319,9 +8304,6 @@
"nullable": true, "nullable": true,
"type": "string" "type": "string"
}, },
"type": {
"$ref": "#/components/schemas/LibraryType"
},
"updatedAt": { "updatedAt": {
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
@ -8336,7 +8318,6 @@
"name", "name",
"ownerId", "ownerId",
"refreshedAt", "refreshedAt",
"type",
"updatedAt" "updatedAt"
], ],
"type": "object" "type": "object"
@ -8369,13 +8350,6 @@
], ],
"type": "object" "type": "object"
}, },
"LibraryType": {
"enum": [
"UPLOAD",
"EXTERNAL"
],
"type": "string"
},
"LogLevel": { "LogLevel": {
"enum": [ "enum": [
"verbose", "verbose",

View file

@ -130,7 +130,8 @@ export type AssetResponseDto = {
/** This property was deprecated in v1.104.0 */ /** This property was deprecated in v1.104.0 */
isReadOnly?: boolean; isReadOnly?: boolean;
isTrashed: boolean; isTrashed: boolean;
libraryId: string; /** This property was deprecated in v1.106.0 */
libraryId?: string | null;
livePhotoVideoId?: string | null; livePhotoVideoId?: string | null;
localDateTime: string; localDateTime: string;
originalFileName: string; originalFileName: string;
@ -307,7 +308,6 @@ export type CreateAssetDto = {
isFavorite?: boolean; isFavorite?: boolean;
isOffline?: boolean; isOffline?: boolean;
isVisible?: boolean; isVisible?: boolean;
libraryId?: string;
livePhotoData?: Blob; livePhotoData?: Blob;
sidecarData?: Blob; sidecarData?: Blob;
}; };
@ -442,7 +442,6 @@ export type LibraryResponseDto = {
name: string; name: string;
ownerId: string; ownerId: string;
refreshedAt: string | null; refreshedAt: string | null;
"type": LibraryType;
updatedAt: string; updatedAt: string;
}; };
export type CreateLibraryDto = { export type CreateLibraryDto = {
@ -450,7 +449,6 @@ export type CreateLibraryDto = {
importPaths?: string[]; importPaths?: string[];
name?: string; name?: string;
ownerId: string; ownerId: string;
"type": LibraryType;
}; };
export type UpdateLibraryDto = { export type UpdateLibraryDto = {
exclusionPatterns?: string[]; exclusionPatterns?: string[];
@ -1754,15 +1752,11 @@ export function sendJobCommand({ id, jobCommandDto }: {
body: jobCommandDto body: jobCommandDto
}))); })));
} }
export function getAllLibraries({ $type }: { export function getAllLibraries(opts?: Oazapfts.RequestOpts) {
$type?: LibraryType;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: LibraryResponseDto[]; data: LibraryResponseDto[];
}>(`/library${QS.query(QS.explode({ }>("/library", {
"type": $type
}))}`, {
...opts ...opts
})); }));
} }
@ -2913,10 +2907,6 @@ export enum JobCommand {
Empty = "empty", Empty = "empty",
ClearFailed = "clear-failed" ClearFailed = "clear-failed"
} }
export enum LibraryType {
Upload = "UPLOAD",
External = "EXTERNAL"
}
export enum Type2 { export enum Type2 {
OnThisDay = "on_this_day" OnThisDay = "on_this_day"
} }

View file

@ -1,11 +1,10 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { import {
CreateLibraryDto, CreateLibraryDto,
LibraryResponseDto, LibraryResponseDto,
LibraryStatsResponseDto, LibraryStatsResponseDto,
ScanLibraryDto, ScanLibraryDto,
SearchLibraryDto,
UpdateLibraryDto, UpdateLibraryDto,
ValidateLibraryDto, ValidateLibraryDto,
ValidateLibraryResponseDto, ValidateLibraryResponseDto,
@ -21,8 +20,8 @@ export class LibraryController {
@Get() @Get()
@Authenticated({ admin: true }) @Authenticated({ admin: true })
getAllLibraries(@Query() dto: SearchLibraryDto): Promise<LibraryResponseDto[]> { getAllLibraries(): Promise<LibraryResponseDto[]> {
return this.service.getAll(dto); return this.service.getAll();
} }
@Post() @Post()

View file

@ -274,7 +274,7 @@ export class AccessCore {
} }
case Permission.ASSET_UPLOAD: { case Permission.ASSET_UPLOAD: {
return await this.repository.library.checkOwnerAccess(auth.user.id, ids); return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set<string>();
} }
case Permission.ARCHIVE_READ: { case Permission.ARCHIVE_READ: {

View file

@ -2,7 +2,6 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
import { SALT_ROUNDS } from 'src/constants'; import { SALT_ROUNDS } from 'src/constants';
import { UserResponseDto } from 'src/dtos/user.dto'; import { UserResponseDto } from 'src/dtos/user.dto';
import { LibraryType } from 'src/entities/library.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface';
@ -93,16 +92,7 @@ export class UserCore {
if (payload.storageLabel) { if (payload.storageLabel) {
payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', '')); payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', ''));
} }
const userEntity = await this.userRepository.create(payload);
await this.libraryRepository.create({
owner: { id: userEntity.id } as UserEntity,
name: 'Default Library',
assets: [],
type: LibraryType.UPLOAD,
importPaths: [],
exclusionPatterns: [],
});
return userEntity; return this.userRepository.create(payload);
} }
} }

View file

@ -26,7 +26,8 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
deviceId!: string; deviceId!: string;
ownerId!: string; ownerId!: string;
owner?: UserResponseDto; owner?: UserResponseDto;
libraryId!: string; @PropertyLifecycle({ deprecatedAt: 'v1.106.0' })
libraryId?: string | null;
originalPath!: string; originalPath!: string;
originalFileName!: string; originalFileName!: string;
fileCreatedAt!: Date; fileCreatedAt!: Date;

View file

@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsEnum, IsInt, IsNotEmpty, IsString, IsUUID, ValidateNested } from 'class-validator'; import { ArrayNotEmpty, IsArray, IsEnum, IsInt, IsNotEmpty, IsString, IsUUID, ValidateNested } from 'class-validator';
import { UploadFieldName } from 'src/dtos/asset.dto'; import { UploadFieldName } from 'src/dtos/asset.dto';
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; import { Optional, ValidateBoolean, ValidateDate } from 'src/validation';
export class AssetBulkUploadCheckItem { export class AssetBulkUploadCheckItem {
@IsString() @IsString()
@ -64,9 +64,6 @@ export class CheckExistingAssetsDto {
} }
export class CreateAssetDto { export class CreateAssetDto {
@ValidateUUID({ optional: true })
libraryId?: string;
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
deviceAssetId!: string; deviceAssetId!: string;

View file

@ -1,13 +1,9 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ArrayMaxSize, ArrayUnique, IsEnum, IsNotEmpty, IsString } from 'class-validator'; import { ArrayMaxSize, ArrayUnique, IsNotEmpty, IsString } from 'class-validator';
import { LibraryEntity, LibraryType } from 'src/entities/library.entity'; import { LibraryEntity } from 'src/entities/library.entity';
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
export class CreateLibraryDto { export class CreateLibraryDto {
@IsEnum(LibraryType)
@ApiProperty({ enumName: 'LibraryType', enum: LibraryType })
type!: LibraryType;
@ValidateUUID() @ValidateUUID()
ownerId!: string; ownerId!: string;
@ -97,21 +93,11 @@ export class ScanLibraryDto {
refreshAllFiles?: boolean; refreshAllFiles?: boolean;
} }
export class SearchLibraryDto {
@IsEnum(LibraryType)
@ApiProperty({ enumName: 'LibraryType', enum: LibraryType })
@Optional()
type?: LibraryType;
}
export class LibraryResponseDto { export class LibraryResponseDto {
id!: string; id!: string;
ownerId!: string; ownerId!: string;
name!: string; name!: string;
@ApiProperty({ enumName: 'LibraryType', enum: LibraryType })
type!: LibraryType;
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer' })
assetCount!: number; assetCount!: number;
@ -146,7 +132,6 @@ export function mapLibrary(entity: LibraryEntity): LibraryResponseDto {
return { return {
id: entity.id, id: entity.id,
ownerId: entity.ownerId, ownerId: entity.ownerId,
type: entity.type,
name: entity.name, name: entity.name,
createdAt: entity.createdAt, createdAt: entity.createdAt,
updatedAt: entity.updatedAt, updatedAt: entity.updatedAt,

View file

@ -25,12 +25,17 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_library_checksum'; export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
@Entity('assets') @Entity('assets')
// Checksums must be unique per user and library // Checksums must be unique per user and library
@Index(ASSET_CHECKSUM_CONSTRAINT, ['owner', 'library', 'checksum'], { @Index(ASSET_CHECKSUM_CONSTRAINT, ['owner', 'checksum'], {
unique: true, unique: true,
where: '"libraryId" IS NULL',
})
@Index('UQ_assets_owner_library_checksum' + '', ['owner', 'library', 'checksum'], {
unique: true,
where: '"libraryId" IS NOT NULL',
}) })
@Index('IDX_day_of_month', { synchronize: false }) @Index('IDX_day_of_month', { synchronize: false })
@Index('IDX_month', { synchronize: false }) @Index('IDX_month', { synchronize: false })
@ -51,11 +56,11 @@ export class AssetEntity {
@Column() @Column()
ownerId!: string; ownerId!: string;
@ManyToOne(() => LibraryEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) @ManyToOne(() => LibraryEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
library!: LibraryEntity; library?: LibraryEntity | null;
@Column() @Column({ nullable: true })
libraryId!: string; libraryId?: string | null;
@Column() @Column()
deviceId!: string; deviceId!: string;

View file

@ -30,9 +30,6 @@ export class LibraryEntity {
@Column() @Column()
ownerId!: string; ownerId!: string;
@Column()
type!: LibraryType;
@Column('text', { array: true }) @Column('text', { array: true })
importPaths!: string[]; importPaths!: string[];
@ -51,8 +48,3 @@ export class LibraryEntity {
@Column({ type: 'timestamptz', nullable: true }) @Column({ type: 'timestamptz', nullable: true })
refreshedAt!: Date | null; refreshedAt!: Date | null;
} }
export enum LibraryType {
UPLOAD = 'UPLOAD',
EXTERNAL = 'EXTERNAL',
}

View file

@ -26,10 +26,6 @@ export interface IAccessRepository {
checkSharedLinkAccess(sharedLinkId: string, albumIds: Set<string>): Promise<Set<string>>; checkSharedLinkAccess(sharedLinkId: string, albumIds: Set<string>): Promise<Set<string>>;
}; };
library: {
checkOwnerAccess(userId: string, libraryIds: Set<string>): Promise<Set<string>>;
};
timeline: { timeline: {
checkPartnerAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>>; checkPartnerAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>>;
}; };

View file

@ -164,7 +164,7 @@ export interface IAssetRepository {
): Promise<AssetEntity[]>; ): Promise<AssetEntity[]>;
getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>; getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>;
getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise<AssetEntity[]>; getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise<AssetEntity[]>;
getByChecksum(libraryId: string, checksum: Buffer): Promise<AssetEntity | null>; getByChecksum(libraryId: string | null, checksum: Buffer): Promise<AssetEntity | null>;
getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise<string | undefined>; getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise<string | undefined>;
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>; getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated<AssetEntity>; getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated<AssetEntity>;

View file

@ -1,18 +1,16 @@
import { LibraryStatsResponseDto } from 'src/dtos/library.dto'; import { LibraryStatsResponseDto } from 'src/dtos/library.dto';
import { LibraryEntity, LibraryType } from 'src/entities/library.entity'; import { LibraryEntity } from 'src/entities/library.entity';
export const ILibraryRepository = 'ILibraryRepository'; export const ILibraryRepository = 'ILibraryRepository';
export interface ILibraryRepository { export interface ILibraryRepository {
getCountForUser(ownerId: string): Promise<number>; getCountForUser(ownerId: string): Promise<number>;
getAll(withDeleted?: boolean, type?: LibraryType): Promise<LibraryEntity[]>; getAll(withDeleted?: boolean): Promise<LibraryEntity[]>;
getAllDeleted(): Promise<LibraryEntity[]>; getAllDeleted(): Promise<LibraryEntity[]>;
get(id: string, withDeleted?: boolean): Promise<LibraryEntity | null>; get(id: string, withDeleted?: boolean): Promise<LibraryEntity | null>;
create(library: Partial<LibraryEntity>): Promise<LibraryEntity>; create(library: Partial<LibraryEntity>): Promise<LibraryEntity>;
delete(id: string): Promise<void>; delete(id: string): Promise<void>;
softDelete(id: string): Promise<void>; softDelete(id: string): Promise<void>;
getDefaultUploadLibrary(ownerId: string): Promise<LibraryEntity | null>;
getUploadLibraryCount(ownerId: string): Promise<number>;
update(library: Partial<LibraryEntity>): Promise<LibraryEntity>; update(library: Partial<LibraryEntity>): Promise<LibraryEntity>;
getStatistics(id: string): Promise<LibraryStatsResponseDto>; getStatistics(id: string): Promise<LibraryStatsResponseDto>;
getAssetIds(id: string, withDeleted?: boolean): Promise<string[]>; getAssetIds(id: string, withDeleted?: boolean): Promise<string[]>;

View file

@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveLibraryType1715804005643 implements MigrationInterface {
name = 'RemoveLibraryType1715804005643';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_9977c3c1de01c3d848039a6b90c"`);
await queryRunner.query(`DROP INDEX "public"."UQ_assets_owner_library_checksum"`);
await queryRunner.query(`DROP INDEX "public"."IDX_originalPath_libraryId"`);
await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "libraryId" DROP NOT NULL`);
await queryRunner.query(`
UPDATE "assets"
SET "libraryId" = NULL
FROM "libraries"
WHERE "assets"."libraryId" = "libraries"."id"
AND "libraries"."type" = 'UPLOAD'
`);
await queryRunner.query(`DELETE FROM "libraries" WHERE "type" = 'UPLOAD'`);
await queryRunner.query(`ALTER TABLE "libraries" DROP COLUMN "type"`);
await queryRunner.query(`CREATE INDEX "IDX_originalPath_libraryId" ON "assets" ("originalPath", "libraryId")`);
await queryRunner.query(`CREATE UNIQUE INDEX "UQ_assets_owner_checksum" ON "assets" ("ownerId", "checksum") WHERE "libraryId" IS NULL`);
await queryRunner.query(`CREATE UNIQUE INDEX "UQ_assets_owner_library_checksum" ON "assets" ("ownerId", "libraryId", "checksum") WHERE "libraryId" IS NOT NULL`);
await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "FK_9977c3c1de01c3d848039a6b90c" FOREIGN KEY ("libraryId") REFERENCES "libraries"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
}
public async down(): Promise<void> {
// not implemented
}
}

View file

@ -191,20 +191,6 @@ WHERE
AND ("SessionEntity"."id" IN ($2)) AND ("SessionEntity"."id" IN ($2))
) )
-- AccessRepository.library.checkOwnerAccess
SELECT
"LibraryEntity"."id" AS "LibraryEntity_id"
FROM
"libraries" "LibraryEntity"
WHERE
(
(
("LibraryEntity"."id" IN ($1))
AND ("LibraryEntity"."ownerId" = $2)
)
)
AND ("LibraryEntity"."deletedAt" IS NULL)
-- AccessRepository.memory.checkOwnerAccess -- AccessRepository.memory.checkOwnerAccess
SELECT SELECT
"MemoryEntity"."id" AS "MemoryEntity_id" "MemoryEntity"."id" AS "MemoryEntity_id"

View file

@ -483,26 +483,16 @@ LIMIT
1 1
-- AssetRepository.getUploadAssetIdByChecksum -- AssetRepository.getUploadAssetIdByChecksum
SELECT DISTINCT
"distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id"
FROM
(
SELECT SELECT
"AssetEntity"."id" AS "AssetEntity_id" "AssetEntity"."id" AS "AssetEntity_id"
FROM FROM
"assets" "AssetEntity" "assets" "AssetEntity"
LEFT JOIN "libraries" "AssetEntity__AssetEntity_library" ON "AssetEntity__AssetEntity_library"."id" = "AssetEntity"."libraryId"
WHERE WHERE
( (
("AssetEntity"."ownerId" = $1) ("AssetEntity"."ownerId" = $1)
AND ("AssetEntity"."checksum" = $2) AND ("AssetEntity"."checksum" = $2)
AND ( AND ("AssetEntity"."libraryId" IS NULL)
(("AssetEntity__AssetEntity_library"."type" = $3))
) )
)
) "distinctAlias"
ORDER BY
"AssetEntity_id" ASC
LIMIT LIMIT
1 1

View file

@ -9,7 +9,6 @@ FROM
"LibraryEntity"."id" AS "LibraryEntity_id", "LibraryEntity"."id" AS "LibraryEntity_id",
"LibraryEntity"."name" AS "LibraryEntity_name", "LibraryEntity"."name" AS "LibraryEntity_name",
"LibraryEntity"."ownerId" AS "LibraryEntity_ownerId", "LibraryEntity"."ownerId" AS "LibraryEntity_ownerId",
"LibraryEntity"."type" AS "LibraryEntity_type",
"LibraryEntity"."importPaths" AS "LibraryEntity_importPaths", "LibraryEntity"."importPaths" AS "LibraryEntity_importPaths",
"LibraryEntity"."exclusionPatterns" AS "LibraryEntity_exclusionPatterns", "LibraryEntity"."exclusionPatterns" AS "LibraryEntity_exclusionPatterns",
"LibraryEntity"."createdAt" AS "LibraryEntity_createdAt", "LibraryEntity"."createdAt" AS "LibraryEntity_createdAt",
@ -77,53 +76,11 @@ WHERE
((("LibraryEntity"."ownerId" = $1))) ((("LibraryEntity"."ownerId" = $1)))
AND ("LibraryEntity"."deletedAt" IS NULL) AND ("LibraryEntity"."deletedAt" IS NULL)
-- LibraryRepository.getDefaultUploadLibrary
SELECT
"LibraryEntity"."id" AS "LibraryEntity_id",
"LibraryEntity"."name" AS "LibraryEntity_name",
"LibraryEntity"."ownerId" AS "LibraryEntity_ownerId",
"LibraryEntity"."type" AS "LibraryEntity_type",
"LibraryEntity"."importPaths" AS "LibraryEntity_importPaths",
"LibraryEntity"."exclusionPatterns" AS "LibraryEntity_exclusionPatterns",
"LibraryEntity"."createdAt" AS "LibraryEntity_createdAt",
"LibraryEntity"."updatedAt" AS "LibraryEntity_updatedAt",
"LibraryEntity"."deletedAt" AS "LibraryEntity_deletedAt",
"LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt"
FROM
"libraries" "LibraryEntity"
WHERE
(
(
("LibraryEntity"."ownerId" = $1)
AND ("LibraryEntity"."type" = $2)
)
)
AND ("LibraryEntity"."deletedAt" IS NULL)
ORDER BY
"LibraryEntity"."createdAt" ASC
LIMIT
1
-- LibraryRepository.getUploadLibraryCount
SELECT
COUNT(1) AS "cnt"
FROM
"libraries" "LibraryEntity"
WHERE
(
(
("LibraryEntity"."ownerId" = $1)
AND ("LibraryEntity"."type" = $2)
)
)
AND ("LibraryEntity"."deletedAt" IS NULL)
-- LibraryRepository.getAllByUserId -- LibraryRepository.getAllByUserId
SELECT SELECT
"LibraryEntity"."id" AS "LibraryEntity_id", "LibraryEntity"."id" AS "LibraryEntity_id",
"LibraryEntity"."name" AS "LibraryEntity_name", "LibraryEntity"."name" AS "LibraryEntity_name",
"LibraryEntity"."ownerId" AS "LibraryEntity_ownerId", "LibraryEntity"."ownerId" AS "LibraryEntity_ownerId",
"LibraryEntity"."type" AS "LibraryEntity_type",
"LibraryEntity"."importPaths" AS "LibraryEntity_importPaths", "LibraryEntity"."importPaths" AS "LibraryEntity_importPaths",
"LibraryEntity"."exclusionPatterns" AS "LibraryEntity_exclusionPatterns", "LibraryEntity"."exclusionPatterns" AS "LibraryEntity_exclusionPatterns",
"LibraryEntity"."createdAt" AS "LibraryEntity_createdAt", "LibraryEntity"."createdAt" AS "LibraryEntity_createdAt",
@ -163,7 +120,6 @@ SELECT
"LibraryEntity"."id" AS "LibraryEntity_id", "LibraryEntity"."id" AS "LibraryEntity_id",
"LibraryEntity"."name" AS "LibraryEntity_name", "LibraryEntity"."name" AS "LibraryEntity_name",
"LibraryEntity"."ownerId" AS "LibraryEntity_ownerId", "LibraryEntity"."ownerId" AS "LibraryEntity_ownerId",
"LibraryEntity"."type" AS "LibraryEntity_type",
"LibraryEntity"."importPaths" AS "LibraryEntity_importPaths", "LibraryEntity"."importPaths" AS "LibraryEntity_importPaths",
"LibraryEntity"."exclusionPatterns" AS "LibraryEntity_exclusionPatterns", "LibraryEntity"."exclusionPatterns" AS "LibraryEntity_exclusionPatterns",
"LibraryEntity"."createdAt" AS "LibraryEntity_createdAt", "LibraryEntity"."createdAt" AS "LibraryEntity_createdAt",
@ -202,7 +158,6 @@ SELECT
"LibraryEntity"."id" AS "LibraryEntity_id", "LibraryEntity"."id" AS "LibraryEntity_id",
"LibraryEntity"."name" AS "LibraryEntity_name", "LibraryEntity"."name" AS "LibraryEntity_name",
"LibraryEntity"."ownerId" AS "LibraryEntity_ownerId", "LibraryEntity"."ownerId" AS "LibraryEntity_ownerId",
"LibraryEntity"."type" AS "LibraryEntity_type",
"LibraryEntity"."importPaths" AS "LibraryEntity_importPaths", "LibraryEntity"."importPaths" AS "LibraryEntity_importPaths",
"LibraryEntity"."exclusionPatterns" AS "LibraryEntity_exclusionPatterns", "LibraryEntity"."exclusionPatterns" AS "LibraryEntity_exclusionPatterns",
"LibraryEntity"."createdAt" AS "LibraryEntity_createdAt", "LibraryEntity"."createdAt" AS "LibraryEntity_createdAt",
@ -238,7 +193,6 @@ SELECT
"libraries"."id" AS "libraries_id", "libraries"."id" AS "libraries_id",
"libraries"."name" AS "libraries_name", "libraries"."name" AS "libraries_name",
"libraries"."ownerId" AS "libraries_ownerId", "libraries"."ownerId" AS "libraries_ownerId",
"libraries"."type" AS "libraries_type",
"libraries"."importPaths" AS "libraries_importPaths", "libraries"."importPaths" AS "libraries_importPaths",
"libraries"."exclusionPatterns" AS "libraries_exclusionPatterns", "libraries"."exclusionPatterns" AS "libraries_exclusionPatterns",
"libraries"."createdAt" AS "libraries_createdAt", "libraries"."createdAt" AS "libraries_createdAt",

View file

@ -167,12 +167,10 @@ SET
COALESCE(SUM(exif."fileSizeInByte"), 0) COALESCE(SUM(exif."fileSizeInByte"), 0)
FROM FROM
"assets" "assets" "assets" "assets"
LEFT JOIN "libraries" "library" ON "library"."id" = "assets"."libraryId"
AND ("library"."deletedAt" IS NULL)
LEFT JOIN "exif" "exif" ON "exif"."assetId" = "assets"."id" LEFT JOIN "exif" "exif" ON "exif"."assetId" = "assets"."id"
WHERE WHERE
"assets"."ownerId" = users.id "assets"."ownerId" = users.id
AND "library"."type" = 'UPLOAD' AND "assets"."libraryId" IS NULL
), ),
"updatedAt" = CURRENT_TIMESTAMP "updatedAt" = CURRENT_TIMESTAMP
WHERE WHERE

View file

@ -20,7 +20,6 @@ type IActivityAccess = IAccessRepository['activity'];
type IAlbumAccess = IAccessRepository['album']; type IAlbumAccess = IAccessRepository['album'];
type IAssetAccess = IAccessRepository['asset']; type IAssetAccess = IAccessRepository['asset'];
type IAuthDeviceAccess = IAccessRepository['authDevice']; type IAuthDeviceAccess = IAccessRepository['authDevice'];
type ILibraryAccess = IAccessRepository['library'];
type ITimelineAccess = IAccessRepository['timeline']; type ITimelineAccess = IAccessRepository['timeline'];
type IMemoryAccess = IAccessRepository['memory']; type IMemoryAccess = IAccessRepository['memory'];
type IPersonAccess = IAccessRepository['person']; type IPersonAccess = IAccessRepository['person'];
@ -313,28 +312,6 @@ class AuthDeviceAccess implements IAuthDeviceAccess {
} }
} }
class LibraryAccess implements ILibraryAccess {
constructor(private libraryRepository: Repository<LibraryEntity>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, libraryIds: Set<string>): Promise<Set<string>> {
if (libraryIds.size === 0) {
return new Set();
}
return this.libraryRepository
.find({
select: { id: true },
where: {
id: In([...libraryIds]),
ownerId: userId,
},
})
.then((libraries) => new Set(libraries.map((library) => library.id)));
}
}
class TimelineAccess implements ITimelineAccess { class TimelineAccess implements ITimelineAccess {
constructor(private partnerRepository: Repository<PartnerEntity>) {} constructor(private partnerRepository: Repository<PartnerEntity>) {}
@ -447,7 +424,6 @@ export class AccessRepository implements IAccessRepository {
album: IAlbumAccess; album: IAlbumAccess;
asset: IAssetAccess; asset: IAssetAccess;
authDevice: IAuthDeviceAccess; authDevice: IAuthDeviceAccess;
library: ILibraryAccess;
memory: IMemoryAccess; memory: IMemoryAccess;
person: IPersonAccess; person: IPersonAccess;
partner: IPartnerAccess; partner: IPartnerAccess;
@ -469,7 +445,6 @@ export class AccessRepository implements IAccessRepository {
this.album = new AlbumAccess(albumRepository, sharedLinkRepository); this.album = new AlbumAccess(albumRepository, sharedLinkRepository);
this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository); this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository);
this.authDevice = new AuthDeviceAccess(sessionRepository); this.authDevice = new AuthDeviceAccess(sessionRepository);
this.library = new LibraryAccess(libraryRepository);
this.memory = new MemoryAccess(memoryRepository); this.memory = new MemoryAccess(memoryRepository);
this.person = new PersonAccess(assetFaceRepository, personRepository); this.person = new PersonAccess(assetFaceRepository, personRepository);
this.partner = new PartnerAccess(partnerRepository); this.partner = new PartnerAccess(partnerRepository);

View file

@ -5,7 +5,6 @@ import { AlbumEntity, AssetOrder } from 'src/entities/album.entity';
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { AssetEntity, AssetType } from 'src/entities/asset.entity'; import { AssetEntity, AssetType } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity'; import { ExifEntity } from 'src/entities/exif.entity';
import { LibraryType } from 'src/entities/library.entity';
import { PartnerEntity } from 'src/entities/partner.entity'; import { PartnerEntity } from 'src/entities/partner.entity';
import { SmartInfoEntity } from 'src/entities/smart-info.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity';
import { import {
@ -292,8 +291,13 @@ export class AssetRepository implements IAssetRepository {
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] })
getByChecksum(libraryId: string, checksum: Buffer): Promise<AssetEntity | null> { getByChecksum(libraryId: string | null, checksum: Buffer): Promise<AssetEntity | null> {
return this.repository.findOne({ where: { libraryId, checksum } }); return this.repository.findOne({
where: {
libraryId: libraryId || IsNull(),
checksum,
},
});
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] })
@ -303,9 +307,7 @@ export class AssetRepository implements IAssetRepository {
where: { where: {
ownerId, ownerId,
checksum, checksum,
library: { library: IsNull(),
type: LibraryType.UPLOAD,
},
}, },
withDeleted: true, withDeleted: true,
}); });

View file

@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { LibraryStatsResponseDto } from 'src/dtos/library.dto'; import { LibraryStatsResponseDto } from 'src/dtos/library.dto';
import { LibraryEntity, LibraryType } from 'src/entities/library.entity'; import { LibraryEntity } from 'src/entities/library.entity';
import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface';
import { Instrumentation } from 'src/utils/instrumentation'; import { Instrumentation } from 'src/utils/instrumentation';
import { EntityNotFoundError, IsNull, Not } from 'typeorm'; import { EntityNotFoundError, IsNull, Not } from 'typeorm';
@ -40,34 +40,10 @@ export class LibraryRepository implements ILibraryRepository {
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getDefaultUploadLibrary(ownerId: string): Promise<LibraryEntity | null> { getAllByUserId(ownerId: string): Promise<LibraryEntity[]> {
return this.repository.findOne({
where: {
ownerId: ownerId,
type: LibraryType.UPLOAD,
},
order: {
createdAt: 'ASC',
},
});
}
@GenerateSql({ params: [DummyValue.UUID] })
getUploadLibraryCount(ownerId: string): Promise<number> {
return this.repository.count({
where: {
ownerId: ownerId,
type: LibraryType.UPLOAD,
},
});
}
@GenerateSql({ params: [DummyValue.UUID] })
getAllByUserId(ownerId: string, type?: LibraryType): Promise<LibraryEntity[]> {
return this.repository.find({ return this.repository.find({
where: { where: {
ownerId, ownerId,
type,
}, },
relations: { relations: {
owner: true, owner: true,
@ -79,9 +55,8 @@ export class LibraryRepository implements ILibraryRepository {
} }
@GenerateSql({ params: [] }) @GenerateSql({ params: [] })
getAll(withDeleted = false, type?: LibraryType): Promise<LibraryEntity[]> { getAll(withDeleted = false): Promise<LibraryEntity[]> {
return this.repository.find({ return this.repository.find({
where: { type },
relations: { relations: {
owner: true, owner: true,
}, },

View file

@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { LibraryType } from 'src/entities/library.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { import {
IUserRepository, IUserRepository,
@ -123,10 +122,9 @@ export class UserRepository implements IUserRepository {
const subQuery = this.assetRepository const subQuery = this.assetRepository
.createQueryBuilder('assets') .createQueryBuilder('assets')
.select('COALESCE(SUM(exif."fileSizeInByte"), 0)') .select('COALESCE(SUM(exif."fileSizeInByte"), 0)')
.leftJoin('assets.library', 'library')
.leftJoin('assets.exifInfo', 'exif') .leftJoin('assets.exifInfo', 'exif')
.where('assets.ownerId = users.id') .where('assets.ownerId = users.id')
.andWhere(`library.type = '${LibraryType.UPLOAD}'`) .andWhere(`assets.libraryId IS NULL`)
.withDeleted(); .withDeleted();
const query = this.userRepository const query = this.userRepository

View file

@ -32,7 +32,6 @@ const _getCreateAssetDto = (): CreateAssetDto => {
createAssetDto.isFavorite = false; createAssetDto.isFavorite = false;
createAssetDto.isArchived = false; createAssetDto.isArchived = false;
createAssetDto.duration = '0:00:00.000000'; createAssetDto.duration = '0:00:00.000000';
createAssetDto.libraryId = 'libraryId';
return createAssetDto; return createAssetDto;
}; };
@ -121,7 +120,6 @@ describe('AssetService', () => {
const dto = _getCreateAssetDto(); const dto = _getCreateAssetDto();
assetMock.create.mockResolvedValue(assetEntity); assetMock.create.mockResolvedValue(assetEntity);
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([dto.libraryId!]));
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' }); await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' });
@ -149,7 +147,6 @@ describe('AssetService', () => {
assetMock.create.mockRejectedValue(error); assetMock.create.mockRejectedValue(error);
assetRepositoryMockV1.getAssetsByChecksums.mockResolvedValue([_getAsset_1()]); assetRepositoryMockV1.getAssetsByChecksums.mockResolvedValue([_getAsset_1()]);
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([dto.libraryId!]));
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: true, id: 'id_1' }); await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: true, id: 'id_1' });
@ -167,7 +164,6 @@ describe('AssetService', () => {
assetMock.create.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); assetMock.create.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([dto.libraryId!]));
await expect( await expect(
sut.uploadFile(authStub.user1, dto, fileStub.livePhotoStill, fileStub.livePhotoMotion), sut.uploadFile(authStub.user1, dto, fileStub.livePhotoStill, fileStub.livePhotoMotion),

View file

@ -25,7 +25,6 @@ import {
} from 'src/dtos/asset-v1.dto'; } from 'src/dtos/asset-v1.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity';
import { LibraryType } from 'src/entities/library.entity';
import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface'; import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
@ -76,15 +75,20 @@ export class AssetServiceV1 {
let livePhotoAsset: AssetEntity | null = null; let livePhotoAsset: AssetEntity | null = null;
try { try {
const libraryId = await this.getLibraryId(auth, dto.libraryId); await this.access.requirePermission(
await this.access.requirePermission(auth, Permission.ASSET_UPLOAD, libraryId); auth,
Permission.ASSET_UPLOAD,
// do not need an id here, but the interface requires it
auth.user.id,
);
this.requireQuota(auth, file.size); this.requireQuota(auth, file.size);
if (livePhotoFile) { if (livePhotoFile) {
const livePhotoDto = { ...dto, assetType: AssetType.VIDEO, isVisible: false, libraryId }; const livePhotoDto = { ...dto, assetType: AssetType.VIDEO, isVisible: false };
livePhotoAsset = await this.create(auth, livePhotoDto, livePhotoFile); livePhotoAsset = await this.create(auth, livePhotoDto, livePhotoFile);
} }
const asset = await this.create(auth, { ...dto, libraryId }, file, livePhotoAsset?.id, sidecarFile?.originalPath); const asset = await this.create(auth, dto, file, livePhotoAsset?.id, sidecarFile?.originalPath);
await this.userRepository.updateUsage(auth.user.id, (livePhotoFile?.size || 0) + file.size); await this.userRepository.updateUsage(auth.user.id, (livePhotoFile?.size || 0) + file.size);
@ -245,36 +249,16 @@ export class AssetServiceV1 {
return asset.previewPath; return asset.previewPath;
} }
private async getLibraryId(auth: AuthDto, libraryId?: string) {
if (libraryId) {
return libraryId;
}
let library = await this.libraryRepository.getDefaultUploadLibrary(auth.user.id);
if (!library) {
library = await this.libraryRepository.create({
ownerId: auth.user.id,
name: 'Default Library',
assets: [],
type: LibraryType.UPLOAD,
importPaths: [],
exclusionPatterns: [],
});
}
return library.id;
}
private async create( private async create(
auth: AuthDto, auth: AuthDto,
dto: CreateAssetDto & { libraryId: string }, dto: CreateAssetDto,
file: UploadFile, file: UploadFile,
livePhotoAssetId?: string, livePhotoAssetId?: string,
sidecarPath?: string, sidecarPath?: string,
): Promise<AssetEntity> { ): Promise<AssetEntity> {
const asset = await this.assetRepository.create({ const asset = await this.assetRepository.create({
ownerId: auth.user.id, ownerId: auth.user.id,
libraryId: dto.libraryId, libraryId: null,
checksum: file.checksum, checksum: file.checksum,
originalPath: file.originalPath, originalPath: file.originalPath,

View file

@ -27,7 +27,6 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { MapMarkerDto, MapMarkerResponseDto, MemoryLaneDto } from 'src/dtos/search.dto'; import { MapMarkerDto, MapMarkerResponseDto, MemoryLaneDto } from 'src/dtos/search.dto';
import { UpdateStackParentDto } from 'src/dtos/stack.dto'; import { UpdateStackParentDto } from 'src/dtos/stack.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { LibraryType } from 'src/entities/library.entity';
import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
@ -424,7 +423,7 @@ export class AssetService {
} }
await this.assetRepository.remove(asset); await this.assetRepository.remove(asset);
if (asset.library.type === LibraryType.UPLOAD) { if (!asset.libraryId) {
await this.userRepository.updateUsage(asset.ownerId, -(asset.exifInfo?.fileSizeInByte || 0)); await this.userRepository.updateUsage(asset.ownerId, -(asset.exifInfo?.fileSizeInByte || 0));
} }
this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, asset.ownerId, id); this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, asset.ownerId, id);
@ -436,7 +435,7 @@ export class AssetService {
const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath]; const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath];
// skip originals if the user deleted the whole library // skip originals if the user deleted the whole library
if (!asset.library.deletedAt) { if (!asset.library?.deletedAt) {
files.push(asset.sidecarPath, asset.originalPath); files.push(asset.sidecarPath, asset.originalPath);
} }

View file

@ -180,7 +180,6 @@ describe(DownloadService.name, () => {
}); });
it('should return a list of archives (userId)', async () => { it('should return a list of archives (userId)', async () => {
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([authStub.admin.user.id]));
assetMock.getByUserId.mockResolvedValue({ assetMock.getByUserId.mockResolvedValue({
items: [assetStub.image, assetStub.video], items: [assetStub.image, assetStub.video],
hasNextPage: false, hasNextPage: false,
@ -196,8 +195,6 @@ describe(DownloadService.name, () => {
}); });
it('should split archives by size', async () => { it('should split archives by size', async () => {
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([authStub.admin.user.id]));
assetMock.getByUserId.mockResolvedValue({ assetMock.getByUserId.mockResolvedValue({
items: [ items: [
{ ...assetStub.image, id: 'asset-1' }, { ...assetStub.image, id: 'asset-1' },

View file

@ -4,7 +4,6 @@ import { SystemConfig } from 'src/config';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
import { mapLibrary } from 'src/dtos/library.dto'; import { mapLibrary } from 'src/dtos/library.dto';
import { AssetType } from 'src/entities/asset.entity'; import { AssetType } from 'src/entities/asset.entity';
import { LibraryType } from 'src/entities/library.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
@ -213,18 +212,6 @@ describe(LibraryService.name, () => {
]); ]);
}); });
it('should not scan upload libraries', async () => {
const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: false,
refreshAllFiles: false,
};
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.FAILED);
});
it('should ignore import paths that do not exist', async () => { it('should ignore import paths that do not exist', async () => {
storageMock.stat.mockImplementation((path): Promise<Stats> => { storageMock.stat.mockImplementation((path): Promise<Stats> => {
if (path === libraryStub.externalLibraryWithImportPaths1.importPaths[0]) { if (path === libraryStub.externalLibraryWithImportPaths1.importPaths[0]) {
@ -707,7 +694,6 @@ describe(LibraryService.name, () => {
describe('delete', () => { describe('delete', () => {
it('should delete a library', async () => { it('should delete a library', async () => {
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image);
libraryMock.getUploadLibraryCount.mockResolvedValue(2);
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
await sut.delete(libraryStub.externalLibrary1.id); await sut.delete(libraryStub.externalLibrary1.id);
@ -720,21 +706,8 @@ describe(LibraryService.name, () => {
expect(libraryMock.softDelete).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); expect(libraryMock.softDelete).toHaveBeenCalledWith(libraryStub.externalLibrary1.id);
}); });
it('should throw error if the last upload library is deleted', async () => {
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image);
libraryMock.getUploadLibraryCount.mockResolvedValue(1);
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
await expect(sut.delete(libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException);
expect(jobMock.queue).not.toHaveBeenCalled();
expect(jobMock.queueAll).not.toHaveBeenCalled();
expect(libraryMock.softDelete).not.toHaveBeenCalled();
});
it('should allow an external library to be deleted', async () => { it('should allow an external library to be deleted', async () => {
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image);
libraryMock.getUploadLibraryCount.mockResolvedValue(1);
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
await sut.delete(libraryStub.externalLibrary1.id); await sut.delete(libraryStub.externalLibrary1.id);
@ -749,7 +722,6 @@ describe(LibraryService.name, () => {
it('should unwatch an external library when deleted', async () => { it('should unwatch an external library when deleted', async () => {
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image);
libraryMock.getUploadLibraryCount.mockResolvedValue(1);
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]);
@ -767,37 +739,37 @@ describe(LibraryService.name, () => {
describe('get', () => { describe('get', () => {
it('should return a library', async () => { it('should return a library', async () => {
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
await expect(sut.get(libraryStub.uploadLibrary1.id)).resolves.toEqual( await expect(sut.get(libraryStub.externalLibrary1.id)).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
id: libraryStub.uploadLibrary1.id, id: libraryStub.externalLibrary1.id,
name: libraryStub.uploadLibrary1.name, name: libraryStub.externalLibrary1.name,
ownerId: libraryStub.uploadLibrary1.ownerId, ownerId: libraryStub.externalLibrary1.ownerId,
}), }),
); );
expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.uploadLibrary1.id); expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.externalLibrary1.id);
}); });
it('should throw an error when a library is not found', async () => { it('should throw an error when a library is not found', async () => {
libraryMock.get.mockResolvedValue(null); libraryMock.get.mockResolvedValue(null);
await expect(sut.get(libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.get(libraryStub.externalLibrary1.id)).rejects.toBeInstanceOf(BadRequestException);
expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.uploadLibrary1.id); expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.externalLibrary1.id);
}); });
}); });
describe('getStatistics', () => { describe('getStatistics', () => {
it('should return library statistics', async () => { it('should return library statistics', async () => {
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 });
await expect(sut.getStatistics(libraryStub.uploadLibrary1.id)).resolves.toEqual({ await expect(sut.getStatistics(libraryStub.externalLibrary1.id)).resolves.toEqual({
photos: 10, photos: 10,
videos: 0, videos: 0,
total: 10, total: 10,
usage: 1337, usage: 1337,
}); });
expect(libraryMock.getStatistics).toHaveBeenCalledWith(libraryStub.uploadLibrary1.id); expect(libraryMock.getStatistics).toHaveBeenCalledWith(libraryStub.externalLibrary1.id);
}); });
}); });
@ -805,10 +777,9 @@ describe(LibraryService.name, () => {
describe('external library', () => { describe('external library', () => {
it('should create with default settings', async () => { it('should create with default settings', async () => {
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
await expect(sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL })).resolves.toEqual( await expect(sut.create({ ownerId: authStub.admin.user.id })).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
id: libraryStub.externalLibrary1.id, id: libraryStub.externalLibrary1.id,
type: LibraryType.EXTERNAL,
name: libraryStub.externalLibrary1.name, name: libraryStub.externalLibrary1.name,
ownerId: libraryStub.externalLibrary1.ownerId, ownerId: libraryStub.externalLibrary1.ownerId,
assetCount: 0, assetCount: 0,
@ -823,7 +794,6 @@ describe(LibraryService.name, () => {
expect(libraryMock.create).toHaveBeenCalledWith( expect(libraryMock.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
name: expect.any(String), name: expect.any(String),
type: LibraryType.EXTERNAL,
importPaths: [], importPaths: [],
exclusionPatterns: [], exclusionPatterns: [],
}), }),
@ -832,12 +802,9 @@ describe(LibraryService.name, () => {
it('should create with name', async () => { it('should create with name', async () => {
libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1);
await expect( await expect(sut.create({ ownerId: authStub.admin.user.id, name: 'My Awesome Library' })).resolves.toEqual(
sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, name: 'My Awesome Library' }),
).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
id: libraryStub.externalLibrary1.id, id: libraryStub.externalLibrary1.id,
type: LibraryType.EXTERNAL,
name: libraryStub.externalLibrary1.name, name: libraryStub.externalLibrary1.name,
ownerId: libraryStub.externalLibrary1.ownerId, ownerId: libraryStub.externalLibrary1.ownerId,
assetCount: 0, assetCount: 0,
@ -852,7 +819,6 @@ describe(LibraryService.name, () => {
expect(libraryMock.create).toHaveBeenCalledWith( expect(libraryMock.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
name: 'My Awesome Library', name: 'My Awesome Library',
type: LibraryType.EXTERNAL,
importPaths: [], importPaths: [],
exclusionPatterns: [], exclusionPatterns: [],
}), }),
@ -864,13 +830,11 @@ describe(LibraryService.name, () => {
await expect( await expect(
sut.create({ sut.create({
ownerId: authStub.admin.user.id, ownerId: authStub.admin.user.id,
type: LibraryType.EXTERNAL,
importPaths: ['/data/images', '/data/videos'], importPaths: ['/data/images', '/data/videos'],
}), }),
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
id: libraryStub.externalLibrary1.id, id: libraryStub.externalLibrary1.id,
type: LibraryType.EXTERNAL,
name: libraryStub.externalLibrary1.name, name: libraryStub.externalLibrary1.name,
ownerId: libraryStub.externalLibrary1.ownerId, ownerId: libraryStub.externalLibrary1.ownerId,
assetCount: 0, assetCount: 0,
@ -885,7 +849,6 @@ describe(LibraryService.name, () => {
expect(libraryMock.create).toHaveBeenCalledWith( expect(libraryMock.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
name: expect.any(String), name: expect.any(String),
type: LibraryType.EXTERNAL,
importPaths: ['/data/images', '/data/videos'], importPaths: ['/data/images', '/data/videos'],
exclusionPatterns: [], exclusionPatterns: [],
}), }),
@ -901,7 +864,6 @@ describe(LibraryService.name, () => {
await sut.init(); await sut.init();
await sut.create({ await sut.create({
ownerId: authStub.admin.user.id, ownerId: authStub.admin.user.id,
type: LibraryType.EXTERNAL,
importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths, importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths,
}); });
}); });
@ -911,13 +873,11 @@ describe(LibraryService.name, () => {
await expect( await expect(
sut.create({ sut.create({
ownerId: authStub.admin.user.id, ownerId: authStub.admin.user.id,
type: LibraryType.EXTERNAL,
exclusionPatterns: ['*.tmp', '*.bak'], exclusionPatterns: ['*.tmp', '*.bak'],
}), }),
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
id: libraryStub.externalLibrary1.id, id: libraryStub.externalLibrary1.id,
type: LibraryType.EXTERNAL,
name: libraryStub.externalLibrary1.name, name: libraryStub.externalLibrary1.name,
ownerId: libraryStub.externalLibrary1.ownerId, ownerId: libraryStub.externalLibrary1.ownerId,
assetCount: 0, assetCount: 0,
@ -932,105 +892,22 @@ describe(LibraryService.name, () => {
expect(libraryMock.create).toHaveBeenCalledWith( expect(libraryMock.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
name: expect.any(String), name: expect.any(String),
type: LibraryType.EXTERNAL,
importPaths: [], importPaths: [],
exclusionPatterns: ['*.tmp', '*.bak'], exclusionPatterns: ['*.tmp', '*.bak'],
}), }),
); );
}); });
}); });
describe('upload library', () => {
it('should create with default settings', async () => {
libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1);
await expect(sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD })).resolves.toEqual(
expect.objectContaining({
id: libraryStub.uploadLibrary1.id,
type: LibraryType.UPLOAD,
name: libraryStub.uploadLibrary1.name,
ownerId: libraryStub.uploadLibrary1.ownerId,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
createdAt: libraryStub.uploadLibrary1.createdAt,
updatedAt: libraryStub.uploadLibrary1.updatedAt,
refreshedAt: null,
}),
);
expect(libraryMock.create).toHaveBeenCalledWith(
expect.objectContaining({
name: 'New Upload Library',
type: LibraryType.UPLOAD,
importPaths: [],
exclusionPatterns: [],
}),
);
});
it('should create with name', async () => {
libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1);
await expect(
sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, name: 'My Awesome Library' }),
).resolves.toEqual(
expect.objectContaining({
id: libraryStub.uploadLibrary1.id,
type: LibraryType.UPLOAD,
name: libraryStub.uploadLibrary1.name,
ownerId: libraryStub.uploadLibrary1.ownerId,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
createdAt: libraryStub.uploadLibrary1.createdAt,
updatedAt: libraryStub.uploadLibrary1.updatedAt,
refreshedAt: null,
}),
);
expect(libraryMock.create).toHaveBeenCalledWith(
expect.objectContaining({
name: 'My Awesome Library',
type: LibraryType.UPLOAD,
importPaths: [],
exclusionPatterns: [],
}),
);
});
it('should not create with import paths', async () => {
await expect(
sut.create({
ownerId: authStub.admin.user.id,
type: LibraryType.UPLOAD,
importPaths: ['/data/images', '/data/videos'],
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(libraryMock.create).not.toHaveBeenCalled();
});
it('should not create with exclusion patterns', async () => {
await expect(
sut.create({
ownerId: authStub.admin.user.id,
type: LibraryType.UPLOAD,
exclusionPatterns: ['*.tmp', '*.bak'],
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(libraryMock.create).not.toHaveBeenCalled();
});
});
}); });
describe('handleQueueCleanup', () => { describe('handleQueueCleanup', () => {
it('should queue cleanup jobs', async () => { it('should queue cleanup jobs', async () => {
libraryMock.getAllDeleted.mockResolvedValue([libraryStub.uploadLibrary1, libraryStub.externalLibrary1]); libraryMock.getAllDeleted.mockResolvedValue([libraryStub.externalLibrary1, libraryStub.externalLibrary2]);
await expect(sut.handleQueueCleanup()).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleQueueCleanup()).resolves.toBe(JobStatus.SUCCESS);
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ name: JobName.LIBRARY_DELETE, data: { id: libraryStub.uploadLibrary1.id } },
{ name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary1.id } }, { name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary1.id } },
{ name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary2.id } },
]); ]);
}); });
}); });
@ -1044,9 +921,9 @@ describe(LibraryService.name, () => {
}); });
it('should update library', async () => { it('should update library', async () => {
libraryMock.update.mockResolvedValue(libraryStub.uploadLibrary1); libraryMock.update.mockResolvedValue(libraryStub.externalLibrary1);
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
await expect(sut.update('library-id', {})).resolves.toEqual(mapLibrary(libraryStub.uploadLibrary1)); await expect(sut.update('library-id', {})).resolves.toEqual(mapLibrary(libraryStub.externalLibrary1));
expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' })); expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' }));
}); });
}); });
@ -1109,15 +986,6 @@ describe(LibraryService.name, () => {
expect(storageMock.watch).not.toHaveBeenCalled(); expect(storageMock.watch).not.toHaveBeenCalled();
}); });
it('should throw error when watching upload library', async () => {
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
libraryMock.getAll.mockResolvedValue([libraryStub.uploadLibrary1]);
await expect(sut.watchAll()).rejects.toThrow('Can only watch external libraries');
expect(storageMock.watch).not.toHaveBeenCalled();
});
it('should handle a new file event', async () => { it('should handle a new file event', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]);
@ -1253,25 +1121,25 @@ describe(LibraryService.name, () => {
libraryMock.getAssetIds.mockResolvedValue([]); libraryMock.getAssetIds.mockResolvedValue([]);
libraryMock.delete.mockImplementation(async () => {}); libraryMock.delete.mockImplementation(async () => {});
await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(JobStatus.FAILED); await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.FAILED);
}); });
it('should delete an empty library', async () => { it('should delete an empty library', async () => {
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
libraryMock.getAssetIds.mockResolvedValue([]); libraryMock.getAssetIds.mockResolvedValue([]);
libraryMock.delete.mockImplementation(async () => {}); libraryMock.delete.mockImplementation(async () => {});
await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS);
}); });
it('should delete a library with assets', async () => { it('should delete a library with assets', async () => {
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
libraryMock.getAssetIds.mockResolvedValue([assetStub.image1.id]); libraryMock.getAssetIds.mockResolvedValue([assetStub.image1.id]);
libraryMock.delete.mockImplementation(async () => {}); libraryMock.delete.mockImplementation(async () => {});
assetMock.getById.mockResolvedValue(assetStub.image1); assetMock.getById.mockResolvedValue(assetStub.image1);
await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS);
}); });
}); });
@ -1295,14 +1163,6 @@ describe(LibraryService.name, () => {
]); ]);
}); });
it('should not queue a library scan of upload library', async () => {
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
await expect(sut.queueScan(libraryStub.uploadLibrary1.id, {})).rejects.toBeInstanceOf(BadRequestException);
expect(jobMock.queue).not.toBeCalled();
});
it('should queue a library scan of all modified assets', async () => { it('should queue a library scan of all modified assets', async () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);

View file

@ -12,7 +12,6 @@ import {
LibraryResponseDto, LibraryResponseDto,
LibraryStatsResponseDto, LibraryStatsResponseDto,
ScanLibraryDto, ScanLibraryDto,
SearchLibraryDto,
UpdateLibraryDto, UpdateLibraryDto,
ValidateLibraryDto, ValidateLibraryDto,
ValidateLibraryImportPathResponseDto, ValidateLibraryImportPathResponseDto,
@ -20,7 +19,7 @@ import {
mapLibrary, mapLibrary,
} from 'src/dtos/library.dto'; } from 'src/dtos/library.dto';
import { AssetType } from 'src/entities/asset.entity'; import { AssetType } from 'src/entities/asset.entity';
import { LibraryEntity, LibraryType } from 'src/entities/library.entity'; import { LibraryEntity } from 'src/entities/library.entity';
import { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
@ -118,10 +117,7 @@ export class LibraryService {
} }
const library = await this.findOrFail(id); const library = await this.findOrFail(id);
if (library.importPaths.length === 0) {
if (library.type !== LibraryType.EXTERNAL) {
throw new BadRequestException('Can only watch external libraries');
} else if (library.importPaths.length === 0) {
return false; return false;
} }
@ -212,8 +208,7 @@ export class LibraryService {
return false; return false;
} }
const libraries = await this.repository.getAll(false, LibraryType.EXTERNAL); const libraries = await this.repository.getAll(false);
for (const library of libraries) { for (const library of libraries) {
await this.watch(library.id); await this.watch(library.id);
} }
@ -229,8 +224,8 @@ export class LibraryService {
return mapLibrary(library); return mapLibrary(library);
} }
async getAll(dto: SearchLibraryDto): Promise<LibraryResponseDto[]> { async getAll(): Promise<LibraryResponseDto[]> {
const libraries = await this.repository.getAll(false, dto.type); const libraries = await this.repository.getAll(false);
return libraries.map((library) => mapLibrary(library)); return libraries.map((library) => mapLibrary(library));
} }
@ -244,37 +239,12 @@ export class LibraryService {
} }
async create(dto: CreateLibraryDto): Promise<LibraryResponseDto> { async create(dto: CreateLibraryDto): Promise<LibraryResponseDto> {
switch (dto.type) {
case LibraryType.EXTERNAL: {
if (!dto.name) {
dto.name = 'New External Library';
}
break;
}
case LibraryType.UPLOAD: {
if (!dto.name) {
dto.name = 'New Upload Library';
}
if (dto.importPaths && dto.importPaths.length > 0) {
throw new BadRequestException('Upload libraries cannot have import paths');
}
if (dto.exclusionPatterns && dto.exclusionPatterns.length > 0) {
throw new BadRequestException('Upload libraries cannot have exclusion patterns');
}
break;
}
}
const library = await this.repository.create({ const library = await this.repository.create({
ownerId: dto.ownerId, ownerId: dto.ownerId,
name: dto.name, name: dto.name ?? 'New External Library',
type: dto.type,
importPaths: dto.importPaths ?? [], importPaths: dto.importPaths ?? [],
exclusionPatterns: dto.exclusionPatterns ?? [], exclusionPatterns: dto.exclusionPatterns ?? [],
}); });
this.logger.log(`Creating ${dto.type} library for ${dto.ownerId}}`);
return mapLibrary(library); return mapLibrary(library);
} }
@ -362,11 +332,7 @@ export class LibraryService {
} }
async delete(id: string) { async delete(id: string) {
const library = await this.findOrFail(id); await this.findOrFail(id);
const uploadCount = await this.repository.getUploadLibraryCount(library.ownerId);
if (library.type === LibraryType.UPLOAD && uploadCount <= 1) {
throw new BadRequestException('Cannot delete the last upload library');
}
if (this.watchLibraries) { if (this.watchLibraries) {
await this.unwatch(id); await this.unwatch(id);
@ -529,10 +495,7 @@ export class LibraryService {
} }
async queueScan(id: string, dto: ScanLibraryDto) { async queueScan(id: string, dto: ScanLibraryDto) {
const library = await this.findOrFail(id); await this.findOrFail(id);
if (library.type !== LibraryType.EXTERNAL) {
throw new BadRequestException('Can only refresh external libraries');
}
await this.jobRepository.queue({ await this.jobRepository.queue({
name: JobName.LIBRARY_SCAN, name: JobName.LIBRARY_SCAN,
@ -556,7 +519,7 @@ export class LibraryService {
await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} }); await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} });
// Queue all library refresh // Queue all library refresh
const libraries = await this.repository.getAll(true, LibraryType.EXTERNAL); const libraries = await this.repository.getAll(true);
await this.jobRepository.queueAll( await this.jobRepository.queueAll(
libraries.map((library) => ({ libraries.map((library) => ({
name: JobName.LIBRARY_SCAN, name: JobName.LIBRARY_SCAN,
@ -587,8 +550,8 @@ export class LibraryService {
async handleQueueAssetRefresh(job: ILibraryRefreshJob): Promise<JobStatus> { async handleQueueAssetRefresh(job: ILibraryRefreshJob): Promise<JobStatus> {
const library = await this.repository.get(job.id); const library = await this.repository.get(job.id);
if (!library || library.type !== LibraryType.EXTERNAL) { if (!library) {
this.logger.warn('Can only refresh external libraries'); this.logger.warn('Library not found');
return JobStatus.FAILED; return JobStatus.FAILED;
} }

View file

@ -409,7 +409,7 @@ export class MetadataService {
} }
const checksum = this.cryptoRepository.hashSha1(video); const checksum = this.cryptoRepository.hashSha1(video);
let motionAsset = await this.assetRepository.getByChecksum(asset.libraryId, checksum); let motionAsset = await this.assetRepository.getByChecksum(asset.libraryId ?? null, checksum);
if (motionAsset) { if (motionAsset) {
this.logger.debug( this.logger.debug(
`Asset ${asset.id}'s motion photo video with checksum ${checksum.toString( `Asset ${asset.id}'s motion photo video with checksum ${checksum.toString(

View file

@ -48,8 +48,6 @@ export const assetStub = {
deletedAt: null, deletedAt: null,
isOffline: false, isOffline: false,
isExternal: false, isExternal: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
duplicateId: null, duplicateId: null,
}), }),
@ -84,8 +82,6 @@ export const assetStub = {
sidecarPath: null, sidecarPath: null,
isOffline: false, isOffline: false,
isExternal: false, isExternal: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
exifInfo: { exifInfo: {
fileSizeInByte: 123_000, fileSizeInByte: 123_000,
} as ExifEntity, } as ExifEntity,
@ -114,8 +110,6 @@ export const assetStub = {
isFavorite: true, isFavorite: true,
isArchived: false, isArchived: false,
isOffline: false, isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
duration: null, duration: null,
isVisible: true, isVisible: true,
isExternal: false, isExternal: false,
@ -156,8 +150,6 @@ export const assetStub = {
livePhotoVideo: null, livePhotoVideo: null,
livePhotoVideoId: null, livePhotoVideoId: null,
isOffline: false, isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
tags: [], tags: [],
sharedLinks: [], sharedLinks: [],
originalFileName: 'asset-id.jpg', originalFileName: 'asset-id.jpg',
@ -203,8 +195,6 @@ export const assetStub = {
livePhotoVideo: null, livePhotoVideo: null,
livePhotoVideoId: null, livePhotoVideoId: null,
isOffline: false, isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
tags: [], tags: [],
sharedLinks: [], sharedLinks: [],
originalFileName: 'asset-id.jpg', originalFileName: 'asset-id.jpg',
@ -285,8 +275,6 @@ export const assetStub = {
livePhotoVideo: null, livePhotoVideo: null,
livePhotoVideoId: null, livePhotoVideoId: null,
isOffline: true, isOffline: true,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
tags: [], tags: [],
sharedLinks: [], sharedLinks: [],
originalFileName: 'asset-id.jpg', originalFileName: 'asset-id.jpg',
@ -364,8 +352,6 @@ export const assetStub = {
isVisible: true, isVisible: true,
livePhotoVideo: null, livePhotoVideo: null,
livePhotoVideoId: null, livePhotoVideoId: null,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
isExternal: false, isExternal: false,
isOffline: false, isOffline: false,
tags: [], tags: [],
@ -401,8 +387,6 @@ export const assetStub = {
isArchived: false, isArchived: false,
isExternal: false, isExternal: false,
isOffline: false, isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
duration: null, duration: null,
isVisible: true, isVisible: true,
livePhotoVideo: null, livePhotoVideo: null,
@ -442,8 +426,6 @@ export const assetStub = {
isArchived: false, isArchived: false,
isExternal: false, isExternal: false,
isOffline: false, isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
duration: null, duration: null,
isVisible: true, isVisible: true,
livePhotoVideo: null, livePhotoVideo: null,
@ -467,8 +449,6 @@ export const assetStub = {
isVisible: false, isVisible: false,
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
exifInfo: { exifInfo: {
fileSizeInByte: 100_000, fileSizeInByte: 100_000,
timeZone: `America/New_York`, timeZone: `America/New_York`,
@ -483,8 +463,6 @@ export const assetStub = {
isVisible: false, isVisible: false,
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
previewPath: '/uploads/user-id/thumbs/path.ext', previewPath: '/uploads/user-id/thumbs/path.ext',
thumbnailPath: '/uploads/user-id/webp/path.ext', thumbnailPath: '/uploads/user-id/webp/path.ext',
exifInfo: { exifInfo: {
@ -502,8 +480,6 @@ export const assetStub = {
isVisible: true, isVisible: true,
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
exifInfo: { exifInfo: {
fileSizeInByte: 25_000, fileSizeInByte: 25_000,
timeZone: `America/New_York`, timeZone: `America/New_York`,
@ -533,8 +509,6 @@ export const assetStub = {
isArchived: false, isArchived: false,
isExternal: false, isExternal: false,
isOffline: false, isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
duration: null, duration: null,
isVisible: true, isVisible: true,
livePhotoVideo: null, livePhotoVideo: null,
@ -576,8 +550,6 @@ export const assetStub = {
isArchived: false, isArchived: false,
isExternal: false, isExternal: false,
isOffline: false, isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
duration: null, duration: null,
isVisible: true, isVisible: true,
livePhotoVideo: null, livePhotoVideo: null,
@ -612,8 +584,6 @@ export const assetStub = {
isArchived: false, isArchived: false,
isExternal: false, isExternal: false,
isOffline: false, isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
duration: null, duration: null,
isVisible: true, isVisible: true,
livePhotoVideo: null, livePhotoVideo: null,
@ -649,8 +619,6 @@ export const assetStub = {
isArchived: false, isArchived: false,
isExternal: false, isExternal: false,
isOffline: false, isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
duration: null, duration: null,
isVisible: true, isVisible: true,
livePhotoVideo: null, livePhotoVideo: null,
@ -687,8 +655,6 @@ export const assetStub = {
isArchived: false, isArchived: false,
isExternal: false, isExternal: false,
isOffline: false, isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
duration: null, duration: null,
isVisible: true, isVisible: true,
livePhotoVideo: null, livePhotoVideo: null,
@ -807,8 +773,6 @@ export const assetStub = {
livePhotoVideo: null, livePhotoVideo: null,
livePhotoVideoId: null, livePhotoVideoId: null,
isOffline: false, isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
tags: [], tags: [],
sharedLinks: [], sharedLinks: [],
originalFileName: 'asset-id.jpg', originalFileName: 'asset-id.jpg',
@ -848,8 +812,6 @@ export const assetStub = {
livePhotoVideo: null, livePhotoVideo: null,
livePhotoVideoId: null, livePhotoVideoId: null,
isOffline: false, isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
tags: [], tags: [],
sharedLinks: [], sharedLinks: [],
originalFileName: 'asset-id.jpg', originalFileName: 'asset-id.jpg',
@ -891,8 +853,6 @@ export const assetStub = {
livePhotoVideo: null, livePhotoVideo: null,
livePhotoVideoId: null, livePhotoVideoId: null,
isOffline: false, isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
tags: [], tags: [],
sharedLinks: [], sharedLinks: [],
originalFileName: 'asset-id.jpg', originalFileName: 'asset-id.jpg',

View file

@ -49,9 +49,4 @@ export const errorStub = {
statusCode: 400, statusCode: 400,
message: 'The server already has an admin', message: 'The server already has an admin',
}, },
noDeleteUploadLibrary: {
error: 'Bad Request',
statusCode: 400,
message: 'Cannot delete the last upload library',
},
}; };

View file

@ -1,30 +1,16 @@
import { join } from 'node:path'; import { join } from 'node:path';
import { APP_MEDIA_LOCATION } from 'src/constants'; import { APP_MEDIA_LOCATION } from 'src/constants';
import { THUMBNAIL_DIR } from 'src/cores/storage.core'; import { THUMBNAIL_DIR } from 'src/cores/storage.core';
import { LibraryEntity, LibraryType } from 'src/entities/library.entity'; import { LibraryEntity } from 'src/entities/library.entity';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
export const libraryStub = { export const libraryStub = {
uploadLibrary1: Object.freeze<LibraryEntity>({
id: 'library-id',
name: 'test_library',
assets: [],
owner: userStub.user1,
ownerId: 'user-id',
type: LibraryType.UPLOAD,
importPaths: [],
createdAt: new Date('2022-01-01'),
updatedAt: new Date('2022-01-01'),
refreshedAt: null,
exclusionPatterns: [],
}),
externalLibrary1: Object.freeze<LibraryEntity>({ externalLibrary1: Object.freeze<LibraryEntity>({
id: 'library-id', id: 'library-id',
name: 'test_library', name: 'test_library',
assets: [], assets: [],
owner: userStub.admin, owner: userStub.admin,
ownerId: 'admin_id', ownerId: 'admin_id',
type: LibraryType.EXTERNAL,
importPaths: [], importPaths: [],
createdAt: new Date('2023-01-01'), createdAt: new Date('2023-01-01'),
updatedAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'),
@ -37,7 +23,6 @@ export const libraryStub = {
assets: [], assets: [],
owner: userStub.admin, owner: userStub.admin,
ownerId: 'admin_id', ownerId: 'admin_id',
type: LibraryType.EXTERNAL,
importPaths: [], importPaths: [],
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
updatedAt: new Date('2022-01-01'), updatedAt: new Date('2022-01-01'),
@ -50,7 +35,6 @@ export const libraryStub = {
assets: [], assets: [],
owner: userStub.admin, owner: userStub.admin,
ownerId: 'admin_id', ownerId: 'admin_id',
type: LibraryType.EXTERNAL,
importPaths: ['/foo', '/bar'], importPaths: ['/foo', '/bar'],
createdAt: new Date('2023-01-01'), createdAt: new Date('2023-01-01'),
updatedAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'),
@ -63,7 +47,6 @@ export const libraryStub = {
assets: [], assets: [],
owner: userStub.admin, owner: userStub.admin,
ownerId: 'admin_id', ownerId: 'admin_id',
type: LibraryType.EXTERNAL,
importPaths: ['/xyz', '/asdf'], importPaths: ['/xyz', '/asdf'],
createdAt: new Date('2023-01-01'), createdAt: new Date('2023-01-01'),
updatedAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'),
@ -76,7 +59,6 @@ export const libraryStub = {
assets: [], assets: [],
owner: userStub.admin, owner: userStub.admin,
ownerId: 'user-id', ownerId: 'user-id',
type: LibraryType.EXTERNAL,
importPaths: [], importPaths: [],
createdAt: new Date('2023-01-01'), createdAt: new Date('2023-01-01'),
updatedAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'),
@ -89,7 +71,6 @@ export const libraryStub = {
assets: [], assets: [],
owner: userStub.admin, owner: userStub.admin,
ownerId: 'user-id', ownerId: 'user-id',
type: LibraryType.EXTERNAL,
importPaths: ['/xyz', '/asdf'], importPaths: ['/xyz', '/asdf'],
createdAt: new Date('2023-01-01'), createdAt: new Date('2023-01-01'),
updatedAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'),
@ -102,7 +83,6 @@ export const libraryStub = {
assets: [], assets: [],
owner: userStub.admin, owner: userStub.admin,
ownerId: 'user-id', ownerId: 'user-id',
type: LibraryType.EXTERNAL,
importPaths: [join(THUMBNAIL_DIR, 'library'), '/xyz', join(APP_MEDIA_LOCATION, 'library')], importPaths: [join(THUMBNAIL_DIR, 'library'), '/xyz', join(APP_MEDIA_LOCATION, 'library')],
createdAt: new Date('2023-01-01'), createdAt: new Date('2023-01-01'),
updatedAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'),

View file

@ -9,7 +9,6 @@ import { SharedLinkEntity, SharedLinkType } from 'src/entities/shared-link.entit
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { libraryStub } from 'test/fixtures/library.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
const today = new Date(); const today = new Date();
@ -210,8 +209,6 @@ export const sharedLinkStub = {
isArchived: false, isArchived: false,
isExternal: false, isExternal: false,
isOffline: false, isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
smartInfo: { smartInfo: {
assetId: 'id_1', assetId: 'id_1',
tags: [], tags: [],

View file

@ -7,7 +7,6 @@ export interface IAccessRepositoryMock {
asset: Mocked<IAccessRepository['asset']>; asset: Mocked<IAccessRepository['asset']>;
album: Mocked<IAccessRepository['album']>; album: Mocked<IAccessRepository['album']>;
authDevice: Mocked<IAccessRepository['authDevice']>; authDevice: Mocked<IAccessRepository['authDevice']>;
library: Mocked<IAccessRepository['library']>;
timeline: Mocked<IAccessRepository['timeline']>; timeline: Mocked<IAccessRepository['timeline']>;
memory: Mocked<IAccessRepository['memory']>; memory: Mocked<IAccessRepository['memory']>;
person: Mocked<IAccessRepository['person']>; person: Mocked<IAccessRepository['person']>;
@ -43,10 +42,6 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock =>
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
}, },
library: {
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
},
timeline: { timeline: {
checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()), checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()),
}, },

View file

@ -10,8 +10,6 @@ export const newLibraryRepositoryMock = (): Mocked<ILibraryRepository> => {
softDelete: vitest.fn(), softDelete: vitest.fn(),
update: vitest.fn(), update: vitest.fn(),
getStatistics: vitest.fn(), getStatistics: vitest.fn(),
getDefaultUploadLibrary: vitest.fn(),
getUploadLibraryCount: vitest.fn(),
getAssetIds: vitest.fn(), getAssetIds: vitest.fn(),
getAllDeleted: vitest.fn(), getAllDeleted: vitest.fn(),
getAll: vitest.fn(), getAll: vitest.fn(),

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { LibraryType, type LibraryResponseDto } from '@immich/sdk'; import { type LibraryResponseDto } from '@immich/sdk';
import { mdiPencilOutline } from '@mdi/js'; import { mdiPencilOutline } from '@mdi/js';
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import { handleError } from '../../utils/handle-error'; import { handleError } from '../../utils/handle-error';
@ -27,14 +27,14 @@
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
cancel: void; cancel: void;
submit: { library: Partial<LibraryResponseDto>; type: LibraryType }; submit: Partial<LibraryResponseDto>;
}>(); }>();
const handleCancel = () => { const handleCancel = () => {
dispatch('cancel'); dispatch('cancel');
}; };
const handleSubmit = () => { const handleSubmit = () => {
dispatch('submit', { library, type: LibraryType.External }); dispatch('submit', library);
}; };
const handleAddExclusionPattern = () => { const handleAddExclusionPattern = () => {

View file

@ -24,7 +24,6 @@
getAllLibraries, getAllLibraries,
getLibraryStatistics, getLibraryStatistics,
getUserById, getUserById,
LibraryType,
removeOfflineFiles, removeOfflineFiles,
scanLibrary, scanLibrary,
updateLibrary, updateLibrary,
@ -32,7 +31,7 @@
type LibraryStatsResponseDto, type LibraryStatsResponseDto,
type UserResponseDto, type UserResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { mdiDatabase, mdiDotsVertical, mdiPlusBoxOutline, mdiSync, mdiUpload } from '@mdi/js'; import { mdiDatabase, mdiDotsVertical, mdiPlusBoxOutline, mdiSync } from '@mdi/js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { fade, slide } from 'svelte/transition'; import { fade, slide } from 'svelte/transition';
import LinkButton from '../../../lib/components/elements/buttons/link-button.svelte'; import LinkButton from '../../../lib/components/elements/buttons/link-button.svelte';
@ -108,7 +107,7 @@
}; };
async function readLibraryList() { async function readLibraryList() {
libraries = await getAllLibraries({ $type: LibraryType.External }); libraries = await getAllLibraries();
dropdownOpen.length = libraries.length; dropdownOpen.length = libraries.length;
for (let index = 0; index < libraries.length; index++) { for (let index = 0; index < libraries.length; index++) {
@ -119,10 +118,7 @@
const handleCreate = async (ownerId: string) => { const handleCreate = async (ownerId: string) => {
try { try {
const createdLibrary = await createLibrary({ const createdLibrary = await createLibrary({ createLibraryDto: { ownerId } });
createLibraryDto: { ownerId, type: LibraryType.External },
});
notificationController.show({ notificationController.show({
message: `Created library: ${createdLibrary.name}`, message: `Created library: ${createdLibrary.name}`,
type: NotificationType.Info, type: NotificationType.Info,
@ -135,14 +131,14 @@
} }
}; };
const handleUpdate = async (event: Partial<LibraryResponseDto>) => { const handleUpdate = async (library: Partial<LibraryResponseDto>) => {
if (updateLibraryIndex === null) { if (updateLibraryIndex === null) {
return; return;
} }
try { try {
const libraryId = libraries[updateLibraryIndex].id; const libraryId = libraries[updateLibraryIndex].id;
await updateLibrary({ id: libraryId, updateLibraryDto: { ...event } }); await updateLibrary({ id: libraryId, updateLibraryDto: library });
closeAll(); closeAll();
await readLibraryList(); await readLibraryList();
} catch (error) { } catch (error) {
@ -177,10 +173,8 @@
const handleScanAll = async () => { const handleScanAll = async () => {
try { try {
for (const library of libraries) { for (const library of libraries) {
if (library.type === LibraryType.External) {
await scanLibrary({ id: library.id, scanLibraryDto: {} }); await scanLibrary({ id: library.id, scanLibraryDto: {} });
} }
}
notificationController.show({ notificationController.show({
message: `Refreshing all libraries`, message: `Refreshing all libraries`,
type: NotificationType.Info, type: NotificationType.Info,
@ -361,12 +355,8 @@
}`} }`}
> >
<td class=" px-10 text-sm"> <td class=" px-10 text-sm">
{#if library.type === LibraryType.External}
<Icon path={mdiDatabase} size="40" title="External library (created on {library.createdAt})" /> <Icon path={mdiDatabase} size="40" title="External library (created on {library.createdAt})" />
{:else if library.type === LibraryType.Upload} </td>
<Icon path={mdiUpload} size="40" title="Upload library (created on {library.createdAt})" />
{/if}</td
>
<td class=" text-ellipsis px-4 text-sm">{library.name}</td> <td class=" text-ellipsis px-4 text-sm">{library.name}</td>
<td class=" text-ellipsis px-4 text-sm"> <td class=" text-ellipsis px-4 text-sm">
@ -400,7 +390,7 @@
<ContextMenu {...contextMenuPosition} on:outclick={() => onMenuExit()}> <ContextMenu {...contextMenuPosition} on:outclick={() => onMenuExit()}>
<MenuOption on:click={() => onRenameClicked()} text={`Rename`} /> <MenuOption on:click={() => onRenameClicked()} text={`Rename`} />
{#if selectedLibrary && selectedLibrary.type === LibraryType.External} {#if selectedLibrary}
<MenuOption on:click={() => onEditImportPathClicked()} text="Edit Import Paths" /> <MenuOption on:click={() => onEditImportPathClicked()} text="Edit Import Paths" />
<MenuOption on:click={() => onScanSettingClicked()} text="Scan Settings" /> <MenuOption on:click={() => onScanSettingClicked()} text="Scan Settings" />
<hr /> <hr />
@ -448,7 +438,7 @@
<div transition:slide={{ duration: 250 }} class="mb-4 ml-4 mr-4"> <div transition:slide={{ duration: 250 }} class="mb-4 ml-4 mr-4">
<LibraryScanSettingsForm <LibraryScanSettingsForm
{library} {library}
on:submit={({ detail }) => handleUpdate(detail.library)} on:submit={({ detail: library }) => handleUpdate(library)}
on:cancel={() => (editScanSettings = null)} on:cancel={() => (editScanSettings = null)}
/> />
</div> </div>

View file

@ -250,7 +250,7 @@
<div class="px-3"> <div class="px-3">
<p>OFFLINE PATHS {orphans.length > 0 ? `(${orphans.length})` : ''}</p> <p>OFFLINE PATHS {orphans.length > 0 ? `(${orphans.length})` : ''}</p>
<p class="text-gray-600 dark:text-gray-300 mt-1"> <p class="text-gray-600 dark:text-gray-300 mt-1">
These results may be due to manual deletion of files in the default upload library These results may be due to manual deletion of files that are not part of an external library.
</p> </p>
</div> </div>
</th> </th>