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": [
|
||||
"<rootDir>/src/**/*.(t|j)s"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^@api(|/.*)$": "<rootDir>/src/api/$1"
|
||||
},
|
||||
"coverageDirectory": "./coverage",
|
||||
"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 * 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,4 +5,5 @@ export class UploadOptionsDto {
|
|||
skipHash = false;
|
||||
delete = false;
|
||||
readOnly = true;
|
||||
album = false;
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
}
|
|
@ -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('-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')
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue