feat: can create albums

This commit is contained in:
Jonathan Jogenfors 2023-11-11 00:43:43 +01:00
parent 02152082d1
commit d68a9d2c5e
12 changed files with 123 additions and 74 deletions

View file

@ -67,6 +67,9 @@
"collectCoverageFrom": [
"<rootDir>/src/**/*.(t|j)s"
],
"moduleNameMapper": {
"^@api(|/.*)$": "<rootDir>/src/api/$1"
},
"coverageDirectory": "./coverage",
"testEnvironment": "node"
}

11
cli/src/api/album-api.ts Normal file
View file

@ -0,0 +1,11 @@
import axios from 'axios';
import { AlbumResponseDto, CreateAlbumDto } from './open-api';
import { auth } from './auth';
export const albumApi = {
createAlbum: async (server: any, accessToken: string, dto: CreateAlbumDto) => {
const res = await axios.post(`${server}/album`, dto, auth(accessToken));
return res.data as AlbumResponseDto;
},
};

3
cli/src/api/auth.ts Normal file
View file

@ -0,0 +1,3 @@
export const auth = (accessToken: string) => ({
headers: { Authorization: `Bearer ${accessToken}` },
});

7
cli/src/api/index.ts Normal file
View file

@ -0,0 +1,7 @@
import { albumApi } from './album-api';
import { auth } from './auth';
export const api = {
albumApi,
auth,
};

View file

@ -1,4 +1,4 @@
import { CrawledAsset } from '../cores/models/crawled-asset';
import { Asset } from '../cores/models/asset';
import { CrawlService, UploadService } from '../services';
import * as si from 'systeminformation';
import FormData from 'form-data';
@ -8,13 +8,13 @@ import { CrawlOptionsDto } from '../cores/dto/crawl-options-dto';
import cliProgress from 'cli-progress';
import byteSize from 'byte-size';
import { BaseCommand } from '../cli/base-command';
import { api } from 'src/api';
export default class Upload extends BaseCommand {
private crawlService = new CrawlService();
private uploadService!: UploadService;
deviceId!: string;
uploadLength!: number;
dryRun = false;
public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
await this.connect();
@ -23,8 +23,6 @@ export default class Upload extends BaseCommand {
this.deviceId = uuid.os || 'CLI';
this.uploadService = new UploadService(this.immichApi.apiConfiguration);
this.dryRun = options.dryRun;
const crawlOptions = new CrawlOptionsDto();
crawlOptions.pathsToCrawl = paths;
crawlOptions.recursive = options.recursive;
@ -37,7 +35,7 @@ export default class Upload extends BaseCommand {
return;
}
const assetsToUpload = crawledFiles.map((path) => new CrawledAsset(path));
const assetsToUpload = crawledFiles.map((path) => new Asset(path));
const uploadProgress = new cliProgress.SingleBar(
{
@ -58,36 +56,78 @@ export default class Upload extends BaseCommand {
totalSize += asset.fileSize;
}
const existingAlbums = (await this.immichApi.albumApi.getAllAlbums()).data;
uploadProgress.start(totalSize, 0);
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
for (const asset of assetsToUpload) {
uploadProgress.update({
filename: asset.path,
});
try {
for (const asset of assetsToUpload) {
uploadProgress.update({
filename: asset.path,
});
try {
await this.uploadAsset(asset, options.skipHash);
} catch (error) {
// Immediately halt on an upload error
// TODO: In the future, we might retry and do exponential backoff for certain errors
uploadProgress.stop();
throw error;
await asset.readData();
let skipUpload = false;
if (!options.skipHash) {
const checksum = await asset.hash();
const checkResponse = await this.uploadService.checkIfAssetAlreadyExists(asset.path, checksum);
skipUpload = checkResponse.data.results[0].action === 'reject';
}
if (skipUpload) {
asset.skipped = true;
} else {
const uploadFormData = new FormData();
uploadFormData.append('deviceAssetId', asset.deviceAssetId);
uploadFormData.append('deviceId', this.deviceId);
uploadFormData.append('fileCreatedAt', asset.fileCreatedAt);
uploadFormData.append('fileModifiedAt', asset.fileModifiedAt);
uploadFormData.append('isFavorite', String(false));
uploadFormData.append('assetData', asset.assetData, { filename: asset.path });
if (asset.sidecarData) {
uploadFormData.append('sidecarData', asset.sidecarData, {
filename: asset.sidecarPath,
contentType: 'application/xml',
});
}
if (!options.dryRun) {
const res = await this.uploadService.upload(uploadFormData);
if (options.album && asset.albumName) {
let album = existingAlbums.find((album) => album.albumName === asset.albumName);
if (!album) {
const res = await this.immichApi.albumApi.createAlbum({
createAlbumDto: { albumName: asset.albumName },
});
album = res.data;
existingAlbums.push(album);
}
await this.immichApi.albumApi.addAssetsToAlbum({ id: album.id, bulkIdsDto: { ids: [res.data.id] } });
}
}
}
sizeSoFar += asset.fileSize;
if (!asset.skipped) {
totalSizeUploaded += asset.fileSize;
uploadCounter++;
}
uploadProgress.update(sizeSoFar, { value_formatted: byteSize(sizeSoFar) });
}
sizeSoFar += asset.fileSize;
if (!asset.skipped) {
totalSizeUploaded += asset.fileSize;
uploadCounter++;
}
uploadProgress.update(sizeSoFar, { value_formatted: byteSize(sizeSoFar) });
} finally {
uploadProgress.stop();
}
uploadProgress.stop();
let messageStart;
if (this.dryRun) {
if (options.dryRun) {
messageStart = 'Would have';
} else {
messageStart = 'Successfully';
@ -99,7 +139,7 @@ export default class Upload extends BaseCommand {
console.log(`${messageStart} uploaded ${uploadCounter} assets (${byteSize(totalSizeUploaded)})`);
}
if (options.delete) {
if (this.dryRun) {
if (options.dryRun) {
console.log(`Would now have deleted assets, but skipped due to dry run`);
} else {
console.log('Deleting assets that have been uploaded...');
@ -107,7 +147,7 @@ export default class Upload extends BaseCommand {
deletionProgress.start(crawledFiles.length, 0);
for (const asset of assetsToUpload) {
if (!this.dryRun) {
if (!options.dryRun) {
await asset.delete();
}
deletionProgress.increment();
@ -117,40 +157,4 @@ export default class Upload extends BaseCommand {
}
}
}
private async uploadAsset(asset: CrawledAsset, skipHash = false) {
await asset.readData();
let skipUpload = false;
if (!skipHash) {
const checksum = await asset.hash();
const checkResponse = await this.uploadService.checkIfAssetAlreadyExists(asset.path, checksum);
skipUpload = checkResponse.data.results[0].action === 'reject';
}
if (skipUpload) {
asset.skipped = true;
} else {
const uploadFormData = new FormData();
uploadFormData.append('deviceAssetId', asset.deviceAssetId);
uploadFormData.append('deviceId', this.deviceId);
uploadFormData.append('fileCreatedAt', asset.fileCreatedAt);
uploadFormData.append('fileModifiedAt', asset.fileModifiedAt);
uploadFormData.append('isFavorite', String(false));
uploadFormData.append('assetData', asset.assetData, { filename: asset.path });
if (asset.sidecarData) {
uploadFormData.append('sidecarData', asset.sidecarData, {
filename: asset.sidecarPath,
contentType: 'application/xml',
});
}
if (!this.dryRun) {
await this.uploadService.upload(uploadFormData);
}
}
}
}

View file

@ -5,4 +5,5 @@ export class UploadOptionsDto {
skipHash = false;
delete = false;
readOnly = true;
album = false;
}

View file

@ -2,7 +2,7 @@ import * as fs from 'fs';
import { basename } from 'node:path';
import crypto from 'crypto';
export class CrawledAsset {
export class Asset {
public path: string;
public assetData?: fs.ReadStream;
@ -13,6 +13,7 @@ export class CrawledAsset {
public sidecarPath?: string;
public fileSize!: number;
public skipped = false;
public albumName?: string;
constructor(path: string) {
this.path = path;
@ -28,6 +29,7 @@ export class CrawledAsset {
this.fileCreatedAt = stats.mtime.toISOString();
this.fileModifiedAt = stats.mtime.toISOString();
this.fileSize = stats.size;
this.albumName = this.extractAlbumName();
// TODO: doesn't xmp replace the file extension? Will need investigation
const sideCarPath = `${this.path}.xmp`;
@ -55,4 +57,8 @@ export class CrawledAsset {
return await sha1(this.path);
}
private extractAlbumName(): string {
return this.path.split('/').slice(-2)[0];
}
}

View file

@ -1 +1 @@
export * from './crawled-asset';
export * from './asset';

View file

@ -15,6 +15,11 @@ program
.addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
.addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS'))
.addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false))
.addOption(
new Option('-a, --album', 'Automatically create albums based on folder name')
.env('IMMICH_AUTO_CREATE_ALBUM')
.default(false),
)
.addOption(
new Option('-n, --dry-run', "Don't perform any actions, just show what will be done")
.env('IMMICH_DRY_RUN')

View file

@ -1,6 +1,7 @@
import axios, { AxiosRequestConfig } from 'axios';
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
import FormData from 'form-data';
import { ApiConfiguration } from '../cores/api-configuration';
import { AssetFileUploadResponseDto } from 'src/api/open-api';
export class UploadService {
private readonly uploadConfig: AxiosRequestConfig<any>;
@ -36,7 +37,7 @@ export class UploadService {
return axios(this.checkAssetExistenceConfig);
}
public upload(data: FormData) {
public upload(data: FormData): Promise<AxiosResponse<AssetFileUploadResponseDto>> {
this.uploadConfig.data = data;
// TODO: retry on 500 errors?

View file

@ -87,7 +87,7 @@ export class AlbumService {
);
}
async get(authUser: AuthUserDto, id: string, dto: AlbumInfoDto) {
async get(authUser: AuthUserDto, id: string, dto: AlbumInfoDto): Promise<AlbumResponseDto> {
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
await this.albumRepository.updateThumbnails();
return mapAlbum(await this.findOrFail(id, { withAssets: true }), !dto.withoutAssets);

View file

@ -31,23 +31,31 @@ export class AlbumController {
}
@Get()
getAllAlbums(@AuthUser() authUser: AuthUserDto, @Query() query: GetAlbumsDto) {
getAllAlbums(@AuthUser() authUser: AuthUserDto, @Query() query: GetAlbumsDto): Promise<AlbumResponseDto[]> {
return this.service.getAll(authUser, query);
}
@Post()
createAlbum(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateDto) {
createAlbum(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateDto): Promise<AlbumResponseDto> {
return this.service.create(authUser, dto);
}
@SharedLinkRoute()
@Get(':id')
getAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Query() dto: AlbumInfoDto) {
getAlbumInfo(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Query() dto: AlbumInfoDto,
): Promise<AlbumResponseDto> {
return this.service.get(authUser, id, dto);
}
@Patch(':id')
updateAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateDto) {
updateAlbumInfo(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: UpdateDto,
): Promise<AlbumResponseDto> {
return this.service.update(authUser, id, dto);
}