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": [ "collectCoverageFrom": [
"<rootDir>/src/**/*.(t|j)s" "<rootDir>/src/**/*.(t|j)s"
], ],
"moduleNameMapper": {
"^@api(|/.*)$": "<rootDir>/src/api/$1"
},
"coverageDirectory": "./coverage", "coverageDirectory": "./coverage",
"testEnvironment": "node" "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 { CrawlService, UploadService } from '../services';
import * as si from 'systeminformation'; import * as si from 'systeminformation';
import FormData from 'form-data'; import FormData from 'form-data';
@ -8,13 +8,13 @@ import { CrawlOptionsDto } from '../cores/dto/crawl-options-dto';
import cliProgress from 'cli-progress'; import cliProgress from 'cli-progress';
import byteSize from 'byte-size'; import byteSize from 'byte-size';
import { BaseCommand } from '../cli/base-command'; import { BaseCommand } from '../cli/base-command';
import { api } from 'src/api';
export default class Upload extends BaseCommand { export default class Upload extends BaseCommand {
private crawlService = new CrawlService(); private crawlService = new CrawlService();
private uploadService!: UploadService; private uploadService!: UploadService;
deviceId!: string; deviceId!: string;
uploadLength!: number; uploadLength!: number;
dryRun = false;
public async run(paths: string[], options: UploadOptionsDto): Promise<void> { public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
await this.connect(); await this.connect();
@ -23,8 +23,6 @@ export default class Upload extends BaseCommand {
this.deviceId = uuid.os || 'CLI'; this.deviceId = uuid.os || 'CLI';
this.uploadService = new UploadService(this.immichApi.apiConfiguration); this.uploadService = new UploadService(this.immichApi.apiConfiguration);
this.dryRun = options.dryRun;
const crawlOptions = new CrawlOptionsDto(); const crawlOptions = new CrawlOptionsDto();
crawlOptions.pathsToCrawl = paths; crawlOptions.pathsToCrawl = paths;
crawlOptions.recursive = options.recursive; crawlOptions.recursive = options.recursive;
@ -37,7 +35,7 @@ export default class Upload extends BaseCommand {
return; return;
} }
const assetsToUpload = crawledFiles.map((path) => new CrawledAsset(path)); const assetsToUpload = crawledFiles.map((path) => new Asset(path));
const uploadProgress = new cliProgress.SingleBar( const uploadProgress = new cliProgress.SingleBar(
{ {
@ -58,71 +56,21 @@ export default class Upload extends BaseCommand {
totalSize += asset.fileSize; totalSize += asset.fileSize;
} }
const existingAlbums = (await this.immichApi.albumApi.getAllAlbums()).data;
uploadProgress.start(totalSize, 0); uploadProgress.start(totalSize, 0);
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) }); uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
try {
for (const asset of assetsToUpload) { for (const asset of assetsToUpload) {
uploadProgress.update({ uploadProgress.update({
filename: asset.path, 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;
}
sizeSoFar += asset.fileSize;
if (!asset.skipped) {
totalSizeUploaded += asset.fileSize;
uploadCounter++;
}
uploadProgress.update(sizeSoFar, { value_formatted: byteSize(sizeSoFar) });
}
uploadProgress.stop();
let messageStart;
if (this.dryRun) {
messageStart = 'Would have';
} else {
messageStart = 'Successfully';
}
if (uploadCounter === 0) {
console.log('All assets were already uploaded, nothing to do.');
} else {
console.log(`${messageStart} uploaded ${uploadCounter} assets (${byteSize(totalSizeUploaded)})`);
}
if (options.delete) {
if (this.dryRun) {
console.log(`Would now have deleted assets, but skipped due to dry run`);
} else {
console.log('Deleting assets that have been uploaded...');
const deletionProgress = new cliProgress.SingleBar(cliProgress.Presets.shades_classic);
deletionProgress.start(crawledFiles.length, 0);
for (const asset of assetsToUpload) {
if (!this.dryRun) {
await asset.delete();
}
deletionProgress.increment();
}
deletionProgress.stop();
console.log('Deletion complete');
}
}
}
private async uploadAsset(asset: CrawledAsset, skipHash = false) {
await asset.readData(); await asset.readData();
let skipUpload = false; let skipUpload = false;
if (!skipHash) { if (!options.skipHash) {
const checksum = await asset.hash(); const checksum = await asset.hash();
const checkResponse = await this.uploadService.checkIfAssetAlreadyExists(asset.path, checksum); const checkResponse = await this.uploadService.checkIfAssetAlreadyExists(asset.path, checksum);
@ -148,8 +96,64 @@ export default class Upload extends BaseCommand {
}); });
} }
if (!this.dryRun) { if (!options.dryRun) {
await this.uploadService.upload(uploadFormData); 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) });
}
} finally {
uploadProgress.stop();
}
let messageStart;
if (options.dryRun) {
messageStart = 'Would have';
} else {
messageStart = 'Successfully';
}
if (uploadCounter === 0) {
console.log('All assets were already uploaded, nothing to do.');
} else {
console.log(`${messageStart} uploaded ${uploadCounter} assets (${byteSize(totalSizeUploaded)})`);
}
if (options.delete) {
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...');
const deletionProgress = new cliProgress.SingleBar(cliProgress.Presets.shades_classic);
deletionProgress.start(crawledFiles.length, 0);
for (const asset of assetsToUpload) {
if (!options.dryRun) {
await asset.delete();
}
deletionProgress.increment();
}
deletionProgress.stop();
console.log('Deletion complete');
} }
} }
} }

View file

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

View file

@ -2,7 +2,7 @@ import * as fs from 'fs';
import { basename } from 'node:path'; import { basename } from 'node:path';
import crypto from 'crypto'; import crypto from 'crypto';
export class CrawledAsset { export class Asset {
public path: string; public path: string;
public assetData?: fs.ReadStream; public assetData?: fs.ReadStream;
@ -13,6 +13,7 @@ export class CrawledAsset {
public sidecarPath?: string; public sidecarPath?: string;
public fileSize!: number; public fileSize!: number;
public skipped = false; public skipped = false;
public albumName?: string;
constructor(path: string) { constructor(path: string) {
this.path = path; this.path = path;
@ -28,6 +29,7 @@ export class CrawledAsset {
this.fileCreatedAt = stats.mtime.toISOString(); this.fileCreatedAt = stats.mtime.toISOString();
this.fileModifiedAt = stats.mtime.toISOString(); this.fileModifiedAt = stats.mtime.toISOString();
this.fileSize = stats.size; this.fileSize = stats.size;
this.albumName = this.extractAlbumName();
// TODO: doesn't xmp replace the file extension? Will need investigation // TODO: doesn't xmp replace the file extension? Will need investigation
const sideCarPath = `${this.path}.xmp`; const sideCarPath = `${this.path}.xmp`;
@ -55,4 +57,8 @@ export class CrawledAsset {
return await sha1(this.path); 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('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
.addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS')) .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('-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( .addOption(
new Option('-n, --dry-run', "Don't perform any actions, just show what will be done") new Option('-n, --dry-run', "Don't perform any actions, just show what will be done")
.env('IMMICH_DRY_RUN') .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 FormData from 'form-data';
import { ApiConfiguration } from '../cores/api-configuration'; import { ApiConfiguration } from '../cores/api-configuration';
import { AssetFileUploadResponseDto } from 'src/api/open-api';
export class UploadService { export class UploadService {
private readonly uploadConfig: AxiosRequestConfig<any>; private readonly uploadConfig: AxiosRequestConfig<any>;
@ -36,7 +37,7 @@ export class UploadService {
return axios(this.checkAssetExistenceConfig); return axios(this.checkAssetExistenceConfig);
} }
public upload(data: FormData) { public upload(data: FormData): Promise<AxiosResponse<AssetFileUploadResponseDto>> {
this.uploadConfig.data = data; this.uploadConfig.data = data;
// TODO: retry on 500 errors? // 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.access.requirePermission(authUser, Permission.ALBUM_READ, id);
await this.albumRepository.updateThumbnails(); await this.albumRepository.updateThumbnails();
return mapAlbum(await this.findOrFail(id, { withAssets: true }), !dto.withoutAssets); return mapAlbum(await this.findOrFail(id, { withAssets: true }), !dto.withoutAssets);

View file

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