1
0
forked from Cutlery/immich

Compare commits

...

80 Commits

Author SHA1 Message Date
Jonathan Jogenfors
b218e0d903 Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job 2024-04-06 00:29:37 +02:00
Jonathan Jogenfors
a352f7e1d7 Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job 2024-04-04 23:09:52 +02:00
Jonathan Jogenfors
0348372692 remove logging 2024-04-02 00:47:08 +02:00
Jonathan Jogenfors
17f2adb2db fix loop bug 2024-04-02 00:43:24 +02:00
Jonathan Jogenfors
ee0fedf061 Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job 2024-04-02 00:27:45 +02:00
Jonathan Jogenfors
5515f57c09 Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job 2024-03-30 21:52:16 +01:00
Jonathan Jogenfors
586bf19e1a refactor path validation 2024-03-26 23:24:00 +01:00
Jonathan Jogenfors
37ad05ff86 Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job 2024-03-26 23:08:51 +01:00
Jonathan Jogenfors
cfbad58d2c fix lint 2024-03-26 00:03:01 +01:00
Jonathan Jogenfors
2d40c85a54 fix comments 2024-03-25 23:58:45 +01:00
Jonathan Jogenfors
52a19b6f1f fix comments 2024-03-25 23:36:59 +01:00
Jonathan Jogenfors
57450108be move one test to new e2e 2024-03-25 23:32:06 +01:00
Jonathan Jogenfors
f2c723510f library spec compiles now 2024-03-25 22:55:40 +01:00
Jonathan Jogenfors
2c4b174f73 add offline job to correct queue 2024-03-25 22:50:56 +01:00
Jonathan Jogenfors
2398edf231 add library job back 2024-03-25 22:49:39 +01:00
Jonathan Jogenfors
17216e1984 Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job 2024-03-25 22:40:59 +01:00
Jonathan Jogenfors
fbee95b821 Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job 2024-03-22 22:55:16 +01:00
Jonathan Jogenfors
6fd511e59b fix lint 2024-03-20 22:24:48 +01:00
Jonathan Jogenfors
7043f2f04b Merge remote-tracking branch 'origin' into feat/offline-files-job 2024-03-20 22:24:24 +01:00
Jonathan Jogenfors
38e2cde109 fix merge 2024-03-20 22:18:33 +01:00
Jonathan Jogenfors
f48992b0cb Merge remote-tracking branch 'origin' into feat/offline-files-job 2024-03-20 22:04:03 +01:00
Jonathan Jogenfors
94b9b4d68a Merge remote-tracking branch 'origin' into feat/offline-files-job 2024-03-20 21:55:46 +01:00
Jonathan Jogenfors
cba8019243 add test for already-online assets 2024-03-20 11:58:36 +01:00
Jonathan Jogenfors
3fc6e826d5 add test for import path 2024-03-20 11:45:18 +01:00
Jonathan Jogenfors
5cdf31c739 decrease batch size to 1000 2024-03-20 11:45:09 +01:00
Jonathan Jogenfors
ea47cb84a4 remove console logs 2024-03-20 08:43:48 +01:00
Jonathan Jogenfors
7858cf4009 normalize import path 2024-03-20 08:36:00 +01:00
Jonathan Jogenfors
11eb7f7c3f fix lint 2024-03-20 08:23:15 +01:00
Jonathan Jogenfors
f5073a1a7a check import paths when testing offline 2024-03-20 08:17:22 +01:00
Jonathan Jogenfors
9a707874e4 test for import paths 2024-03-20 08:01:56 +01:00
Jonathan Jogenfors
b288743707 update specs 2024-03-20 07:40:48 +01:00
Jonathan Jogenfors
bedb494bf1 add file extensions 2024-03-20 07:40:05 +01:00
Jonathan Jogenfors
76241e0364 save -> update 2024-03-20 07:35:03 +01:00
Alex
482645e22d
Merge branch 'main' into feat/offline-files-job 2024-03-19 23:51:49 -05:00
Jonathan Jogenfors
894107126e use library batch size 2024-03-20 00:15:13 +01:00
Jonathan Jogenfors
f28f8992ab fix e2e 2024-03-19 23:38:06 +01:00
Jonathan Jogenfors
b969fc760d fix sql 2024-03-19 23:31:50 +01:00
Jonathan Jogenfors
7db36ea70d add mock 2024-03-19 22:46:35 +01:00
Jonathan Jogenfors
e6c761894c fix test 2024-03-19 22:39:35 +01:00
Jonathan Jogenfors
6f3401343f fix messages 2024-03-19 22:07:48 +01:00
Jonathan Jogenfors
3c5eb9259c fix errors 2024-03-19 22:01:20 +01:00
Jonathan Jogenfors
104ffdd7d5 Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job 2024-03-19 21:52:35 +01:00
Jonathan Jogenfors
69ce4e883a interweave scans 2024-03-19 21:52:30 +01:00
Jonathan Jogenfors
0070b83d8a Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job 2024-03-18 23:59:56 +01:00
Jonathan Jogenfors
17c9f0bd1d Merge remote-tracking branch 'origin' into feat/offline-files-job 2024-03-18 13:51:11 +01:00
Jonathan Jogenfors
32b86309a6 fix e2e 2024-03-17 17:28:13 +01:00
Jonathan Jogenfors
a5e6e90e24 fix lint 2024-03-17 13:50:24 +01:00
Jonathan Jogenfors
f2017730a1 Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job 2024-03-17 13:46:40 +01:00
Jonathan Jogenfors
890d488d12 add tests 2024-03-17 13:46:15 +01:00
Jonathan Jogenfors
65d3990dce fix test 2024-03-17 13:43:27 +01:00
Jonathan Jogenfors
5905fce428 fix test 2024-03-17 13:09:54 +01:00
Jonathan Jogenfors
649358cbc3 fix e2e 2024-03-17 01:05:22 +01:00
Jonathan Jogenfors
9b5a0a90ce fix tests 2024-03-17 00:54:50 +01:00
Jonathan Jogenfors
0bf31bdb44 remove tries 2024-03-17 00:40:35 +01:00
Jonathan Jogenfors
d8830e2a52 fix comments from mert 2024-03-17 00:32:44 +01:00
Jonathan Jogenfors
3f56cbeddf don't double batch 2024-03-17 00:30:13 +01:00
Jonathan Jogenfors
5bc22d5854 reset text 2024-03-17 00:27:16 +01:00
Jonathan Jogenfors
a3ce28bfc9 fix spinner 2024-03-17 00:26:19 +01:00
Jonathan Jogenfors
f8039a7d57 fix test 2024-03-16 00:43:26 +01:00
Jonathan Jogenfors
5ae4fb8b81 open-api 2024-03-16 00:38:36 +01:00
Jonathan Jogenfors
e26f8b45e0 add test 2024-03-16 00:37:20 +01:00
Jonathan Jogenfors
f7f30a5939 cleanup 2024-03-16 00:19:31 +01:00
Jonathan Jogenfors
380ae35ca4 fix lint 2024-03-16 00:06:28 +01:00
Jonathan Jogenfors
ff47d5576a fix job statuses 2024-03-16 00:00:56 +01:00
Jonathan Jogenfors
8bcee7ff64 Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job 2024-03-15 23:43:39 +01:00
Jonathan Jogenfors
fa3a70a2ad rename scan to check 2024-03-15 23:33:20 +01:00
Jonathan Jogenfors
d7a78e5f25 don't do offline scan 2024-03-14 20:57:15 +01:00
Jonathan Jogenfors
311d7d5fcd fix spelling 2024-03-14 20:54:56 +01:00
Jonathan Jogenfors
d8dd1fbff0 Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job 2024-03-14 20:54:27 +01:00
Jonathan Jogenfors
68a49258cb Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job 2024-03-14 20:43:56 +01:00
Jonathan Jogenfors
95b57f082e also check offline status 2024-03-14 12:17:28 +01:00
Jonathan Jogenfors
4ba95bb0a6 refactor batches 2024-03-14 11:45:21 +01:00
Jonathan Jogenfors
3b0d993f12 remove trie 2024-03-14 11:29:21 +01:00
Jonathan Jogenfors
5b581cee6a wip 2024-03-14 09:48:08 +01:00
Jonathan Jogenfors
d09d4d3f29 remove old test 2024-03-14 07:57:27 +01:00
Jonathan Jogenfors
f68bcf0f07 Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job 2024-03-14 07:49:39 +01:00
Jonathan Jogenfors
0803458d40 improve tests 2024-03-14 01:09:29 +01:00
Jonathan Jogenfors
247429c3e4 only check for offline when using checkForOffline 2024-03-14 00:47:25 +01:00
Jonathan Jogenfors
8bb73d6f3d fix lint 2024-03-14 00:19:43 +01:00
Jonathan Jogenfors
5e497e5166 add job to check for offline files 2024-03-13 21:55:27 +01:00
14 changed files with 415 additions and 286 deletions

