feat: add modular services and repositories for improved code organization
Introduce repository pattern across multiple services, including `favorites`, `tags`, `sessions`, `reports`, `auth`, and more. Decouple crypto functionalities into modular services like `HashingService`, `JwtService`, and `EncryptionService`. Improve testability and maintainability by simplifying dependencies and consolidating utility logic.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MediaService } from "./media.service";
|
||||
import { ImageProcessorStrategy } from "./strategies/image-processor.strategy";
|
||||
import { VideoProcessorStrategy } from "./strategies/video-processor.strategy";
|
||||
|
||||
@Module({
|
||||
providers: [MediaService],
|
||||
providers: [MediaService, ImageProcessorStrategy, VideoProcessorStrategy],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
|
||||
@@ -6,6 +6,9 @@ import ffmpeg from "fluent-ffmpeg";
|
||||
import sharp from "sharp";
|
||||
import { MediaService } from "./media.service";
|
||||
|
||||
import { ImageProcessorStrategy } from "./strategies/image-processor.strategy";
|
||||
import { VideoProcessorStrategy } from "./strategies/video-processor.strategy";
|
||||
|
||||
jest.mock("sharp");
|
||||
jest.mock("fluent-ffmpeg");
|
||||
jest.mock("node:fs/promises");
|
||||
@@ -29,6 +32,8 @@ describe("MediaService", () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
MediaService,
|
||||
ImageProcessorStrategy,
|
||||
VideoProcessorStrategy,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import * as NodeClam from "clamscan";
|
||||
import ffmpeg from "fluent-ffmpeg";
|
||||
import sharp from "sharp";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type {
|
||||
IMediaService,
|
||||
MediaProcessingResult,
|
||||
ScanResult,
|
||||
} from "./interfaces/media.interface";
|
||||
} from "../common/interfaces/media.interface";
|
||||
import { ImageProcessorStrategy } from "./strategies/image-processor.strategy";
|
||||
import { VideoProcessorStrategy } from "./strategies/video-processor.strategy";
|
||||
|
||||
interface ClamScanner {
|
||||
scanStream(
|
||||
@@ -25,12 +21,16 @@ interface ClamScanner {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
export class MediaService implements IMediaService {
|
||||
private readonly logger = new Logger(MediaService.name);
|
||||
private clamscan: ClamScanner | null = null;
|
||||
private isClamAvInitialized = false;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly imageProcessor: ImageProcessorStrategy,
|
||||
private readonly videoProcessor: VideoProcessorStrategy,
|
||||
) {
|
||||
this.initClamScan();
|
||||
}
|
||||
|
||||
@@ -84,82 +84,13 @@ export class MediaService {
|
||||
buffer: Buffer,
|
||||
format: "webp" | "avif" = "webp",
|
||||
): Promise<MediaProcessingResult> {
|
||||
try {
|
||||
let pipeline = sharp(buffer);
|
||||
const metadata = await pipeline.metadata();
|
||||
|
||||
if (format === "webp") {
|
||||
pipeline = pipeline.webp({ quality: 80, effort: 6 });
|
||||
} else {
|
||||
pipeline = pipeline.avif({ quality: 65, effort: 6 });
|
||||
}
|
||||
|
||||
const processedBuffer = await pipeline.toBuffer();
|
||||
|
||||
return {
|
||||
buffer: processedBuffer,
|
||||
mimeType: `image/${format}`,
|
||||
extension: format,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
size: processedBuffer.length,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Error processing image: ${error.message}`);
|
||||
throw new BadRequestException("Failed to process image");
|
||||
}
|
||||
return this.imageProcessor.process(buffer, { format });
|
||||
}
|
||||
|
||||
async processVideo(
|
||||
buffer: Buffer,
|
||||
format: "webm" | "av1" = "webm",
|
||||
): Promise<MediaProcessingResult> {
|
||||
const tempInput = join(tmpdir(), `${uuidv4()}.tmp`);
|
||||
const tempOutput = join(
|
||||
tmpdir(),
|
||||
`${uuidv4()}.${format === "av1" ? "mp4" : "webm"}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await writeFile(tempInput, buffer);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let command = ffmpeg(tempInput);
|
||||
|
||||
if (format === "webm") {
|
||||
command = command
|
||||
.toFormat("webm")
|
||||
.videoCodec("libvpx-vp9")
|
||||
.audioCodec("libopus")
|
||||
.outputOptions("-crf 30", "-b:v 0");
|
||||
} else {
|
||||
command = command
|
||||
.toFormat("mp4")
|
||||
.videoCodec("libaom-av1")
|
||||
.audioCodec("libopus")
|
||||
.outputOptions("-crf 34", "-b:v 0", "-strict experimental");
|
||||
}
|
||||
|
||||
command
|
||||
.on("end", () => resolve())
|
||||
.on("error", (err) => reject(err))
|
||||
.save(tempOutput);
|
||||
});
|
||||
|
||||
const processedBuffer = await readFile(tempOutput);
|
||||
|
||||
return {
|
||||
buffer: processedBuffer,
|
||||
mimeType: format === "av1" ? "video/mp4" : "video/webm",
|
||||
extension: format === "av1" ? "mp4" : "webm",
|
||||
size: processedBuffer.length,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Error processing video: ${error.message}`);
|
||||
throw new BadRequestException("Failed to process video");
|
||||
} finally {
|
||||
await unlink(tempInput).catch(() => {});
|
||||
await unlink(tempOutput).catch(() => {});
|
||||
}
|
||||
return this.videoProcessor.process(buffer, { format });
|
||||
}
|
||||
}
|
||||
|
||||
44
backend/src/media/strategies/image-processor.strategy.ts
Normal file
44
backend/src/media/strategies/image-processor.strategy.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import sharp from "sharp";
|
||||
import type { MediaProcessingResult } from "../../common/interfaces/media.interface";
|
||||
import type { IMediaProcessorStrategy } from "./media-processor.strategy";
|
||||
|
||||
@Injectable()
|
||||
export class ImageProcessorStrategy implements IMediaProcessorStrategy {
|
||||
private readonly logger = new Logger(ImageProcessorStrategy.name);
|
||||
|
||||
canHandle(mimeType: string): boolean {
|
||||
return mimeType.startsWith("image/");
|
||||
}
|
||||
|
||||
async process(
|
||||
buffer: Buffer,
|
||||
options: { format: "webp" | "avif" } = { format: "webp" },
|
||||
): Promise<MediaProcessingResult> {
|
||||
try {
|
||||
const { format } = options;
|
||||
let pipeline = sharp(buffer);
|
||||
const metadata = await pipeline.metadata();
|
||||
|
||||
if (format === "webp") {
|
||||
pipeline = pipeline.webp({ quality: 80, effort: 6 });
|
||||
} else {
|
||||
pipeline = pipeline.avif({ quality: 65, effort: 6 });
|
||||
}
|
||||
|
||||
const processedBuffer = await pipeline.toBuffer();
|
||||
|
||||
return {
|
||||
buffer: processedBuffer,
|
||||
mimeType: `image/${format}`,
|
||||
extension: format,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
size: processedBuffer.length,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Error processing image: ${error.message}`);
|
||||
throw new BadRequestException("Failed to process image");
|
||||
}
|
||||
}
|
||||
}
|
||||
6
backend/src/media/strategies/media-processor.strategy.ts
Normal file
6
backend/src/media/strategies/media-processor.strategy.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { MediaProcessingResult } from "../../common/interfaces/media.interface";
|
||||
|
||||
export interface IMediaProcessorStrategy {
|
||||
canHandle(mimeType: string): boolean;
|
||||
process(buffer: Buffer, options?: any): Promise<MediaProcessingResult>;
|
||||
}
|
||||
71
backend/src/media/strategies/video-processor.strategy.ts
Normal file
71
backend/src/media/strategies/video-processor.strategy.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import ffmpeg from "fluent-ffmpeg";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { MediaProcessingResult } from "../../common/interfaces/media.interface";
|
||||
import type { IMediaProcessorStrategy } from "./media-processor.strategy";
|
||||
|
||||
@Injectable()
|
||||
export class VideoProcessorStrategy implements IMediaProcessorStrategy {
|
||||
private readonly logger = new Logger(VideoProcessorStrategy.name);
|
||||
|
||||
canHandle(mimeType: string): boolean {
|
||||
return mimeType.startsWith("video/");
|
||||
}
|
||||
|
||||
async process(
|
||||
buffer: Buffer,
|
||||
options: { format: "webm" | "av1" } = { format: "webm" },
|
||||
): Promise<MediaProcessingResult> {
|
||||
const { format } = options;
|
||||
const tempInput = join(tmpdir(), `${uuidv4()}.tmp`);
|
||||
const tempOutput = join(
|
||||
tmpdir(),
|
||||
`${uuidv4()}.${format === "av1" ? "mp4" : "webm"}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await writeFile(tempInput, buffer);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let command = ffmpeg(tempInput);
|
||||
|
||||
if (format === "webm") {
|
||||
command = command
|
||||
.toFormat("webm")
|
||||
.videoCodec("libvpx-vp9")
|
||||
.audioCodec("libopus")
|
||||
.outputOptions("-crf 30", "-b:v 0");
|
||||
} else {
|
||||
command = command
|
||||
.toFormat("mp4")
|
||||
.videoCodec("libaom-av1")
|
||||
.audioCodec("libopus")
|
||||
.outputOptions("-crf 34", "-b:v 0", "-strict experimental");
|
||||
}
|
||||
|
||||
command
|
||||
.on("end", () => resolve())
|
||||
.on("error", (err) => reject(err))
|
||||
.save(tempOutput);
|
||||
});
|
||||
|
||||
const processedBuffer = await readFile(tempOutput);
|
||||
|
||||
return {
|
||||
buffer: processedBuffer,
|
||||
mimeType: format === "av1" ? "video/mp4" : "video/webm",
|
||||
extension: format === "av1" ? "mp4" : "webm",
|
||||
size: processedBuffer.length,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Error processing video: ${error.message}`);
|
||||
throw new BadRequestException("Failed to process video");
|
||||
} finally {
|
||||
await unlink(tempInput).catch(() => {});
|
||||
await unlink(tempOutput).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user