feat(server,web): libraries (#3124)

* feat: libraries

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Jonathan Jogenfors 2023-09-20 13:16:33 +02:00 committed by GitHub
parent 816db700e1
commit acdc66413c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
143 changed files with 10941 additions and 386 deletions

View file

@ -12,6 +12,7 @@
import FaceRecognition from 'svelte-material-icons/FaceRecognition.svelte';
import FileJpgBox from 'svelte-material-icons/FileJpgBox.svelte';
import FileXmlBox from 'svelte-material-icons/FileXmlBox.svelte';
import LibraryShelves from 'svelte-material-icons/LibraryShelves.svelte';
import FolderMove from 'svelte-material-icons/FolderMove.svelte';
import CogIcon from 'svelte-material-icons/Cog.svelte';
import Table from 'svelte-material-icons/Table.svelte';
@ -64,6 +65,13 @@
title: api.getJobName(JobName.MetadataExtraction),
subtitle: 'Extract metadata information i.e. GPS, resolution...etc',
},
[JobName.Library]: {
icon: LibraryShelves,
title: api.getJobName(JobName.Library),
subtitle: 'Perform library tasks',
allText: 'ALL',
missingText: 'REFRESH',
},
[JobName.Sidecar]: {
title: api.getJobName(JobName.Sidecar),
icon: FileXmlBox,

View file

@ -6,6 +6,7 @@
import { createEventDispatcher } from 'svelte';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
import AlertOutline from 'svelte-material-icons/AlertOutline.svelte';
import ContentCopy from 'svelte-material-icons/ContentCopy.svelte';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
@ -74,6 +75,14 @@
<CircleIconButton isOpacity={true} logo={ArrowLeft} on:click={() => dispatch('goBack')} />
</div>
<div class="flex w-[calc(100%-3rem)] justify-end gap-2 overflow-hidden text-white">
{#if asset.isOffline}
<CircleIconButton
isOpacity={true}
logo={AlertOutline}
on:click={() => dispatch('showDetail')}
title="Asset Offline"
/>
{/if}
{#if showMotionPlayButton}
{#if isMotionPhotoPlaying}
<CircleIconButton
@ -134,7 +143,9 @@
{/if}
{#if isOwner}
<CircleIconButton isOpacity={true} logo={DeleteOutline} on:click={() => dispatch('delete')} title="Delete" />
{#if !asset.isReadOnly && !asset.isExternal}
<CircleIconButton isOpacity={true} logo={DeleteOutline} on:click={() => dispatch('delete')} title="Delete" />
{/if}
<div use:clickOutside on:outclick={() => (isShowAssetOptions = false)}>
<CircleIconButton isOpacity={true} logo={DotsVertical} on:click={showOptionsMenu} title="More" />
{#if isShowAssetOptions}

View file

@ -50,7 +50,7 @@
let addToSharedAlbum = true;
let shouldPlayMotionPhoto = false;
let isShowProfileImageCrop = false;
let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : true;
let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
let canCopyImagesToClipboard: boolean;
const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo);

View file

@ -101,6 +101,20 @@
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">Info</p>
</div>
{#if asset.isOffline}
<section class="px-4 py-4">
<div role="alert">
<div class="rounded-t bg-red-500 px-4 py-2 font-bold text-white">Asset offline</div>
<div class="rounded-b border border-t-0 border-red-400 bg-red-100 px-4 py-3 text-red-700">
<p>
This asset is offline. Immich can not access its file location. Please ensure the asset is available and
then rescan the library.
</p>
</div>
</div>
</section>
{/if}
<section class="mx-4 mt-10" style:display={!isOwner && textarea?.value == '' ? 'none' : 'block'}>
<textarea
bind:this={textarea}
@ -156,8 +170,16 @@
{/if}
<div class="px-4 py-4">
{#if !asset.exifInfo}
{#if !asset.exifInfo && !asset.isExternal}
<p class="text-sm">NO EXIF INFO AVAILABLE</p>
{:else if !asset.exifInfo && asset.isExternal}
<div class="flex gap-4 py-4">
<div>
<p class="break-all">
Metadata not loaded for {asset.originalPath}
</p>
</div>
</div>
{:else}
<p class="text-sm">DETAILS</p>
{/if}

View file

@ -0,0 +1,55 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import FolderRemove from 'svelte-material-icons/FolderRemove.svelte';
import Button from '../elements/buttons/button.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
export let exclusionPattern: string;
export let canDelete = false;
export let submitText = 'Submit';
const dispatch = createEventDispatcher();
const handleCancel = () => dispatch('cancel');
const handleSubmit = () => dispatch('submit', { excludePattern: exclusionPattern });
</script>
<FullScreenModal on:clickOutside={() => handleCancel()}>
<div
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<FolderRemove size="4em" />
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Add Exclusion pattern</h1>
</div>
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
<p class="p-5 text-sm">
Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have
folders that contain files you don't want to import, such as RAW files.
<br /><br />
Add exclusion patterns. Globbing using *, **, and ? is supported. To ignore all files in any directory named "Raw",
use "**/Raw/**". To ignore all files ending in ".tif", use "**/*.tif". To ignore an absolute path, use "/path/to/ignore".
</p>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="exclusionPattern">Pattern</label>
<input
class="immich-form-input"
id="exclusionPattern"
name="exclusionPattern"
type="text"
bind:value={exclusionPattern}
/>
</div>
<div class="mt-8 flex w-full gap-4 px-4">
<Button color="gray" fullwidth on:click={() => handleCancel()}>Cancel</Button>
{#if canDelete}
<Button color="red" fullwidth on:click={() => dispatch('delete')}>Delete</Button>
{/if}
<Button type="submit" fullwidth>{submitText}</Button>
</div>
</form>
</div>
</FullScreenModal>

View file

@ -0,0 +1,53 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import FolderSync from 'svelte-material-icons/FolderSync.svelte';
import Button from '../elements/buttons/button.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
export let importPath: string;
export let title = 'Import path';
export let cancelText = 'Cancel';
export let submitText = 'Save';
export let canDelete = false;
const dispatch = createEventDispatcher();
const handleCancel = () => dispatch('cancel');
const handleSubmit = () => dispatch('submit', { importPath });
</script>
<FullScreenModal on:clickOutside={() => handleCancel()}>
<div
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<FolderSync size="4em" />
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">
{title}
</h1>
</div>
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
<p class="p-5 text-sm">
Specify a folder to import. This folder, including subfolders, will be scanned for images and videos. Note that
you are only allowed to import paths inside of your account's external path, configured in the administrative
settings.
</p>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="path">Path</label>
<input class="immich-form-input" id="path" name="path" type="text" bind:value={importPath} />
</div>
<div class="mt-8 flex w-full gap-4 px-4">
<Button color="gray" fullwidth on:click={() => handleCancel()}>{cancelText}</Button>
{#if canDelete}
<Button color="red" fullwidth on:click={() => dispatch('delete')}>Delete</Button>
{/if}
<Button type="submit" fullwidth>{submitText}</Button>
</div>
</form>
</div>
</FullScreenModal>

View file

@ -0,0 +1,174 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte';
import { handleError } from '../../utils/handle-error';
import LibraryImportPathForm from './library-import-path-form.svelte';
import { onMount } from 'svelte';
import PencilOutline from 'svelte-material-icons/PencilOutline.svelte';
import type { LibraryResponseDto } from '@api';
export let library: Partial<LibraryResponseDto>;
let addImportPath = false;
let editImportPath: number | null = null;
let importPathToAdd: string;
let editedImportPath: string;
let importPaths: string[] = [];
onMount(() => {
if (library.importPaths) {
importPaths = library.importPaths;
} else {
library.importPaths = [];
}
});
const dispatch = createEventDispatcher();
const handleCancel = () => {
dispatch('cancel');
};
const handleSubmit = () => {
dispatch('submit', { ...library });
};
const handleAddImportPath = async () => {
if (!addImportPath) {
return;
}
if (!library.importPaths) {
library.importPaths = [];
}
try {
library.importPaths.push(importPathToAdd);
importPaths = library.importPaths;
} catch (error) {
handleError(error, 'Unable to remove import path');
} finally {
addImportPath = false;
}
};
const handleEditImportPath = async () => {
if (editImportPath === null) {
return;
}
if (!library.importPaths) {
library.importPaths = [];
}
try {
library.importPaths[editImportPath] = editedImportPath;
importPaths = library.importPaths;
} catch (error) {
editImportPath = null;
handleError(error, 'Unable to edit import path');
} finally {
editImportPath = null;
}
};
const handleDeleteImportPath = async () => {
if (editImportPath === null) {
return;
}
try {
if (!library.importPaths) {
library.importPaths = [];
}
const pathToDelete = library.importPaths[editImportPath];
library.importPaths = library.importPaths.filter((path) => path != pathToDelete);
importPaths = library.importPaths;
} catch (error) {
handleError(error, 'Unable to delete import path');
} finally {
editImportPath = null;
}
};
</script>
{#if addImportPath}
<LibraryImportPathForm
title="Add Import Path"
submitText="Add"
bind:importPath={importPathToAdd}
on:submit={handleAddImportPath}
on:cancel={() => {
addImportPath = false;
}}
/>
{/if}
{#if editImportPath != null}
<LibraryImportPathForm
title="Edit Import Path"
submitText="Save"
canDelete={true}
bind:importPath={editedImportPath}
on:submit={handleEditImportPath}
on:delete={handleDeleteImportPath}
on:cancel={() => {
editImportPath = null;
}}
/>
{/if}
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off" class="m-4 flex flex-col gap-4">
<table class="text-left">
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each importPaths as importPath, listIndex}
<tr
class={`flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg ${
listIndex % 2 == 0
? 'bg-immich-gray dark:bg-immich-dark-gray/75'
: 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
>
<td class="w-4/5 text-ellipsis px-4 text-sm">{importPath}</td>
<td class="w-1/5 text-ellipsis px-4 text-sm">
<button
type="button"
on:click={() => {
editImportPath = listIndex;
editedImportPath = importPath;
}}
class="rounded-full bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
>
<PencilOutline size="16" />
</button>
</td>
</tr>
{/each}
<tr
class={`flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg ${
importPaths.length % 2 == 0
? 'bg-immich-gray dark:bg-immich-dark-gray/75'
: 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
>
<td class="w-4/5 text-ellipsis px-4 text-sm" />
<td class="w-1/5 text-ellipsis px-4 text-sm"
><Button
type="button"
size="sm"
on:click={() => {
addImportPath = true;
}}>Add path</Button
></td
></tr
>
</tbody>
</table>
<div class="flex w-full justify-end gap-2">
<Button size="sm" color="gray" on:click={() => handleCancel()}>Cancel</Button>
<Button size="sm" type="submit">Save</Button>
</div>
</form>

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte';
import type { LibraryResponseDto } from '@api';
export let library: Partial<LibraryResponseDto>;
const dispatch = createEventDispatcher();
const handleCancel = () => {
dispatch('cancel');
};
const handleSubmit = () => {
dispatch('submit', { ...library });
};
</script>
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off" class="m-4 flex flex-col gap-2">
<div class="flex flex-col gap-2">
<label class="immich-form-label" for="path">Name</label>
<input class="immich-form-input" id="name" name="name" type="text" bind:value={library.name} />
</div>
<div class="flex w-full justify-end gap-2 pt-2">
<Button size="sm" color="gray" on:click={() => handleCancel()}>Cancel</Button>
<Button size="sm" type="submit">Save</Button>
</div>
</form>

View file

@ -0,0 +1,175 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte';
import { LibraryType, type LibraryResponseDto } from '@api';
import { handleError } from '../../utils/handle-error';
import { onMount } from 'svelte';
import PencilOutline from 'svelte-material-icons/PencilOutline.svelte';
import LibraryExclusionPatternForm from './library-exclusion-pattern-form.svelte';
export let library: Partial<LibraryResponseDto>;
let addExclusionPattern = false;
let editExclusionPattern: number | null = null;
let exclusionPatternToAdd: string;
let editedExclusionPattern: string;
let exclusionPatterns: string[] = [];
onMount(() => {
if (library.exclusionPatterns) {
exclusionPatterns = library.exclusionPatterns;
} else {
library.exclusionPatterns = [];
}
});
const dispatch = createEventDispatcher();
const handleCancel = () => {
dispatch('cancel');
};
const handleSubmit = () => {
dispatch('submit', { ...library, libraryType: LibraryType.External });
};
const handleAddExclusionPattern = async () => {
if (!addExclusionPattern) {
return;
}
if (!library.exclusionPatterns) {
library.exclusionPatterns = [];
}
try {
library.exclusionPatterns.push(exclusionPatternToAdd);
exclusionPatternToAdd = '';
exclusionPatterns = library.exclusionPatterns;
addExclusionPattern = false;
} catch (error) {
handleError(error, 'Unable to add exclude pattern');
}
};
const handleEditExclusionPattern = async () => {
if (editExclusionPattern === null) {
return;
}
if (!library.exclusionPatterns) {
library.exclusionPatterns = [];
}
try {
library.exclusionPatterns[editExclusionPattern] = editedExclusionPattern;
exclusionPatterns = library.exclusionPatterns;
} catch (error) {
handleError(error, 'Unable to edit exclude pattern');
} finally {
editExclusionPattern = null;
}
};
const handleDeleteExclusionPattern = async () => {
if (editExclusionPattern === null) {
return;
}
try {
if (!library.exclusionPatterns) {
library.exclusionPatterns = [];
}
const pathToDelete = library.exclusionPatterns[editExclusionPattern];
library.exclusionPatterns = library.exclusionPatterns.filter((path) => path != pathToDelete);
exclusionPatterns = library.exclusionPatterns;
} catch (error) {
handleError(error, 'Unable to delete exclude pattern');
} finally {
editExclusionPattern = null;
}
};
</script>
{#if addExclusionPattern}
<LibraryExclusionPatternForm
submitText="Add"
bind:exclusionPattern={exclusionPatternToAdd}
on:submit={handleAddExclusionPattern}
on:cancel={() => {
addExclusionPattern = false;
}}
/>
{/if}
{#if editExclusionPattern != null}
<LibraryExclusionPatternForm
submitText="Save"
canDelete={true}
bind:exclusionPattern={editedExclusionPattern}
on:submit={handleEditExclusionPattern}
on:delete={handleDeleteExclusionPattern}
on:cancel={() => {
editExclusionPattern = null;
}}
/>
{/if}
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off" class="m-4 flex flex-col gap-4">
<table class="w-full text-left">
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each exclusionPatterns as exclusionPattern, listIndex}
<tr
class={`flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg ${
listIndex % 2 == 0
? 'bg-immich-gray dark:bg-immich-dark-gray/75'
: 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
>
<td class="w-3/4 text-ellipsis px-4 text-sm">{exclusionPattern}</td>
<td class="w-1/4 text-ellipsis px-4 text-sm">
<button
type="button"
on:click={() => {
editExclusionPattern = listIndex;
editedExclusionPattern = exclusionPattern;
}}
class="rounded-full bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
>
<PencilOutline size="16" />
</button>
</td>
</tr>
{/each}
<tr
class={`flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg ${
exclusionPatterns.length % 2 == 0
? 'bg-immich-gray dark:bg-immich-dark-gray/75'
: 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
>
<td class="w-3/4 text-ellipsis px-4 text-sm">
{#if exclusionPatterns.length === 0}
No pattern added
{/if}
</td>
<td class="w-1/4 text-ellipsis px-4 text-sm"
><Button
size="sm"
on:click={() => {
addExclusionPattern = true;
}}>Add Exclusion Pattern</Button
></td
></tr
>
</tbody>
</table>
<div class="flex w-full justify-end gap-4">
<Button size="sm" color="gray" on:click={() => handleCancel()}>Cancel</Button>
<Button size="sm" type="submit">Save</Button>
</div>
</form>

View file

@ -0,0 +1,380 @@
<script lang="ts">
import { api, UpdateLibraryDto, LibraryResponseDto, LibraryType, LibraryStatsResponseDto } from '@api';
import { onMount } from 'svelte';
import Button from '../elements/buttons/button.svelte';
import { notificationController, NotificationType } from '../shared-components/notification/notification';
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
import { handleError } from '$lib/utils/handle-error';
import { fade } from 'svelte/transition';
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
import Database from 'svelte-material-icons/Database.svelte';
import Upload from 'svelte-material-icons/Upload.svelte';
import Pulse from 'svelte-loading-spinners/Pulse.svelte';
import { slide } from 'svelte/transition';
import { Dropdown, DropdownDivider, DropdownItem, Helper } from 'flowbite-svelte';
import { Icon } from 'flowbite-svelte-icons';
import LibraryImportPathsForm from '../forms/library-import-paths-form.svelte';
import LibraryScanSettingsForm from '../forms/library-scan-settings-form.svelte';
import LibraryRenameForm from '../forms/library-rename-form.svelte';
import { getBytesWithUnit } from '$lib/utils/byte-units';
let libraries: LibraryResponseDto[] = [];
let stats: LibraryStatsResponseDto[] = [];
let photos: number[] = [];
let videos: number[] = [];
let totalCount: number[] = [];
let diskUsage: number[] = [];
let diskUsageUnit: string[] = [];
let confirmDeleteLibrary: LibraryResponseDto | null = null;
let deleteLibrary: LibraryResponseDto | null = null;
let editImportPaths: number | null;
let editScanSettings: number | null;
let renameLibrary: number | null;
let updateLibraryIndex: number | null;
let deleteAssetCount = 0;
let dropdownOpen: boolean[] = [];
onMount(() => {
readLibraryList();
});
const closeAll = () => {
editImportPaths = null;
editScanSettings = null;
renameLibrary = null;
updateLibraryIndex = null;
for (let i = 0; i < dropdownOpen.length; i++) {
dropdownOpen[i] = false;
}
};
const refreshStats = async (listIndex: number) => {
const { data } = await api.libraryApi.getLibraryStatistics({ id: libraries[listIndex].id });
stats[listIndex] = data;
photos[listIndex] = stats[listIndex].photos;
videos[listIndex] = stats[listIndex].videos;
totalCount[listIndex] = stats[listIndex].total;
[diskUsage[listIndex], diskUsageUnit[listIndex]] = getBytesWithUnit(stats[listIndex].usage, 0);
};
async function readLibraryList() {
const { data } = await api.libraryApi.getAllForUser();
libraries = data;
dropdownOpen.length = libraries.length;
for (let i = 0; i < libraries.length; i++) {
await refreshStats(i);
dropdownOpen[i] = false;
}
}
const handleCreate = async (libraryType: LibraryType) => {
try {
const { data } = await api.libraryApi.createLibrary({
createLibraryDto: { type: libraryType },
});
const createdLibrary = data;
notificationController.show({
message: `Created library: ${createdLibrary.name}`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to create library');
} finally {
await readLibraryList();
}
};
const handleUpdate = async (event: CustomEvent<UpdateLibraryDto>) => {
if (updateLibraryIndex === null) {
return;
}
try {
const dto = event.detail;
const libraryId = libraries[updateLibraryIndex].id;
await api.libraryApi.updateLibrary({ id: libraryId, updateLibraryDto: dto });
} catch (error) {
handleError(error, 'Unable to update library');
} finally {
closeAll();
await readLibraryList();
}
};
const handleDelete = async () => {
if (confirmDeleteLibrary) {
deleteLibrary = confirmDeleteLibrary;
}
if (!deleteLibrary) {
return;
}
try {
await api.libraryApi.deleteLibrary({ id: deleteLibrary.id });
notificationController.show({
message: `Library deleted`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to remove library');
} finally {
confirmDeleteLibrary = null;
deleteLibrary = null;
await readLibraryList();
}
};
const handleScanAll = async () => {
try {
for (const library of libraries) {
if (library.type === LibraryType.External) {
await api.libraryApi.scanLibrary({ id: library.id, scanLibraryDto: {} });
}
}
notificationController.show({
message: `Refreshing all libraries`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to scan libraries');
}
};
const handleScan = async (libraryId: string) => {
try {
await api.libraryApi.scanLibrary({ id: libraryId, scanLibraryDto: {} });
notificationController.show({
message: `Scanning library for new files`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to scan library');
}
};
const handleScanChanges = async (libraryId: string) => {
try {
await api.libraryApi.scanLibrary({ id: libraryId, scanLibraryDto: { refreshModifiedFiles: true } });
notificationController.show({
message: `Scanning library for changed files`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to scan library');
}
};
const handleForceScan = async (libraryId: string) => {
try {
await api.libraryApi.scanLibrary({ id: libraryId, scanLibraryDto: { refreshAllFiles: true } });
notificationController.show({
message: `Forcing refresh of all library files`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to scan library');
}
};
const handleRemoveOffline = async (libraryId: string) => {
try {
await api.libraryApi.removeOfflineFiles({ id: libraryId });
notificationController.show({
message: `Removing Offline Files`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to remove offline files');
}
};
</script>
{#if confirmDeleteLibrary}
<ConfirmDialogue
title="Warning!"
prompt="Are you sure you want to delete this library? This will DELETE all {deleteAssetCount} contained assets and cannot be undone."
on:confirm={handleDelete}
on:cancel={() => (confirmDeleteLibrary = null)}
/>
{/if}
<section class="my-4">
<div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
{#if libraries.length > 0}
<table class="w-full text-left">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
>
<tr class="flex w-full place-items-center">
<th class="w-1/6 text-center text-sm font-medium">Type</th>
<th class="w-1/3 text-center text-sm font-medium">Name</th>
<th class="w-1/5 text-center text-sm font-medium">Assets</th>
<th class="w-1/6 text-center text-sm font-medium">Size</th>
<th class="w-1/6 text-center text-sm font-medium" />
</tr>
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each libraries as library, index}
{#key library.id}
<tr
class={`flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg ${
index % 2 == 0
? 'bg-immich-gray dark:bg-immich-dark-gray/75'
: 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
>
<td class="w-1/6 px-10 text-sm">
{#if library.type === LibraryType.External}
<Database size="40" title="External library (created on {library.createdAt})" />
{:else if library.type === LibraryType.Upload}
<Upload size="40" title="Upload library (created on {library.createdAt})" />
{/if}</td
>
<td class="w-1/3 text-ellipsis px-4 text-sm">{library.name}</td>
{#if totalCount[index] == undefined}
<td colspan="2" class="flex w-1/3 items-center justify-center text-ellipsis px-4 text-sm">
<Pulse color="gray" size="40" unit="px" />
</td>
{:else}
<td class="w-1/6 text-ellipsis px-4 text-sm">
{totalCount[index]}
</td>
<td class="w-1/6 text-ellipsis px-4 text-sm">{diskUsage[index]} {diskUsageUnit[index]} </td>
{/if}
<td class="w-1/6 text-ellipsis px-4 text-sm">
<button
class="rounded-full bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
>
<DotsVertical size="16" />
</button>
<Dropdown bind:open={dropdownOpen[index]}>
<DropdownItem
on:click={() => {
closeAll();
renameLibrary = index;
updateLibraryIndex = index;
}}>Rename</DropdownItem
>
{#if library.type === LibraryType.External}
<DropdownItem
on:click={function () {
closeAll();
handleScan(library.id);
}}
>
Scan Library Files
<Helper>Looks for new files</Helper>
</DropdownItem>
<DropdownItem
on:click={() => {
closeAll();
editImportPaths = index;
updateLibraryIndex = index;
}}>Edit Import Paths</DropdownItem
>
<DropdownItem class="flex items-center justify-between">
Manage<Icon name="chevron-right-solid" class="ml-2 h-3 w-3 text-primary-700 dark:text-white" />
</DropdownItem>
<Dropdown slot="footer" class="w-60" placement="right-start">
<DropdownItem
on:click={() => {
closeAll();
editScanSettings = index;
updateLibraryIndex = index;
}}>Scan Settings</DropdownItem
>
<DropdownDivider />
<DropdownItem
on:click={function () {
closeAll();
handleScanChanges(library.id);
}}
>Scan All Library Files
<Helper>Rescan, but also refreshes modified files</Helper>
</DropdownItem>
<DropdownItem
on:click={function () {
closeAll();
handleForceScan(library.id);
}}
>Force Scan All Library Files
<Helper>Rescan, but refreshes every file</Helper>
</DropdownItem>
<DropdownItem
on:click={function () {
closeAll();
handleRemoveOffline(library.id);
}}
>Remove Offline Files
<Helper>Any offline files are removed from Immich</Helper>
</DropdownItem>
<DropdownItem
on:click={function () {
closeAll();
refreshStats(index);
if (totalCount[index] > 0) {
deleteAssetCount = totalCount[index];
confirmDeleteLibrary = library;
} else {
deleteLibrary = library;
handleDelete();
}
}}>Delete Library</DropdownItem
>
</Dropdown>
{/if}
</Dropdown>
</td>
</tr>
{#if renameLibrary === index}
<div transition:slide={{ duration: 250 }}>
<LibraryRenameForm {library} on:submit={handleUpdate} on:cancel={() => (renameLibrary = null)} />
</div>
{/if}
{#if editImportPaths === index}
<div transition:slide={{ duration: 250 }}>
<LibraryImportPathsForm
{library}
on:submit={handleUpdate}
on:cancel={() => (editImportPaths = null)}
/>
</div>
{/if}
{#if editScanSettings === index}
<div transition:slide={{ duration: 250 }} class="mb-4 ml-4 mr-4">
<LibraryScanSettingsForm
{library}
on:submit={handleUpdate}
on:cancel={() => (editScanSettings = null)}
/>
</div>
{/if}
{/key}
{/each}
</tbody>
</table>
{/if}
<div class="my-2 flex justify-end gap-2">
<Button size="sm" on:click={() => handleScanAll()}>Scan All Libraries</Button>
<Button size="sm" on:click={() => handleCreate(LibraryType.External)}>Create External Library</Button>
</div>
</div>
</section>

View file

@ -11,6 +11,7 @@
import PartnerSettings from './partner-settings.svelte';
import UserAPIKeyList from './user-api-key-list.svelte';
import UserProfileSettings from './user-profile-settings.svelte';
import LibraryList from './library-list.svelte';
export let user: UserResponseDto;
@ -36,6 +37,10 @@
<DeviceList bind:devices />
</SettingAccordion>
<SettingAccordion title="Libraries" subtitle="Manage your asset libraries">
<LibraryList />
</SettingAccordion>
<SettingAccordion title="Memories" subtitle="Manage what you see in your memories.">
<MemoriesSettings {user} />
</SettingAccordion>