View File

@ -474,10 +474,10 @@ describe('/library', () => {
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Can only refresh external libraries')); expect(body).toEqual(errorDto.badRequest('Can only scan external libraries'));
}); });
it('should scan external library', async () => { it('should scan 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, type: LibraryType.External,

View File

@ -31,77 +31,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
}); });
describe('POST /library/:id/scan', () => { describe('POST /library/:id/scan', () => {
it('should offline missing files', async () => {
await fs.promises.cp(`${IMMICH_TEST_ASSET_PATH}/albums/nature`, `${IMMICH_TEST_ASSET_TEMP_PATH}/albums/nature`, {
recursive: true,
});
const library = await api.libraryApi.create(server, admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
});
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const onlineAssets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(onlineAssets.length).toBeGreaterThan(1);
await restoreTempFolder();
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assets).toEqual(
expect.arrayContaining([
expect.objectContaining({
isOffline: true,
originalFileName: 'el_torcal_rocks.jpg',
}),
expect.objectContaining({
isOffline: true,
originalFileName: 'tanners_ridge.jpg',
}),
]),
);
});
it('should scan new files', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
});
await fs.promises.cp(
`${IMMICH_TEST_ASSET_PATH}/albums/nature/silver_fir.jpg`,
`${IMMICH_TEST_ASSET_TEMP_PATH}/silver_fir.jpg`,
);
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
await fs.promises.cp(
`${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`,
`${IMMICH_TEST_ASSET_TEMP_PATH}/el_torcal_rocks.jpg`,
);
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assets).toEqual(
expect.arrayContaining([
expect.objectContaining({
originalFileName: 'el_torcal_rocks.jpg',
}),
expect.objectContaining({
originalFileName: 'silver_fir.jpg',
}),
]),
);
});
describe('with refreshModifiedFiles=true', () => { describe('with refreshModifiedFiles=true', () => {
it('should reimport modified files', async () => { it('should reimport modified files', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, { const library = await api.libraryApi.create(server, admin.accessToken, {
@ -236,6 +165,185 @@ describe(`${LibraryController.name} (e2e)`, () => {
); );
}); });
}); });
it('should offline a missing file', async () => {
await fs.promises.cp(`${IMMICH_TEST_ASSET_PATH}/albums/nature`, `${IMMICH_TEST_ASSET_TEMP_PATH}/albums/nature`, {
recursive: true,
});
const library = await api.libraryApi.create(server, admin.accessToken, {
type: LibraryType.EXTERNAL,
ownerId: admin.userId,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
});
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const onlineAssets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(onlineAssets.length).toBeGreaterThan(1);
await fs.promises.rm(`${IMMICH_TEST_ASSET_TEMP_PATH}/albums/nature/silver_fir.jpg`);
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assets).toEqual(
expect.arrayContaining([
expect.objectContaining({
isOffline: true,
originalFileName: 'silver_fir.jpg',
}),
expect.objectContaining({
isOffline: false,
originalFileName: 'tanners_ridge.jpg',
}),
]),
);
});
it('should offline a file on disk not in import paths', async () => {
await fs.promises.cp(`${IMMICH_TEST_ASSET_PATH}/albums/nature`, `${IMMICH_TEST_ASSET_TEMP_PATH}/albums/nature`, {
recursive: true,
});
await fs.promises.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/albums/other`);
const library = await api.libraryApi.create(server, admin.accessToken, {
type: LibraryType.EXTERNAL,
ownerId: admin.userId,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}/albums`],
});
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const onlineAssets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(onlineAssets.length).toBeGreaterThan(1);
await api.libraryApi.setImportPaths(server, admin.accessToken, library.id, [
`${IMMICH_TEST_ASSET_TEMP_PATH}/albums/other`,
]);
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assets).toEqual(
expect.arrayContaining([
expect.objectContaining({
isOffline: true,
originalFileName: 'silver_fir.jpg',
}),
expect.objectContaining({
isOffline: true,
originalFileName: 'tanners_ridge.jpg',
}),
]),
);
});
it('should mark a rediscovered file as back online', async () => {
await fs.promises.cp(`${IMMICH_TEST_ASSET_PATH}/albums/nature`, `${IMMICH_TEST_ASSET_TEMP_PATH}/albums/nature`, {
recursive: true,
});
const library = await api.libraryApi.create(server, admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
});
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const onlineAssets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(onlineAssets.length).toBeGreaterThan(1);
await fs.promises.rm(`${IMMICH_TEST_ASSET_TEMP_PATH}/albums/nature/silver_fir.jpg`);
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
await fs.promises.cp(`${IMMICH_TEST_ASSET_PATH}/albums/nature`, `${IMMICH_TEST_ASSET_TEMP_PATH}/albums/nature`, {
recursive: true,
});
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assets).toEqual(
expect.arrayContaining([
expect.objectContaining({
isOffline: false,
originalFileName: 'silver_fir.jpg',
}),
expect.objectContaining({
isOffline: false,
originalFileName: 'tanners_ridge.jpg',
}),
]),
);
});
it('should mark a rediscovered file in import paths as back online', async () => {
await fs.promises.cp(`${IMMICH_TEST_ASSET_PATH}/albums/nature`, `${IMMICH_TEST_ASSET_TEMP_PATH}/albums/nature`, {
recursive: true,
});
await fs.promises.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/albums/other`);
const library = await api.libraryApi.create(server, admin.accessToken, {
ownerId: admin.userId,
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}/albums/`],
});
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const initialAssets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(initialAssets.length).toBeGreaterThan(1);
await api.libraryApi.setImportPaths(server, admin.accessToken, library.id, [
`${IMMICH_TEST_ASSET_TEMP_PATH}/albums/other`,
]);
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const offlineAssets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(offlineAssets).toEqual(
expect.arrayContaining([
expect.objectContaining({
isOffline: true,
originalFileName: 'silver_fir.jpg',
}),
expect.objectContaining({
isOffline: true,
originalFileName: 'tanners_ridge.jpg',
}),
]),
);
await api.libraryApi.setImportPaths(server, admin.accessToken, library.id, [
`${IMMICH_TEST_ASSET_TEMP_PATH}/albums/nature`,
]);
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const onlineAssets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(onlineAssets).toEqual(
expect.arrayContaining([
expect.objectContaining({
isOffline: false,
originalFileName: 'silver_fir.jpg',
}),
expect.objectContaining({
isOffline: false,
originalFileName: 'tanners_ridge.jpg',
}),
]),
);
});
}); });
describe('POST /library/:id/removeOffline', () => { describe('POST /library/:id/removeOffline', () => {

View File

@ -47,7 +47,6 @@
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"luxon": "^3.4.2", "luxon": "^3.4.2",
"mnemonist": "^0.39.8",
"nest-commander": "^3.11.1", "nest-commander": "^3.11.1",
"nestjs-otel": "^5.1.5", "nestjs-otel": "^5.1.5",
"openid-client": "^5.4.3", "openid-client": "^5.4.3",
@ -10460,14 +10459,6 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"dev": true "dev": true
}, },
"node_modules/mnemonist": {
"version": "0.39.8",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz",
"integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==",
"dependencies": {
"obliterator": "^2.0.1"
}
},
"node_modules/mock-fs": { "node_modules/mock-fs": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz",
@ -10853,11 +10844,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/obliterator": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.4.tgz",
"integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ=="
},
"node_modules/obuf": { "node_modules/obuf": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
@ -22093,14 +22079,6 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"dev": true "dev": true
}, },
"mnemonist": {
"version": "0.39.8",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz",
"integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==",
"requires": {
"obliterator": "^2.0.1"
}
},
"mock-fs": { "mock-fs": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz",
@ -22408,11 +22386,6 @@
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz",
"integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==" "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g=="
}, },
"obliterator": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.4.tgz",
"integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ=="
},
"obuf": { "obuf": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",

View File

@ -71,7 +71,6 @@
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"luxon": "^3.4.2", "luxon": "^3.4.2",
"mnemonist": "^0.39.8",
"nest-commander": "^3.11.1", "nest-commander": "^3.11.1",
"nestjs-otel": "^5.1.5", "nestjs-otel": "^5.1.5",
"openid-client": "^5.4.3", "openid-client": "^5.4.3",

View File

@ -48,6 +48,7 @@ export enum WithoutProperty {
export enum WithProperty { export enum WithProperty {
SIDECAR = 'sidecar', SIDECAR = 'sidecar',
IS_ONLINE = 'isOnline',
IS_OFFLINE = 'isOffline', IS_OFFLINE = 'isOffline',
} }

View File

@ -72,6 +72,7 @@ export enum JobName {
LIBRARY_SCAN = 'library-refresh', LIBRARY_SCAN = 'library-refresh',
LIBRARY_SCAN_ASSET = 'library-refresh-asset', LIBRARY_SCAN_ASSET = 'library-refresh-asset',
LIBRARY_REMOVE_OFFLINE = 'library-remove-offline', LIBRARY_REMOVE_OFFLINE = 'library-remove-offline',
LIBRARY_CHECK_OFFLINE = 'library-check-offline',
LIBRARY_DELETE = 'library-delete', LIBRARY_DELETE = 'library-delete',
LIBRARY_QUEUE_SCAN_ALL = 'library-queue-all-refresh', LIBRARY_QUEUE_SCAN_ALL = 'library-queue-all-refresh',
LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup', LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup',
@ -111,6 +112,10 @@ export interface ILibraryFileJob extends IEntityJob {
assetPath: string; assetPath: string;
} }
export interface ILibraryOfflineJob extends IEntityJob {
importPaths: string[];
}
export interface ILibraryRefreshJob extends IEntityJob { export interface ILibraryRefreshJob extends IEntityJob {
refreshModifiedFiles: boolean; refreshModifiedFiles: boolean;
refreshAllFiles: boolean; refreshAllFiles: boolean;
@ -216,6 +221,7 @@ export type JobItem =
| { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob } | { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob }
| { name: JobName.LIBRARY_DELETE; data: IEntityJob } | { name: JobName.LIBRARY_DELETE; data: IEntityJob }
| { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob } | { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob }
| { name: JobName.LIBRARY_CHECK_OFFLINE; data: IEntityJob }
| { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob }; | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob };
export enum JobStatus { export enum JobStatus {

View File

@ -281,20 +281,6 @@ WHERE
GROUP BY GROUP BY
"libraries"."id" "libraries"."id"
-- LibraryRepository.getOnlineAssetPaths
SELECT
"assets"."originalPath" AS "assets_originalPath"
FROM
"libraries" "library"
INNER JOIN "assets" "assets" ON "assets"."libraryId" = "library"."id"
AND ("assets"."deletedAt" IS NULL)
WHERE
(
"library"."id" = $1
AND "assets"."isOffline" = false
)
AND ("library"."deletedAt" IS NULL)
-- LibraryRepository.getAssetIds -- LibraryRepository.getAssetIds
SELECT SELECT
"assets"."id" AS "assets_id" "assets"."id" AS "assets_id"

View File

@ -289,7 +289,7 @@ export class AssetRepository implements IAssetRepository {
@GenerateSql( @GenerateSql(
...Object.values(WithProperty) ...Object.values(WithProperty)
.filter((property) => property !== WithProperty.IS_OFFLINE) .filter((property) => property !== WithProperty.IS_OFFLINE && property !== WithProperty.IS_ONLINE)
.map((property) => ({ .map((property) => ({
name: property, name: property,
params: [DummyValue.PAGINATION, property], params: [DummyValue.PAGINATION, property],
@ -430,7 +430,14 @@ export class AssetRepository implements IAssetRepository {
if (!libraryId) { if (!libraryId) {
throw new Error('Library id is required when finding offline assets'); throw new Error('Library id is required when finding offline assets');
} }
where = [{ isOffline: true, libraryId: libraryId }]; where = [{ isOffline: true, libraryId }];
break;
}
case WithProperty.IS_ONLINE: {
if (!libraryId) {
throw new Error('Library id is required when finding online assets');
}
where = [{ isOffline: false, libraryId }];
break; break;
} }

View File

@ -74,6 +74,7 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
[JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY, [JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY,
[JobName.LIBRARY_SCAN]: QueueName.LIBRARY, [JobName.LIBRARY_SCAN]: QueueName.LIBRARY,
[JobName.LIBRARY_DELETE]: QueueName.LIBRARY, [JobName.LIBRARY_DELETE]: QueueName.LIBRARY,
[JobName.LIBRARY_CHECK_OFFLINE]: QueueName.LIBRARY,
[JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY, [JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY, [JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY, [JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY,

View File

@ -151,26 +151,6 @@ export class LibraryRepository implements ILibraryRepository {
}; };
} }
@GenerateSql({ params: [DummyValue.UUID] })
async getOnlineAssetPaths(libraryId: string): Promise<string[]> {
// Return all non-offline asset paths for a given library
const rawResults = await this.repository
.createQueryBuilder('library')
.innerJoinAndSelect('library.assets', 'assets')
.where('library.id = :id', { id: libraryId })
.andWhere('assets.isOffline = false')
.select('assets.originalPath')
.getRawMany();
const results: string[] = [];
for (const rawPath of rawResults) {
results.push(rawPath.assets_originalPath);
}
return results;
}
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getAssetIds(libraryId: string, withDeleted = false): Promise<string[]> { async getAssetIds(libraryId: string, withDeleted = false): Promise<string[]> {
let query = this.repository let query = this.repository

View File

@ -11,7 +11,14 @@ 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';
import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IJobRepository, ILibraryFileJob, ILibraryRefreshJob, JobName, JobStatus } from 'src/interfaces/job.interface'; import {
IJobRepository,
ILibraryFileJob,
ILibraryOfflineJob,
ILibraryRefreshJob,
JobName,
JobStatus,
} from 'src/interfaces/job.interface';
import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface';
import { IStorageRepository, StorageEventType } from 'src/interfaces/storage.interface'; import { IStorageRepository, StorageEventType } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
@ -148,13 +155,15 @@ describe(LibraryService.name, () => {
}); });
describe('handleQueueAssetRefresh', () => { describe('handleQueueAssetRefresh', () => {
it('should queue new assets', async () => { it('should queue refresh of a new asset', async () => {
const mockLibraryJob: ILibraryRefreshJob = { const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibrary1.id, id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: false, refreshModifiedFiles: false,
refreshAllFiles: false, refreshAllFiles: false,
}; };
assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false });
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
// eslint-disable-next-line @typescript-eslint/require-await // eslint-disable-next-line @typescript-eslint/require-await
storageMock.walk.mockImplementation(async function* generator() { storageMock.walk.mockImplementation(async function* generator() {
@ -184,6 +193,7 @@ describe(LibraryService.name, () => {
refreshAllFiles: true, refreshAllFiles: true,
}; };
assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false });
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
// eslint-disable-next-line @typescript-eslint/require-await // eslint-disable-next-line @typescript-eslint/require-await
storageMock.walk.mockImplementation(async function* generator() { storageMock.walk.mockImplementation(async function* generator() {
@ -231,6 +241,8 @@ describe(LibraryService.name, () => {
storageMock.checkFileExists.mockResolvedValue(true); storageMock.checkFileExists.mockResolvedValue(true);
assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false });
const mockLibraryJob: ILibraryRefreshJob = { const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibraryWithImportPaths1.id, id: libraryStub.externalLibraryWithImportPaths1.id,
refreshModifiedFiles: false, refreshModifiedFiles: false,
@ -247,49 +259,85 @@ describe(LibraryService.name, () => {
exclusionPatterns: [], exclusionPatterns: [],
}); });
}); });
});
describe('handleOfflineCheck', () => {
it('should set missing assets offline', async () => { it('should set missing assets offline', async () => {
const mockLibraryJob: ILibraryRefreshJob = { const mockAssetJob: ILibraryOfflineJob = {
id: libraryStub.externalLibrary1.id, id: assetStub.external.id,
refreshModifiedFiles: false, importPaths: ['/'],
refreshAllFiles: false,
}; };
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); assetMock.getById.mockResolvedValue(assetStub.external);
assetMock.getLibraryAssetPaths.mockResolvedValue({
items: [assetStub.image],
hasNextPage: false,
});
await sut.handleQueueAssetRefresh(mockLibraryJob); storageMock.checkFileExists.mockResolvedValue(false);
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.image.id], { isOffline: true }); await sut.handleOfflineCheck(mockAssetJob);
expect(assetMock.updateAll).not.toHaveBeenCalledWith(expect.anything(), { isOffline: false });
expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true });
}); });
it('should set crawled assets that were previously offline back online', async () => { it('should set an asset outside of import paths as offline', async () => {
const mockLibraryJob: ILibraryRefreshJob = { const mockAssetJob: ILibraryOfflineJob = {
id: libraryStub.externalLibrary1.id, id: assetStub.external.id,
refreshModifiedFiles: false, importPaths: ['/data/user2'],
refreshAllFiles: false,
}; };
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); assetMock.getById.mockResolvedValue(assetStub.external);
// eslint-disable-next-line @typescript-eslint/require-await
storageMock.walk.mockImplementation(async function* generator() {
yield assetStub.offline.originalPath;
});
assetMock.getLibraryAssetPaths.mockResolvedValue({
items: [assetStub.offline],
hasNextPage: false,
});
await sut.handleQueueAssetRefresh(mockLibraryJob); storageMock.checkFileExists.mockResolvedValue(true);
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.offline.id], { isOffline: false }); await sut.handleOfflineCheck(mockAssetJob);
expect(assetMock.updateAll).not.toHaveBeenCalledWith(expect.anything(), { isOffline: true });
expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true });
});
it('should skip an already-offline asset', async () => {
const mockAssetJob: ILibraryOfflineJob = {
id: assetStub.external.id,
importPaths: ['/'],
};
assetMock.getById.mockResolvedValue(assetStub.offline);
storageMock.checkFileExists.mockResolvedValue(true);
const response = await sut.handleOfflineCheck(mockAssetJob);
expect(response).toBe(JobStatus.SKIPPED);
expect(assetMock.update).not.toHaveBeenCalled();
});
it('should do nothing if asset is still online', async () => {
const mockAssetJob: ILibraryOfflineJob = {
id: assetStub.external.id,
importPaths: ['/'],
};
assetMock.getById.mockResolvedValue(assetStub.external);
storageMock.checkFileExists.mockResolvedValue(true);
const response = await sut.handleOfflineCheck(mockAssetJob);
expect(response).toBe(JobStatus.SUCCESS);
expect(assetMock.update).not.toHaveBeenCalled();
});
it('should skip a nonexistent asset id', async () => {
const mockAssetJob: ILibraryOfflineJob = {
id: assetStub.external.id,
importPaths: ['/'],
};
assetMock.getById.mockImplementation(() => Promise.resolve(null));
storageMock.checkFileExists.mockResolvedValue(true);
const response = await sut.handleOfflineCheck(mockAssetJob);
expect(response).toBe(JobStatus.SKIPPED);
expect(assetMock.update).not.toHaveBeenCalled();
}); });
}); });
@ -1096,6 +1144,18 @@ describe(LibraryService.name, () => {
expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' })); expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' }));
}); });
it('should reject an invalid import path', async () => {
libraryMock.update.mockResolvedValue(libraryStub.uploadLibrary1);
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
storageMock.stat.mockResolvedValue({
isDirectory: () => false,
} as Stats);
await expect(sut.update('library-id', { importPaths: ['/nonexistent'] })).rejects.toThrow(
'Invalid import path: Not a directory',
);
});
it('should re-watch library when updating import paths', async () => { it('should re-watch library when updating import paths', async () => {
libraryMock.update.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.update.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);

View File

@ -1,5 +1,4 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { Trie } from 'mnemonist';
import { R_OK } from 'node:constants'; import { R_OK } from 'node:constants';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { Stats } from 'node:fs'; import { Stats } from 'node:fs';
@ -20,8 +19,8 @@ import {
ValidateLibraryResponseDto, ValidateLibraryResponseDto,
mapLibrary, mapLibrary,
} from 'src/dtos/library.dto'; } from 'src/dtos/library.dto';
import { AssetType } from 'src/entities/asset.entity'; import { AssetEntity, AssetType } from 'src/entities/asset.entity';
import { LibraryEntity, LibraryType } from 'src/entities/library.entity'; import { LibraryType } 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';
@ -31,6 +30,7 @@ import {
IEntityJob, IEntityJob,
IJobRepository, IJobRepository,
ILibraryFileJob, ILibraryFileJob,
ILibraryOfflineJob,
ILibraryRefreshJob, ILibraryRefreshJob,
JOBS_ASSET_PAGINATION_SIZE, JOBS_ASSET_PAGINATION_SIZE,
JobName, JobName,
@ -45,7 +45,7 @@ import { handlePromiseError } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
import { validateCronExpression } from 'src/validation'; import { validateCronExpression } from 'src/validation';
const LIBRARY_SCAN_BATCH_SIZE = 5000; const LIBRARY_SCAN_BATCH_SIZE = 1000;
@Injectable() @Injectable()
export class LibraryService extends EventEmitter { export class LibraryService extends EventEmitter {
@ -294,24 +294,17 @@ export class LibraryService extends EventEmitter {
private async scanAssets(libraryId: string, assetPaths: string[], ownerId: string, force = false) { private async scanAssets(libraryId: string, assetPaths: string[], ownerId: string, force = false) {
this.logger.verbose(`Queuing refresh of ${assetPaths.length} asset(s)`); this.logger.verbose(`Queuing refresh of ${assetPaths.length} asset(s)`);
// We perform this in batches to save on memory when performing large refreshes (greater than 1M assets) await this.jobRepository.queueAll(
const batchSize = 5000; assetPaths.map((assetPath) => ({
for (let i = 0; i < assetPaths.length; i += batchSize) { name: JobName.LIBRARY_SCAN_ASSET,
const batch = assetPaths.slice(i, i + batchSize); data: {
await this.jobRepository.queueAll( id: libraryId,
batch.map((assetPath) => ({ assetPath: assetPath,
name: JobName.LIBRARY_SCAN_ASSET, ownerId,
data: { force,
id: libraryId, },
assetPath: assetPath, })),
ownerId, );
force,
},
})),
);
}
this.logger.debug('Asset refresh queue completed');
} }
private async validateImportPath(importPath: string): Promise<ValidateLibraryImportPathResponseDto> { private async validateImportPath(importPath: string): Promise<ValidateLibraryImportPathResponseDto> {
@ -494,6 +487,7 @@ export class LibraryService extends EventEmitter {
sidecarPath = `${assetPath}.xmp`; sidecarPath = `${assetPath}.xmp`;
} }
// TODO: device asset id is deprecated, remove it
const deviceAssetId = `${basename(assetPath)}`.replaceAll(/\s+/g, ''); const deviceAssetId = `${basename(assetPath)}`.replaceAll(/\s+/g, '');
let assetId; let assetId;
@ -550,7 +544,7 @@ export class LibraryService extends EventEmitter {
async queueScan(id: string, dto: ScanLibraryDto) { async queueScan(id: string, dto: ScanLibraryDto) {
const library = await this.findOrFail(id); const library = await this.findOrFail(id);
if (library.type !== LibraryType.EXTERNAL) { if (library.type !== LibraryType.EXTERNAL) {
throw new BadRequestException('Can only refresh external libraries'); throw new BadRequestException('Can only scan external libraries');
} }
await this.jobRepository.queue({ await this.jobRepository.queue({
@ -589,6 +583,29 @@ export class LibraryService extends EventEmitter {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
// Check if an asset has no file or is outside of import paths, marking it as offline
async handleOfflineCheck(job: ILibraryOfflineJob): Promise<JobStatus> {
const asset = await this.assetRepository.getById(job.id);
if (!asset || asset.isOffline) {
// We only care about online assets, we exit here if offline
return JobStatus.SKIPPED;
}
const exists = await this.storageRepository.checkFileExists(asset.originalPath, R_OK);
const isInPath = job.importPaths.find((path) => asset.originalPath.startsWith(path));
if (exists && isInPath) {
this.logger.verbose(`Asset is still online: ${asset.originalPath}`);
} else {
this.logger.debug(`Marking asset as offline: ${asset.originalPath}`);
await this.assetRepository.update({ id: asset.id, isOffline: true });
}
return JobStatus.SUCCESS;
}
async handleOfflineRemoval(job: IEntityJob): Promise<JobStatus> { async handleOfflineRemoval(job: IEntityJob): Promise<JobStatus> {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.assetRepository.getWith(pagination, WithProperty.IS_OFFLINE, job.id), this.assetRepository.getWith(pagination, WithProperty.IS_OFFLINE, job.id),
@ -607,106 +624,92 @@ export class LibraryService extends EventEmitter {
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 || library.type !== LibraryType.EXTERNAL) {
this.logger.warn('Can only refresh external libraries'); this.logger.warn('Can only scan external libraries');
return JobStatus.FAILED; return JobStatus.FAILED;
} }
this.logger.log(`Refreshing library: ${job.id}`); this.logger.log(`Refreshing library: ${job.id}`);
const crawledAssetPaths = await this.getPathTrie(library); const validImportPaths: string[] = [];
this.logger.debug(`Found ${crawledAssetPaths.size} asset(s) when crawling import paths ${library.importPaths}`);
const assetIdsToMarkOffline = []; for (const importPath of library.importPaths) {
const assetIdsToMarkOnline = []; const validation = await this.validateImportPath(importPath);
const pagination = usePagination(LIBRARY_SCAN_BATCH_SIZE, (pagination) => if (validation.isValid) {
this.assetRepository.getLibraryAssetPaths(pagination, library.id), validImportPaths.push(path.normalize(importPath));
); } else {
this.logger.error(`Skipping invalid import path: ${importPath}. Reason: ${validation.message}`);
this.logger.verbose(`Crawled asset paths paginated`);
const shouldScanAll = job.refreshAllFiles || job.refreshModifiedFiles;
for await (const page of pagination) {
for (const asset of page) {
const isOffline = !crawledAssetPaths.has(asset.originalPath);
if (isOffline && !asset.isOffline) {
assetIdsToMarkOffline.push(asset.id);
this.logger.verbose(`Added to mark-offline list: ${asset.originalPath}`);
}
if (!isOffline && asset.isOffline) {
assetIdsToMarkOnline.push(asset.id);
this.logger.verbose(`Added to mark-online list: ${asset.originalPath}`);
}
if (!shouldScanAll) {
crawledAssetPaths.delete(asset.originalPath);
}
} }
} }
this.logger.verbose(`Crawled assets have been checked for online/offline status`); const crawledAssets = this.storageRepository.walk({
if (assetIdsToMarkOffline.length > 0) {
this.logger.debug(`Found ${assetIdsToMarkOffline.length} offline asset(s) previously marked as online`);
await this.assetRepository.updateAll(assetIdsToMarkOffline, { isOffline: true });
}
if (assetIdsToMarkOnline.length > 0) {
this.logger.debug(`Found ${assetIdsToMarkOnline.length} online asset(s) previously marked as offline`);
await this.assetRepository.updateAll(assetIdsToMarkOnline, { isOffline: false });
}
if (crawledAssetPaths.size > 0) {
if (!shouldScanAll) {
this.logger.debug(`Will import ${crawledAssetPaths.size} new asset(s)`);
}
let batch = [];
for (const assetPath of crawledAssetPaths) {
batch.push(assetPath);
if (batch.length >= LIBRARY_SCAN_BATCH_SIZE) {
await this.scanAssets(job.id, batch, library.ownerId, job.refreshAllFiles ?? false);
batch = [];
}
}
if (batch.length > 0) {
await this.scanAssets(job.id, batch, library.ownerId, job.refreshAllFiles ?? false);
}
}
await this.repository.update({ id: job.id, refreshedAt: new Date() });
return JobStatus.SUCCESS;
}
private async getPathTrie(library: LibraryEntity): Promise<Trie<string>> {
const pathValidation = await Promise.all(
library.importPaths.map(async (importPath) => await this.validateImportPath(importPath)),
);
const validImportPaths = pathValidation
.map((validation) => {
if (!validation.isValid) {
this.logger.error(`Skipping invalid import path: ${validation.importPath}. Reason: ${validation.message}`);
}
return validation;
})
.filter((validation) => validation.isValid)
.map((validation) => validation.importPath);
const generator = this.storageRepository.walk({
pathsToCrawl: validImportPaths, pathsToCrawl: validImportPaths,
exclusionPatterns: library.exclusionPatterns, exclusionPatterns: library.exclusionPatterns,
}); });
const trie = new Trie<string>(); const onlineAssets = usePagination(LIBRARY_SCAN_BATCH_SIZE, (pagination) =>
for await (const filePath of generator) { this.assetRepository.getWith(pagination, WithProperty.IS_ONLINE, job.id),
trie.add(filePath); );
let crawlDone = false;
let existingAssetsDone = false;
let crawlCounter = 0;
let existingAssetCounter = 0;
const checkIfOnlineAssetsAreOffline = async () => {
const existingAssetPage = await onlineAssets.next();
existingAssetsDone = existingAssetPage.done ?? true;
if (existingAssetPage.value) {
existingAssetCounter += existingAssetPage.value.length;
this.logger.log(
`Queuing online check of ${existingAssetPage.value.length} asset(s) in library ${library.id}...`,
);
await this.jobRepository.queueAll(
existingAssetPage.value.map((asset: AssetEntity) => ({
name: JobName.LIBRARY_CHECK_OFFLINE,
data: { id: asset.id, importPaths: validImportPaths },
})),
);
}
};
let crawledAssetPaths: string[] = [];
while (!crawlDone) {
const crawlResult = await crawledAssets.next();
crawlDone = crawlResult.done ?? true;
if (!crawlDone) {
crawledAssetPaths.push(crawlResult.value);
crawlCounter++;
}
if (crawledAssetPaths.length % LIBRARY_SCAN_BATCH_SIZE === 0 || crawlDone) {
this.logger.log(`Queueing scan of ${crawledAssetPaths.length} asset path(s) in library ${library.id}...`);
// We have reached the batch size or the end of the generator, scan the assets
await this.scanAssets(job.id, crawledAssetPaths, library.ownerId, job.refreshAllFiles ?? false);
crawledAssetPaths = [];
if (!existingAssetsDone) {
// Interweave the queuing of offline checks with the asset scanning (if any)
await checkIfOnlineAssetsAreOffline();
}
}
} }
return trie; // If there are any remaining assets to check for offline status, do so
while (!existingAssetsDone) {
await checkIfOnlineAssetsAreOffline();
}
this.logger.log(
`Finished queuing scan of ${crawlCounter} crawled and ${existingAssetCounter} existing asset(s) in library ${library.id}`,
);
await this.repository.update({ id: job.id, refreshedAt: new Date() });
return JobStatus.SUCCESS;
} }
private async findOrFail(id: string) { private async findOrFail(id: string) {

View File

@ -74,6 +74,7 @@ export class MicroservicesService {
[JobName.LIBRARY_SCAN_ASSET]: (data) => this.libraryService.handleAssetRefresh(data), [JobName.LIBRARY_SCAN_ASSET]: (data) => this.libraryService.handleAssetRefresh(data),
[JobName.LIBRARY_SCAN]: (data) => this.libraryService.handleQueueAssetRefresh(data), [JobName.LIBRARY_SCAN]: (data) => this.libraryService.handleQueueAssetRefresh(data),
[JobName.LIBRARY_DELETE]: (data) => this.libraryService.handleDeleteLibrary(data), [JobName.LIBRARY_DELETE]: (data) => this.libraryService.handleDeleteLibrary(data),
[JobName.LIBRARY_CHECK_OFFLINE]: (data) => this.libraryService.handleOfflineCheck(data),
[JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleOfflineRemoval(data), [JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleOfflineRemoval(data),
[JobName.LIBRARY_QUEUE_SCAN_ALL]: (data) => this.libraryService.handleQueueAllScan(data), [JobName.LIBRARY_QUEUE_SCAN_ALL]: (data) => this.libraryService.handleQueueAllScan(data),
[JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(), [JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(),

View File

@ -371,16 +371,20 @@
{:else}{owner[index].name}{/if} {:else}{owner[index].name}{/if}
</td> </td>
{#if totalCount[index] == undefined} <td class=" text-ellipsis px-4 text-sm">
<td colspan="2" class="flex w-1/3 items-center justify-center text-ellipsis px-4 text-sm"> {#if totalCount[index] == undefined}
<LoadingSpinner size="40" /> <LoadingSpinner size="40" />
</td> {:else}
{:else}
<td class=" text-ellipsis px-4 text-sm">
{totalCount[index]} {totalCount[index]}
</td> {/if}
<td class=" text-ellipsis px-4 text-sm">{diskUsage[index]} {diskUsageUnit[index]}</td> </td>
{/if} <td class=" text-ellipsis px-4 text-sm">
{#if totalCount[index] == undefined}
<LoadingSpinner size="40" />
{:else}
{diskUsage[index]} {diskUsageUnit[index]}
{/if}
</td>
<td class=" text-ellipsis px-4 text-sm"> <td class=" text-ellipsis px-4 text-sm">
<button <button