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

[WEB] Upload asset directly to album (#379)

* Added stores to get album assetId

* Upload assets and add to album

* Added comments

* resolve conflict when add assets from upload directly

* Filtered out duplicate asset before adding to the album
This commit is contained in:
Alex 2022-07-26 20:53:25 -05:00 committed by GitHub
parent 2336a6159c
commit 03457f5d32
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 3802 additions and 4405 deletions

View file

@ -202,8 +202,6 @@ export class AssetController {
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) checkDuplicateAssetDto: CheckDuplicateAssetDto,
): Promise<CheckDuplicateAssetResponseDto> {
const res = await this.assetService.checkDuplicatedAsset(authUser, checkDuplicateAssetDto);
return new CheckDuplicateAssetResponseDto(res);
return await this.assetService.checkDuplicatedAsset(authUser, checkDuplicateAssetDto);
}
}

View file

@ -24,6 +24,7 @@ import { AssetFileUploadDto } from './dto/asset-file-upload.dto';
import { CreateAssetDto } from './dto/create-asset.dto';
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
const fileInfo = promisify(stat);
@ -487,7 +488,10 @@ export class AssetService {
return curatedObjects;
}
async checkDuplicatedAsset(authUser: AuthUserDto, checkDuplicateAssetDto: CheckDuplicateAssetDto): Promise<boolean> {
async checkDuplicatedAsset(
authUser: AuthUserDto,
checkDuplicateAssetDto: CheckDuplicateAssetDto,
): Promise<CheckDuplicateAssetResponseDto> {
const res = await this.assetRepository.findOne({
where: {
deviceAssetId: checkDuplicateAssetDto.deviceAssetId,
@ -496,6 +500,8 @@ export class AssetService {
},
});
return res ? true : false;
const isDuplicated = res ? true : false;
return new CheckDuplicateAssetResponseDto(isDuplicated, res?.id);
}
}

View file

@ -1,6 +1,8 @@
export class CheckDuplicateAssetResponseDto {
constructor(isExist: boolean) {
constructor(isExist: boolean, id?: string) {
this.isExist = isExist;
this.id = id;
}
isExist: boolean;
id?: string;
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -5,29 +5,30 @@
* Immich API
*
* The version of the OpenAPI document: 1.17.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import { Configuration } from './configuration';
import { Configuration } from "./configuration";
// Some imports not used depending on template conditions
// @ts-ignore
import globalAxios, { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios';
export const BASE_PATH = '/api'.replace(/\/+$/, '');
export const BASE_PATH = "/api".replace(/\/+$/, "");
/**
*
* @export
*/
export const COLLECTION_FORMATS = {
csv: ',',
ssv: ' ',
tsv: '\t',
pipes: '|'
csv: ",",
ssv: " ",
tsv: "\t",
pipes: "|",
};
/**
@ -36,8 +37,8 @@ export const COLLECTION_FORMATS = {
* @interface RequestArgs
*/
export interface RequestArgs {
url: string;
options: AxiosRequestConfig;
url: string;
options: AxiosRequestConfig;
}
/**
@ -46,19 +47,15 @@ export interface RequestArgs {
* @class BaseAPI
*/
export class BaseAPI {
protected configuration: Configuration | undefined;
protected configuration: Configuration | undefined;
constructor(
configuration?: Configuration,
protected basePath: string = BASE_PATH,
protected axios: AxiosInstance = globalAxios
) {
if (configuration) {
this.configuration = configuration;
this.basePath = configuration.basePath || this.basePath;
}
}
}
constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) {
if (configuration) {
this.configuration = configuration;
this.basePath = configuration.basePath || this.basePath;
}
}
};
/**
*
@ -67,8 +64,8 @@ export class BaseAPI {
* @extends {Error}
*/
export class RequiredError extends Error {
name: 'RequiredError' = 'RequiredError';
constructor(public field: string, msg?: string) {
super(msg);
}
name: "RequiredError" = "RequiredError";
constructor(public field: string, msg?: string) {
super(msg);
}
}

View file

@ -5,166 +5,134 @@
* Immich API
*
* The version of the OpenAPI document: 1.17.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import { Configuration } from './configuration';
import { RequiredError, RequestArgs } from './base';
import { Configuration } from "./configuration";
import { RequiredError, RequestArgs } from "./base";
import { AxiosInstance, AxiosResponse } from 'axios';
/**
*
* @export
*/
export const DUMMY_BASE_URL = 'https://example.com';
export const DUMMY_BASE_URL = 'https://example.com'
/**
*
* @throws {RequiredError}
* @export
*/
export const assertParamExists = function (
functionName: string,
paramName: string,
paramValue: unknown
) {
if (paramValue === null || paramValue === undefined) {
throw new RequiredError(
paramName,
`Required parameter ${paramName} was null or undefined when calling ${functionName}.`
);
}
};
export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) {
if (paramValue === null || paramValue === undefined) {
throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`);
}
}
/**
*
* @export
*/
export const setApiKeyToObject = async function (
object: any,
keyParamName: string,
configuration?: Configuration
) {
if (configuration && configuration.apiKey) {
const localVarApiKeyValue =
typeof configuration.apiKey === 'function'
? await configuration.apiKey(keyParamName)
: await configuration.apiKey;
object[keyParamName] = localVarApiKeyValue;
}
};
export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) {
if (configuration && configuration.apiKey) {
const localVarApiKeyValue = typeof configuration.apiKey === 'function'
? await configuration.apiKey(keyParamName)
: await configuration.apiKey;
object[keyParamName] = localVarApiKeyValue;
}
}
/**
*
* @export
*/
export const setBasicAuthToObject = function (object: any, configuration?: Configuration) {
if (configuration && (configuration.username || configuration.password)) {
object['auth'] = { username: configuration.username, password: configuration.password };
}
};
if (configuration && (configuration.username || configuration.password)) {
object["auth"] = { username: configuration.username, password: configuration.password };
}
}
/**
*
* @export
*/
export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) {
if (configuration && configuration.accessToken) {
const accessToken =
typeof configuration.accessToken === 'function'
? await configuration.accessToken()
: await configuration.accessToken;
object['Authorization'] = 'Bearer ' + accessToken;
}
};
if (configuration && configuration.accessToken) {
const accessToken = typeof configuration.accessToken === 'function'
? await configuration.accessToken()
: await configuration.accessToken;
object["Authorization"] = "Bearer " + accessToken;
}
}
/**
*
* @export
*/
export const setOAuthToObject = async function (
object: any,
name: string,
scopes: string[],
configuration?: Configuration
) {
if (configuration && configuration.accessToken) {
const localVarAccessTokenValue =
typeof configuration.accessToken === 'function'
? await configuration.accessToken(name, scopes)
: await configuration.accessToken;
object['Authorization'] = 'Bearer ' + localVarAccessTokenValue;
}
};
export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) {
if (configuration && configuration.accessToken) {
const localVarAccessTokenValue = typeof configuration.accessToken === 'function'
? await configuration.accessToken(name, scopes)
: await configuration.accessToken;
object["Authorization"] = "Bearer " + localVarAccessTokenValue;
}
}
/**
*
* @export
*/
export const setSearchParams = function (url: URL, ...objects: any[]) {
const searchParams = new URLSearchParams(url.search);
for (const object of objects) {
for (const key in object) {
if (Array.isArray(object[key])) {
searchParams.delete(key);
for (const item of object[key]) {
searchParams.append(key, item);
}
} else {
searchParams.set(key, object[key]);
}
}
}
url.search = searchParams.toString();
};
const searchParams = new URLSearchParams(url.search);
for (const object of objects) {
for (const key in object) {
if (Array.isArray(object[key])) {
searchParams.delete(key);
for (const item of object[key]) {
searchParams.append(key, item);
}
} else {
searchParams.set(key, object[key]);
}
}
}
url.search = searchParams.toString();
}
/**
*
* @export
*/
export const serializeDataIfNeeded = function (
value: any,
requestOptions: any,
configuration?: Configuration
) {
const nonString = typeof value !== 'string';
const needsSerialization =
nonString && configuration && configuration.isJsonMime
? configuration.isJsonMime(requestOptions.headers['Content-Type'])
: nonString;
return needsSerialization ? JSON.stringify(value !== undefined ? value : {}) : value || '';
};
export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) {
const nonString = typeof value !== 'string';
const needsSerialization = nonString && configuration && configuration.isJsonMime
? configuration.isJsonMime(requestOptions.headers['Content-Type'])
: nonString;
return needsSerialization
? JSON.stringify(value !== undefined ? value : {})
: (value || "");
}
/**
*
* @export
*/
export const toPathString = function (url: URL) {
return url.pathname + url.search + url.hash;
};
return url.pathname + url.search + url.hash
}
/**
*
* @export
*/
export const createRequestFunction = function (
axiosArgs: RequestArgs,
globalAxios: AxiosInstance,
BASE_PATH: string,
configuration?: Configuration
) {
return <T = unknown, R = AxiosResponse<T>>(
axios: AxiosInstance = globalAxios,
basePath: string = BASE_PATH
) => {
const axiosRequestArgs = {
...axiosArgs.options,
url: (configuration?.basePath || basePath) + axiosArgs.url
};
return axios.request<T, R>(axiosRequestArgs);
};
};
export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) {
return <T = unknown, R = AxiosResponse<T>>(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => {
const axiosRequestArgs = {...axiosArgs.options, url: (configuration?.basePath || basePath) + axiosArgs.url};
return axios.request<T, R>(axiosRequestArgs);
};
}

View file

@ -5,117 +5,97 @@
* Immich API
*
* The version of the OpenAPI document: 1.17.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface ConfigurationParameters {
apiKey?:
| string
| Promise<string>
| ((name: string) => string)
| ((name: string) => Promise<string>);
username?: string;
password?: string;
accessToken?:
| string
| Promise<string>
| ((name?: string, scopes?: string[]) => string)
| ((name?: string, scopes?: string[]) => Promise<string>);
basePath?: string;
baseOptions?: any;
formDataCtor?: new () => any;
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
username?: string;
password?: string;
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
basePath?: string;
baseOptions?: any;
formDataCtor?: new () => any;
}
export class Configuration {
/**
* parameter for apiKey security
* @param name security name
* @memberof Configuration
*/
apiKey?:
| string
| Promise<string>
| ((name: string) => string)
| ((name: string) => Promise<string>);
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
username?: string;
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
password?: string;
/**
* parameter for oauth2 security
* @param name security name
* @param scopes oauth2 scope
* @memberof Configuration
*/
accessToken?:
| string
| Promise<string>
| ((name?: string, scopes?: string[]) => string)
| ((name?: string, scopes?: string[]) => Promise<string>);
/**
* override base path
*
* @type {string}
* @memberof Configuration
*/
basePath?: string;
/**
* base options for axios calls
*
* @type {any}
* @memberof Configuration
*/
baseOptions?: any;
/**
* The FormData constructor that will be used to create multipart form data
* requests. You can inject this here so that execution environments that
* do not support the FormData class can still run the generated client.
*
* @type {new () => FormData}
*/
formDataCtor?: new () => any;
/**
* parameter for apiKey security
* @param name security name
* @memberof Configuration
*/
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
username?: string;
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
password?: string;
/**
* parameter for oauth2 security
* @param name security name
* @param scopes oauth2 scope
* @memberof Configuration
*/
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
/**
* override base path
*
* @type {string}
* @memberof Configuration
*/
basePath?: string;
/**
* base options for axios calls
*
* @type {any}
* @memberof Configuration
*/
baseOptions?: any;
/**
* The FormData constructor that will be used to create multipart form data
* requests. You can inject this here so that execution environments that
* do not support the FormData class can still run the generated client.
*
* @type {new () => FormData}
*/
formDataCtor?: new () => any;
constructor(param: ConfigurationParameters = {}) {
this.apiKey = param.apiKey;
this.username = param.username;
this.password = param.password;
this.accessToken = param.accessToken;
this.basePath = param.basePath;
this.baseOptions = param.baseOptions;
this.formDataCtor = param.formDataCtor;
}
constructor(param: ConfigurationParameters = {}) {
this.apiKey = param.apiKey;
this.username = param.username;
this.password = param.password;
this.accessToken = param.accessToken;
this.basePath = param.basePath;
this.baseOptions = param.baseOptions;
this.formDataCtor = param.formDataCtor;
}
/**
* Check if the given MIME is a JSON MIME.
* JSON MIME examples:
* application/json
* application/json; charset=UTF8
* APPLICATION/JSON
* application/vnd.company+json
* @param mime - MIME (Multipurpose Internet Mail Extensions)
* @return True if the given MIME is JSON, false otherwise.
*/
public isJsonMime(mime: string): boolean {
const jsonMime: RegExp = new RegExp(
'^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$',
'i'
);
return (
mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json')
);
}
/**
* Check if the given MIME is a JSON MIME.
* JSON MIME examples:
* application/json
* application/json; charset=UTF8
* APPLICATION/JSON
* application/vnd.company+json
* @param mime - MIME (Multipurpose Internet Mail Extensions)
* @return True if the given MIME is JSON, false otherwise.
*/
public isJsonMime(mime: string): boolean {
const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i');
return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json');
}
}

View file

@ -5,12 +5,14 @@
* Immich API
*
* The version of the OpenAPI document: 1.17.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export * from './api';
export * from './configuration';
export * from "./api";
export * from "./configuration";

View file

@ -9,6 +9,8 @@
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
import { AssetResponseDto } from '@api';
import AlbumAppBar from './album-app-bar.svelte';
import { openFileUploadDialog, UploadType } from '$lib/utils/file-uploader';
import { albumUploadAssetStore } from '$lib/stores/album-upload-asset';
const dispatch = createEventDispatcher();
@ -19,7 +21,41 @@
let existingGroup: Set<number> = new Set();
let groupWithAssetsInAlbum: Record<number, Set<string>> = {};
onMount(() => scanForExistingSelectedGroup());
let uploadAssets: string[] = [];
let uploadAssetsCount = 9999;
onMount(() => {
scanForExistingSelectedGroup();
albumUploadAssetStore.asset.subscribe((uploadedAsset) => {
uploadAssets = uploadedAsset;
});
albumUploadAssetStore.count.subscribe((count) => {
uploadAssetsCount = count;
});
});
/**
* Watch for the uploading event - when the uploaded assets are the same number of the chosen asset
* navigate back and add them to the album
*/
$: {
if (uploadAssets.length == uploadAssetsCount) {
// Filter assets that are already in the album
const assetsToAdd = uploadAssets.filter(
(asset) => !assetsInAlbum.some((a) => a.id === asset)
);
// Add the just uploaded assets to the album
dispatch('create-album', {
assets: assetsToAdd
});
// Clean up states.
albumUploadAssetStore.asset.set([]);
albumUploadAssetStore.count.set(9999);
}
}
const selectAssetHandler = (assetId: string, groupIndex: number) => {
const tempSelectedAsset = new Set(selectedAsset);
@ -146,6 +182,12 @@
</svelte:fragment>
<svelte:fragment slot="trailing">
<button
on:click={() => openFileUploadDialog(UploadType.ALBUM)}
class="text-immich-primary text-sm hover:bg-immich-primary/10 transition-all px-6 py-2 rounded-lg font-medium"
>
Select from computer
</button>
<button
disabled={selectedAsset.size === 0}
on:click={addSelectedAssets}

View file

@ -0,0 +1,13 @@
import { writable } from 'svelte/store';
function createAlbumUploadStore() {
const albumUploadAsset = writable<Array<string>>([]);
const albumUploadAssetCount = writable<number>(9999);
return {
asset: albumUploadAsset,
count: albumUploadAssetCount
};
}
export const albumUploadAssetStore = createAlbumUploadStore();

View file

@ -3,9 +3,58 @@ import * as exifr from 'exifr';
import { serverEndpoint } from '../constants';
import { uploadAssetsStore } from '$lib/stores/upload';
import type { UploadAsset } from '../models/upload-asset';
import { api } from '@api';
import { api, AssetFileUploadResponseDto } from '@api';
import { albumUploadAssetStore } from '$lib/stores/album-upload-asset';
export async function fileUploader(asset: File) {
/**
* Determine if the upload is for album or for the user general backup
* @variant GENERAL - Upload assets to the server for general backup
* @variant ALBUM - Upload assets to the server for backup and add to the album
*/
export enum UploadType {
/**
* Upload assets to the server
*/
GENERAL = 'GENERAL',
/**
* Upload assets to the server and add to album
*/
ALBUM = 'ALBUM'
}
export const openFileUploadDialog = (uploadType: UploadType) => {
try {
let fileSelector = document.createElement('input');
fileSelector.type = 'file';
fileSelector.multiple = true;
fileSelector.accept = 'image/*,video/*,.heic,.heif';
fileSelector.onchange = async (e: any) => {
const files = Array.from<File>(e.target.files);
const acceptedFile = files.filter(
(e) => e.type.split('/')[0] === 'video' || e.type.split('/')[0] === 'image'
);
if (uploadType === UploadType.ALBUM) {
albumUploadAssetStore.asset.set([]);
albumUploadAssetStore.count.set(acceptedFile.length);
}
for (const asset of acceptedFile) {
await fileUploader(asset, uploadType);
}
};
fileSelector.click();
} catch (e) {
console.log('Error seelcting file', e);
}
};
async function fileUploader(asset: File, uploadType: UploadType) {
const assetType = asset.type.split('/')[0].toUpperCase();
const temp = asset.name.split('.');
const fileExtension = temp[temp.length - 1];
@ -61,6 +110,11 @@ export async function fileUploader(asset: File) {
if (status === 200) {
if (data.isExist) {
if (uploadType === UploadType.ALBUM && data.id) {
albumUploadAssetStore.asset.update((a) => {
return [...a, data.id!];
});
}
return;
}
}
@ -78,12 +132,26 @@ export async function fileUploader(asset: File) {
uploadAssetsStore.addNewUploadAsset(newUploadAsset);
};
request.upload.onload = () => {
request.upload.onload = (e) => {
setTimeout(() => {
uploadAssetsStore.removeUploadAsset(deviceAssetId);
}, 1000);
};
request.onreadystatechange = () => {
try {
if (request.readyState === 4 && uploadType === UploadType.ALBUM) {
const res: AssetFileUploadResponseDto = JSON.parse(request.response);
albumUploadAssetStore.asset.update((assets) => {
return [...assets, res.id];
});
}
} catch (e) {
console.error('ERROR parsing data JSON in upload onreadystatechange');
}
};
// listen for `error` event
request.upload.onerror = () => {
uploadAssetsStore.removeUploadAsset(deviceAssetId);

View file

@ -32,7 +32,7 @@
import ImmichThumbnail from '$lib/components/shared-components/immich-thumbnail.svelte';
import moment from 'moment';
import AssetViewer from '$lib/components/asset-viewer/asset-viewer.svelte';
import { fileUploader } from '$lib/utils/file-uploader';
import { openFileUploadDialog, UploadType } from '$lib/utils/file-uploader';
import { api, AssetResponseDto, UserResponseDto } from '@api';
import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte';
@ -64,32 +64,6 @@
pushState(selectedAsset.id);
};
const uploadClickedHandler = async () => {
try {
let fileSelector = document.createElement('input');
fileSelector.type = 'file';
fileSelector.multiple = true;
fileSelector.accept = 'image/*,video/*,.heic,.heif';
fileSelector.onchange = async (e: any) => {
const files = Array.from<File>(e.target.files);
const acceptedFile = files.filter(
(e) => e.type.split('/')[0] === 'video' || e.type.split('/')[0] === 'image'
);
for (const asset of acceptedFile) {
await fileUploader(asset);
}
};
fileSelector.click();
} catch (e) {
console.log('Error seelcting file', e);
}
};
const navigateAssetForward = () => {
try {
if (currentViewAssetIndex < $flattenAssetGroupByDate.length - 1) {
@ -131,7 +105,7 @@
</svelte:head>
<section>
<NavigationBar {user} on:uploadClicked={uploadClickedHandler} />
<NavigationBar {user} on:uploadClicked={() => openFileUploadDialog(UploadType.GENERAL)} />
</section>
<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg">