feat: can create albums
This commit is contained in:
parent
02152082d1
commit
d68a9d2c5e
12 changed files with 123 additions and 74 deletions
|
@ -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
11
cli/src/api/album-api.ts
Normal 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
3
cli/src/api/auth.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export const auth = (accessToken: string) => ({
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
7
cli/src/api/index.ts
Normal file
7
cli/src/api/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { albumApi } from './album-api';
|
||||||
|
import { auth } from './auth';
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
albumApi,
|
||||||
|
auth,
|
||||||
|
};
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,4 +5,5 @@ export class UploadOptionsDto {
|
||||||
skipHash = false;
|
skipHash = false;
|
||||||
delete = false;
|
delete = false;
|
||||||
readOnly = true;
|
readOnly = true;
|
||||||
|
album = false;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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];
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1 +1 @@
|
||||||
export * from './crawled-asset';
|
export * from './asset';
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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?
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue