Skip to content

Commit dcff0b3

Browse files
committed
Refactor ffprobe to use signed url instead of piped object stream
I think this ends up being better in every way. NodeJS doesn't have to worry about piping streams, and the hangs that were occurring there. Additionally, even when the previous process worked, we got a detected duration of 0. This is some different with ffprobe. Input streams would give this, but files/urls are detected correctly. I upgraded the migration as well to re-detect any existing media that has a duration of 0.
1 parent 58fe08f commit dcff0b3

File tree

6 files changed

+68
-71
lines changed

6 files changed

+68
-71
lines changed

src/components/file/file.repository.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export class FileRepository extends CommonRepository {
154154
return result!;
155155
}
156156

157-
private hydrate() {
157+
hydrate() {
158158
return (query: Query) =>
159159
query
160160
.subQuery((sub) =>
@@ -463,13 +463,13 @@ export class FileRepository extends CommonRepository {
463463
}),
464464
)
465465
.apply(this.defaultPublicFromParent(input.public))
466-
.return<{ id: ID }>('node.id as id');
466+
.apply(this.hydrate());
467467

468468
const result = await createFile.first();
469469
if (!result) {
470470
throw new ServerException('Failed to create file version');
471471
}
472-
return result;
472+
return result.dto as FileVersion;
473473
}
474474

475475
async rename(fileNode: FileNode, newName: string): Promise<void> {

src/components/file/file.service.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
DurationIn,
1515
generateId,
1616
ID,
17-
IdOf,
1817
InputException,
1918
NotFoundException,
2019
ServerException,
@@ -318,7 +317,7 @@ export class FileService {
318317
const mimeType =
319318
mimeTypeOverride ?? upload?.ContentType ?? 'application/octet-stream';
320319

321-
await this.repo.createFileVersion(
320+
const fv = await this.repo.createFileVersion(
322321
fileId,
323322
{
324323
id: uploadId,
@@ -357,16 +356,7 @@ export class FileService {
357356
}
358357
}
359358

360-
await this.mediaService.detectAndSave(
361-
this.asDownloadable(
362-
{
363-
file: uploadId as IdOf<FileVersion>,
364-
mimeType,
365-
...media,
366-
},
367-
uploadId,
368-
),
369-
);
359+
await this.mediaService.detectAndSave(fv, media);
370360

371361
// Change the file's name to match the latest version name
372362
await this.rename({ id: fileId, name }, session);

src/components/file/media/detect-existing-media.migration.ts

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { ModuleRef } from '@nestjs/core';
22
import { node, relation } from 'cypher-query-builder';
3-
import { IdOf } from '~/common';
43
import { BaseMigration, Migration } from '~/core/database';
54
import { ACTIVE } from '~/core/database/query';
65
import { FileVersion } from '../dto';
7-
import { FileService } from '../file.service';
6+
import { FileRepository } from '../file.repository';
87
import { MediaService } from './media.service';
98

10-
@Migration('2023-08-24T15:00:00')
9+
@Migration('2023-09-01T18:00:00')
1110
export class DetectExistingMediaMigration extends BaseMigration {
1211
constructor(
1312
private readonly mediaService: MediaService,
@@ -17,12 +16,10 @@ export class DetectExistingMediaMigration extends BaseMigration {
1716
}
1817

1918
async up() {
20-
const fileService = this.moduleRef.get(FileService, { strict: false });
21-
const detect = async (f: Row) => {
19+
const detect = async (f: FileVersion) => {
2220
this.logger.info('Detecting', f);
2321
try {
24-
const d = fileService.asDownloadable(f, f.file);
25-
const result = await this.mediaService.detectAndSave(d);
22+
const result = await this.mediaService.detectAndSave(f);
2623
this.logger.info('Detected and saved media', {
2724
...f,
2825
...(result ?? {}),
@@ -39,26 +36,40 @@ export class DetectExistingMediaMigration extends BaseMigration {
3936
}
4037

4138
private async *grabFileVersionsToDetect(type: string) {
39+
const fileRepo = this.moduleRef.get(FileRepository, { strict: false });
40+
4241
let page = 0;
4342
const size = 100;
4443
do {
4544
this.logger.info(`Grabbing page of files to detect ${page}`);
4645

4746
const currentPage = await this.db
4847
.query()
49-
.match([
50-
node('fv', 'FileVersion'),
51-
relation('out', '', 'mimeType', ACTIVE),
52-
node('mt', 'Property'),
53-
])
54-
.raw(
55-
`where not (fv)-[:media]->(:Media)
56-
and mt.value starts with '${type}/'`,
48+
// eslint-disable-next-line no-loop-func
49+
.subQuery((sub) =>
50+
sub
51+
.match([
52+
node('fv', 'FileVersion'),
53+
relation('out', '', 'mimeType', ACTIVE),
54+
node('mt', 'Property'),
55+
])
56+
.optionalMatch([
57+
node('fv'),
58+
relation('out', '', 'media'),
59+
node('media', 'Media'),
60+
])
61+
.with('fv, mt, media')
62+
.raw(
63+
`where mt.value starts with '${type}/' and (media is null or media.duration = 0)`,
64+
)
65+
.return('fv')
66+
.orderBy('fv.createdAt')
67+
.skip(page * size)
68+
.limit(size),
5769
)
58-
.return<Row>('fv.id as file, mt.value as mimeType')
59-
.orderBy('fv.createdAt')
60-
.skip(page * size)
61-
.limit(size)
70+
.with('fv as node')
71+
.apply(fileRepo.hydrate())
72+
.map((row) => row.dto as FileVersion)
6273
.run();
6374

6475
if (currentPage.length === 0) {
@@ -70,8 +81,3 @@ export class DetectExistingMediaMigration extends BaseMigration {
7081
} while (true);
7182
}
7283
}
73-
74-
interface Row {
75-
file: IdOf<FileVersion>;
76-
mimeType: string;
77-
}

src/components/file/media/media-detector.service.ts

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,29 @@
1-
import ffprobeBinary from '@ffprobe-installer/ffprobe';
2-
import { Injectable } from '@nestjs/common';
1+
import { path as ffprobeBinary } from '@ffprobe-installer/ffprobe';
2+
import { forwardRef, Inject, Injectable } from '@nestjs/common';
33
import { execa } from 'execa';
44
import { FFProbeResult } from 'ffprobe';
55
import { imageSize } from 'image-size';
66
import { ISize as ImageSize } from 'image-size/dist/types/interface';
7-
import { Readable } from 'stream';
87
import { Except } from 'type-fest';
98
import { retry } from '~/common/retry';
109
import { ILogger, Logger } from '~/core';
11-
import { Downloadable } from '../dto';
10+
import { FileVersion } from '../dto';
11+
import { FileService } from '../file.service';
1212
import { AnyMedia, Media } from './media.dto';
1313

1414
@Injectable()
1515
export class MediaDetector {
16-
constructor(@Logger('media:detector') private readonly logger: ILogger) {}
16+
constructor(
17+
@Inject(forwardRef(() => FileService))
18+
private readonly files: FileService & {},
19+
@Logger('media:detector') private readonly logger: ILogger,
20+
) {}
1721

1822
async detect(
19-
file: Downloadable<{ mimeType: string }>,
23+
file: FileVersion,
2024
): Promise<Except<AnyMedia, Exclude<keyof Media, '__typename'>> | null> {
2125
if (file.mimeType.startsWith('image/')) {
22-
const buffer = await file.download();
26+
const buffer = await this.files.asDownloadable(file).download();
2327

2428
let size: ImageSize = { width: 0, height: 0 };
2529
try {
@@ -43,9 +47,9 @@ export class MediaDetector {
4347
return null;
4448
}
4549

46-
const stream = await file.stream();
50+
const url = await this.files.getDownloadUrl(file);
4751

48-
const result = await this.ffprobe(stream);
52+
const result = await this.ffprobe(url);
4953
const { width, height, duration: rawDuration } = result.streams?.[0] ?? {};
5054

5155
const d = rawDuration as string | number | undefined; // I've seen as string
@@ -64,35 +68,25 @@ export class MediaDetector {
6468
};
6569
}
6670

67-
private async ffprobe(stream: Readable): Promise<Partial<FFProbeResult>> {
71+
private async ffprobe(url: string): Promise<Partial<FFProbeResult>> {
6872
try {
6973
return await retry(
7074
async () => {
7175
const probe = await execa(
72-
ffprobeBinary.path,
76+
ffprobeBinary,
7377
[
78+
'-v',
79+
'error',
7480
'-print_format',
7581
'json',
7682
'-show_format',
7783
'-show_streams',
78-
'-i',
79-
'pipe:0',
84+
url,
8085
],
8186
{
82-
reject: false, // just return error instead of throwing
83-
input: stream,
8487
timeout: 10_000,
8588
},
8689
);
87-
this.logger.info('ffprobe result', probe);
88-
// Ffprobe stops reading stdin & exits as soon as it has enough info.
89-
// This causes the input stream to SIGPIPE error.
90-
// In shells, this is normal and does not result in an error.
91-
// However, NodeJS converts/interrupts this as an EPIPE error.
92-
// https://github.com/sindresorhus/execa/issues/474#issuecomment-1640423498
93-
if (probe instanceof Error && (probe as any).code !== 'EPIPE') {
94-
throw probe;
95-
}
9690
if (probe.stdout.trim() === '') {
9791
return {};
9892
}

src/components/file/media/media.module.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Module } from '@nestjs/common';
1+
import { forwardRef, Module } from '@nestjs/common';
2+
import { FileModule } from '../file.module';
23
import { DetectExistingMediaMigration } from './detect-existing-media.migration';
34
import { DimensionsResolver } from './dimensions.resolver';
45
import { MediaByFileVersionLoader } from './media-by-file-version.loader';
@@ -9,6 +10,7 @@ import { MediaResolver } from './media.resolver';
910
import { MediaService } from './media.service';
1011

1112
@Module({
13+
imports: [forwardRef(() => FileModule)],
1214
providers: [
1315
DimensionsResolver,
1416
MediaByFileVersionLoader,

src/components/file/media/media.service.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { Injectable } from '@nestjs/common';
2-
import { Except, RequireAtLeastOne } from 'type-fest';
3-
import { NotFoundException, ServerException } from '~/common';
4-
import { Downloadable } from '../dto';
2+
import { RequireAtLeastOne } from 'type-fest';
3+
import { IdOf, NotFoundException, ServerException } from '~/common';
4+
import { FileVersion } from '../dto';
55
import { MediaDetector } from './media-detector.service';
6-
import { AnyMedia, Media, MediaUserMetadata } from './media.dto';
6+
import { AnyMedia, MediaUserMetadata } from './media.dto';
77
import { MediaRepository } from './media.repository';
88

99
@Injectable()
@@ -13,12 +13,17 @@ export class MediaService {
1313
private readonly repo: MediaRepository,
1414
) {}
1515

16-
async detectAndSave(input: Downloadable<Except<Media, 'id' | '__typename'>>) {
17-
const media = await this.detector.detect(input);
16+
async detectAndSave(file: FileVersion, metadata?: MediaUserMetadata) {
17+
const media = await this.detector.detect(file);
1818
if (!media) {
1919
return null;
2020
}
21-
return await this.repo.create({ ...input, ...media });
21+
return await this.repo.create({
22+
file: file.id as IdOf<FileVersion>,
23+
mimeType: file.mimeType,
24+
...media,
25+
...metadata,
26+
});
2227
}
2328

2429
async updateUserMetadata(

0 commit comments

Comments
 (0)