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:
Mathis HERRIOT
2026-01-14 12:11:39 +01:00
parent 9c45bf11e4
commit 514bd354bf
64 changed files with 1801 additions and 1295 deletions

View File

@@ -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 {}

View File

@@ -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: {

View File

@@ -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 });
}
}

View 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");
}
}
}

View 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>;
}

View 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(() => {});
}
}
}