Initial commit of yt2jellyfin-server project
This commit is contained in:
commit
92cdba42fb
11
.env.example
Normal file
11
.env.example
Normal file
@ -0,0 +1,11 @@
|
||||
# Configuration SSH pour le transfert vers Jellyfin
|
||||
SSH_PRIVATE_KEY_PATH=/path/to/your/private/key
|
||||
JELLYFIN_HOST=192.168.1.100
|
||||
JELLYFIN_SSH_PORT=22
|
||||
JELLYFIN_DESTINATION_PATH=/home/playlist
|
||||
|
||||
# Configuration CORS
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
# Dossier temporaire pour les téléchargements
|
||||
TEMP_DIR=./tmp
|
||||
149
README.md
Normal file
149
README.md
Normal file
@ -0,0 +1,149 @@
|
||||
# Yt2Jellyfin - Backend
|
||||
|
||||
API REST pour télécharger, convertir et transférer de la musique YouTube vers un serveur Jellyfin.
|
||||
|
||||
## Stack Technique
|
||||
|
||||
- **Bun** - Runtime JavaScript ultra-rapide
|
||||
- **Hono** - Framework web léger
|
||||
- **TypeScript** - Typage statique
|
||||
|
||||
## Prérequis Système
|
||||
|
||||
Les outils suivants doivent être installés sur le système:
|
||||
|
||||
- `yt-dlp` - Pour télécharger les vidéos YouTube
|
||||
- `ffmpeg` - Pour la conversion audio
|
||||
- `scp` - Pour le transfert SSH (généralement préinstallé)
|
||||
|
||||
### Installation des dépendances système
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
sudo apt update
|
||||
sudo apt install yt-dlp ffmpeg openssh-client
|
||||
|
||||
# Arch Linux
|
||||
sudo pacman -S yt-dlp ffmpeg openssh
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
1. Créez un fichier `.env` basé sur `.env.example`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Configurez les variables d'environnement:
|
||||
|
||||
```env
|
||||
SSH_PRIVATE_KEY_PATH=/path/to/your/ssh/key
|
||||
JELLYFIN_HOST=192.168.1.100
|
||||
JELLYFIN_SSH_PORT=22
|
||||
JELLYFIN_DESTINATION_PATH=/home/playlist
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
TEMP_DIR=./temp
|
||||
```
|
||||
|
||||
3. Assurez-vous que la clé SSH privée a les bonnes permissions:
|
||||
|
||||
```bash
|
||||
chmod 600 /path/to/your/ssh/key
|
||||
```
|
||||
|
||||
## Développement
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Le serveur sera disponible sur `http://localhost:3000`.
|
||||
|
||||
## Production
|
||||
|
||||
```bash
|
||||
bun run start
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST `/api/process`
|
||||
|
||||
Télécharge, convertit et transfère un fichier audio YouTube vers Jellyfin.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"filename": "ma-chanson",
|
||||
"format": "mp3"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (Success):**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Fichier téléchargé, converti et transféré avec succès",
|
||||
"filename": "ma-chanson.mp3"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (Error):**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "URL YouTube invalide",
|
||||
"details": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### GET `/health`
|
||||
|
||||
Vérifie l'état du serveur.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── config/ # Configuration
|
||||
│ └── env.ts
|
||||
├── controllers/ # Contrôleurs API
|
||||
│ └── process-controller.ts
|
||||
├── middleware/ # Middleware HTTP
|
||||
│ └── cors.ts
|
||||
├── services/ # Logique métier
|
||||
│ ├── youtube-downloader.ts
|
||||
│ └── file-transfer.ts
|
||||
├── types/ # Définitions TypeScript
|
||||
│ └── api.ts
|
||||
├── utils/ # Utilitaires
|
||||
│ └── validators.ts
|
||||
└── index.ts # Point d'entrée
|
||||
```
|
||||
|
||||
## Sécurité
|
||||
|
||||
- Validation stricte des URLs YouTube pour prévenir les injections de commandes
|
||||
- Sanitisation des noms de fichiers
|
||||
- Utilisation de clés SSH pour l'authentification
|
||||
- CORS configuré pour autoriser uniquement l'origine du frontend
|
||||
- Nettoyage automatique des fichiers temporaires
|
||||
26
bun.lock
Normal file
26
bun.lock
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "yt2jellyfin-server",
|
||||
"dependencies": {
|
||||
"hono": "^4.6.14",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||
|
||||
"@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
|
||||
"hono": ["hono@4.12.2", "", {}, "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
}
|
||||
}
|
||||
17
package.json
Normal file
17
package.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "yt2jellyfin-server",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"check": "bash scripts/check-prerequisites.sh",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.6.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
}
|
||||
}
|
||||
26
src/config/env.ts
Normal file
26
src/config/env.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { Config } from '../types/api';
|
||||
import {
|
||||
DEFAULT_SSH_PORT,
|
||||
DEFAULT_CORS_ORIGIN,
|
||||
DEFAULT_TEMP_DIR,
|
||||
DEFAULT_DESTINATION_PATH,
|
||||
} from '../constants/config';
|
||||
|
||||
function getEnvVariable(key: string, defaultValue?: string): string {
|
||||
const value = process.env[key];
|
||||
if (!value && !defaultValue) {
|
||||
throw new Error(`Variable d'environnement manquante: ${key}`);
|
||||
}
|
||||
return value || defaultValue!;
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
return {
|
||||
sshPrivateKeyPath: getEnvVariable('SSH_PRIVATE_KEY_PATH'),
|
||||
jellyfinHost: getEnvVariable('JELLYFIN_HOST'),
|
||||
jellyfinSshPort: parseInt(getEnvVariable('JELLYFIN_SSH_PORT', String(DEFAULT_SSH_PORT)), 10),
|
||||
corsOrigin: getEnvVariable('CORS_ORIGIN', DEFAULT_CORS_ORIGIN),
|
||||
tempDir: getEnvVariable('TEMP_DIR', DEFAULT_TEMP_DIR),
|
||||
jellyfinDestinationPath: getEnvVariable('JELLYFIN_DESTINATION_PATH', DEFAULT_DESTINATION_PATH),
|
||||
};
|
||||
}
|
||||
6
src/constants/config.ts
Normal file
6
src/constants/config.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export const DEFAULT_SSH_PORT = 22;
|
||||
export const DEFAULT_CORS_ORIGIN = 'http://localhost:5173';
|
||||
export const DEFAULT_TEMP_DIR = './temp';
|
||||
export const DEFAULT_DESTINATION_PATH = '/home/playlist';
|
||||
export const MAX_FILE_SIZE = '100M';
|
||||
export const SERVER_PORT = 3000;
|
||||
95
src/controllers/process-controller.ts
Normal file
95
src/controllers/process-controller.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { basename } from 'path';
|
||||
import type { Context } from 'hono';
|
||||
import type { ProcessRequest, ProcessResponse, ProcessError, Config } from '../types/api';
|
||||
import { validateProcessRequest, sanitizeFilename } from '../utils/validators';
|
||||
import { downloadAndConvertAudio } from '../services/youtube-downloader';
|
||||
import { transferFileToJellyfin, cleanupTempFile } from '../services/file-transfer';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export async function handleProcessRequest(
|
||||
c: Context,
|
||||
config: Config
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
|
||||
const validation = validateProcessRequest(body);
|
||||
if (!validation.isValid) {
|
||||
logger.warn('Requête invalide', { error: validation.error });
|
||||
const errorResponse: ProcessError = {
|
||||
success: false,
|
||||
error: validation.error!,
|
||||
};
|
||||
return c.json(errorResponse, 400);
|
||||
}
|
||||
|
||||
const request = body as ProcessRequest;
|
||||
logger.info('Nouvelle requête de traitement', {
|
||||
format: request.format,
|
||||
hasCustomFilename: !!request.filename
|
||||
});
|
||||
|
||||
const sanitizedFilename = request.filename
|
||||
? sanitizeFilename(request.filename)
|
||||
: undefined;
|
||||
|
||||
logger.info('Début du téléchargement et conversion');
|
||||
const downloadResult = await downloadAndConvertAudio(
|
||||
request.url,
|
||||
request.format,
|
||||
config.tempDir,
|
||||
sanitizedFilename
|
||||
);
|
||||
|
||||
if (!downloadResult.success || !downloadResult.filePath) {
|
||||
logger.error('Échec du téléchargement', { error: downloadResult.error });
|
||||
const errorResponse: ProcessError = {
|
||||
success: false,
|
||||
error: 'Échec du téléchargement',
|
||||
details: downloadResult.error,
|
||||
};
|
||||
return c.json(errorResponse, 500);
|
||||
}
|
||||
|
||||
logger.info('Téléchargement réussi', { filePath: downloadResult.filePath });
|
||||
|
||||
logger.info('Début du transfert vers Jellyfin');
|
||||
const transferResult = await transferFileToJellyfin(
|
||||
downloadResult.filePath,
|
||||
config
|
||||
);
|
||||
|
||||
if (!transferResult.success) {
|
||||
logger.error('Échec du transfert', { error: transferResult.error });
|
||||
await cleanupTempFile(downloadResult.filePath);
|
||||
const errorResponse: ProcessError = {
|
||||
success: false,
|
||||
error: 'Échec du transfert vers Jellyfin',
|
||||
details: transferResult.error,
|
||||
};
|
||||
return c.json(errorResponse, 500);
|
||||
}
|
||||
|
||||
logger.info('Transfert réussi, nettoyage en cours');
|
||||
await cleanupTempFile(downloadResult.filePath);
|
||||
|
||||
const filename = basename(downloadResult.filePath);
|
||||
logger.info('Traitement terminé avec succès', { filename });
|
||||
|
||||
const successResponse: ProcessResponse = {
|
||||
success: true,
|
||||
message: 'Fichier téléchargé, converti et transféré avec succès',
|
||||
filename,
|
||||
};
|
||||
|
||||
return c.json(successResponse, 200);
|
||||
} catch (error) {
|
||||
logger.error('Erreur interne', { error: error instanceof Error ? error.message : String(error) });
|
||||
const errorResponse: ProcessError = {
|
||||
success: false,
|
||||
error: 'Erreur interne du serveur',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
return c.json(errorResponse, 500);
|
||||
}
|
||||
}
|
||||
47
src/index.ts
Normal file
47
src/index.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import { mkdir } from 'fs/promises';
|
||||
import { loadConfig } from './config/env';
|
||||
import { createCorsMiddleware } from './middleware/cors';
|
||||
import { handleProcessRequest } from './controllers/process-controller';
|
||||
import { SERVER_PORT } from './constants/config';
|
||||
import { logger } from './utils/logger';
|
||||
|
||||
const PORT = SERVER_PORT;
|
||||
|
||||
async function initializeServer() {
|
||||
try {
|
||||
logger.info('Chargement de la configuration...');
|
||||
const config = loadConfig();
|
||||
|
||||
logger.info('Création du dossier temporaire...', { path: config.tempDir });
|
||||
await mkdir(config.tempDir, { recursive: true });
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.use('*', createCorsMiddleware(config.corsOrigin));
|
||||
|
||||
app.post('/api/process', (c: Context) => handleProcessRequest(c, config));
|
||||
|
||||
app.get('/health', (c: Context) => c.json({ status: 'ok' }));
|
||||
|
||||
logger.info('Serveur démarré avec succès', {
|
||||
port: PORT,
|
||||
tempDir: config.tempDir,
|
||||
jellyfinHost: config.jellyfinHost,
|
||||
corsOrigin: config.corsOrigin,
|
||||
});
|
||||
|
||||
return app;
|
||||
} catch (error) {
|
||||
logger.error('Échec de l\'initialisation du serveur', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const app = await initializeServer();
|
||||
|
||||
export default {
|
||||
port: PORT,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
19
src/middleware/cors.ts
Normal file
19
src/middleware/cors.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { Context, Next } from 'hono';
|
||||
|
||||
export function createCorsMiddleware(allowedOrigin: string) {
|
||||
return async (c: Context, next: Next) => {
|
||||
const origin = c.req.header('Origin');
|
||||
|
||||
if (origin === allowedOrigin) {
|
||||
c.header('Access-Control-Allow-Origin', allowedOrigin);
|
||||
c.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||
c.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||
}
|
||||
|
||||
if (c.req.method === 'OPTIONS') {
|
||||
return c.text('', 204);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
88
src/services/file-transfer.ts
Normal file
88
src/services/file-transfer.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { spawn } from 'bun';
|
||||
import { basename } from 'path';
|
||||
import type { Config } from '../types/api';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface TransferResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function transferFileToJellyfin(
|
||||
localFilePath: string,
|
||||
config: Config
|
||||
): Promise<TransferResult> {
|
||||
try {
|
||||
const file = Bun.file(localFilePath);
|
||||
if (!(await file.exists())) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Fichier local introuvable',
|
||||
};
|
||||
}
|
||||
|
||||
const filename = basename(localFilePath);
|
||||
const remotePath = `${config.jellyfinDestinationPath}/${filename}`;
|
||||
|
||||
logger.debug('Transfert SCP', {
|
||||
from: localFilePath,
|
||||
to: `root@${config.jellyfinHost}:${remotePath}`
|
||||
});
|
||||
|
||||
const args = [
|
||||
'-i', config.sshPrivateKeyPath,
|
||||
'-P', String(config.jellyfinSshPort),
|
||||
'-o', 'StrictHostKeyChecking=accept-new',
|
||||
'-o', 'BatchMode=yes',
|
||||
'-o', 'ConnectTimeout=30',
|
||||
localFilePath,
|
||||
`root@${config.jellyfinHost}:${remotePath}`,
|
||||
];
|
||||
|
||||
const proc = spawn({
|
||||
cmd: ['scp', ...args],
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
logger.error('Échec SCP', { exitCode, stderr });
|
||||
return {
|
||||
success: false,
|
||||
error: `Échec du transfert SCP (code ${exitCode})`,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Exception lors du transfert', { error });
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupTempFile(filePath: string): Promise<void> {
|
||||
try {
|
||||
const file = Bun.file(filePath);
|
||||
if (await file.exists()) {
|
||||
await unlink(filePath);
|
||||
logger.debug('Fichier temporaire supprimé', { filePath });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Erreur lors du nettoyage', { filePath, error });
|
||||
}
|
||||
}
|
||||
|
||||
async function unlink(path: string): Promise<void> {
|
||||
const proc = spawn({
|
||||
cmd: ['rm', '-f', path],
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
await proc.exited;
|
||||
}
|
||||
89
src/services/youtube-downloader.ts
Normal file
89
src/services/youtube-downloader.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { spawn } from 'bun';
|
||||
import { join } from 'path';
|
||||
import type { AudioFormat } from '../types/api';
|
||||
import { MAX_FILE_SIZE } from '../constants/config';
|
||||
|
||||
const AUDIO_QUALITY_BEST = '0';
|
||||
|
||||
export interface DownloadResult {
|
||||
success: boolean;
|
||||
filePath?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function downloadAndConvertAudio(
|
||||
url: string,
|
||||
format: AudioFormat,
|
||||
tempDir: string,
|
||||
customFilename?: string
|
||||
): Promise<DownloadResult> {
|
||||
try {
|
||||
const timestamp = Date.now();
|
||||
const safeFilename = customFilename
|
||||
? customFilename.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
: `${timestamp}_%(title)s`;
|
||||
|
||||
const outputTemplate = join(tempDir, `${safeFilename}.%(ext)s`);
|
||||
|
||||
const audioFormat = format === 'mp3' ? 'mp3' : 'flac';
|
||||
|
||||
const args = [
|
||||
'--extract-audio',
|
||||
'--audio-format', audioFormat,
|
||||
'--audio-quality', AUDIO_QUALITY_BEST,
|
||||
'--max-filesize', MAX_FILE_SIZE,
|
||||
'--output', outputTemplate,
|
||||
'--no-playlist',
|
||||
'--quiet',
|
||||
'--no-warnings',
|
||||
'--',
|
||||
url,
|
||||
];
|
||||
|
||||
const proc = spawn({
|
||||
cmd: ['yt-dlp', ...args],
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
return {
|
||||
success: false,
|
||||
error: `yt-dlp a échoué avec le code ${exitCode}`,
|
||||
};
|
||||
}
|
||||
|
||||
const expectedPath = customFilename
|
||||
? join(tempDir, `${safeFilename}.${format}`)
|
||||
: null;
|
||||
|
||||
if (expectedPath) {
|
||||
const file = Bun.file(expectedPath);
|
||||
if (await file.exists()) {
|
||||
return { success: true, filePath: expectedPath };
|
||||
}
|
||||
}
|
||||
|
||||
const files = await Array.fromAsync(
|
||||
new Bun.Glob(`*.${format}`).scan({ cwd: tempDir })
|
||||
);
|
||||
|
||||
if (files.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Aucun fichier audio trouvé après le téléchargement',
|
||||
};
|
||||
}
|
||||
|
||||
const latestFile = files.sort().pop()!;
|
||||
return { success: true, filePath: join(tempDir, latestFile) };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
28
src/types/api.ts
Normal file
28
src/types/api.ts
Normal file
@ -0,0 +1,28 @@
|
||||
export type AudioFormat = 'mp3' | 'flac';
|
||||
|
||||
export interface ProcessRequest {
|
||||
url: string;
|
||||
filename?: string;
|
||||
format: AudioFormat;
|
||||
}
|
||||
|
||||
export interface ProcessResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export interface ProcessError {
|
||||
success: false;
|
||||
error: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
sshPrivateKeyPath: string;
|
||||
jellyfinHost: string;
|
||||
jellyfinSshPort: number;
|
||||
corsOrigin: string;
|
||||
tempDir: string;
|
||||
jellyfinDestinationPath: string;
|
||||
}
|
||||
34
src/utils/logger.ts
Normal file
34
src/utils/logger.ts
Normal file
@ -0,0 +1,34 @@
|
||||
type LogLevel = 'info' | 'warn' | 'error' | 'debug';
|
||||
|
||||
const LOG_COLORS = {
|
||||
info: '\x1b[36m',
|
||||
warn: '\x1b[33m',
|
||||
error: '\x1b[31m',
|
||||
debug: '\x1b[90m',
|
||||
reset: '\x1b[0m',
|
||||
};
|
||||
|
||||
function formatTimestamp(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function log(level: LogLevel, message: string, data?: unknown): void {
|
||||
const timestamp = formatTimestamp();
|
||||
const color = LOG_COLORS[level];
|
||||
const reset = LOG_COLORS.reset;
|
||||
|
||||
const logMessage = `${color}[${timestamp}] [${level.toUpperCase()}]${reset} ${message}`;
|
||||
|
||||
if (data) {
|
||||
console.log(logMessage, data);
|
||||
} else {
|
||||
console.log(logMessage);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
info: (message: string, data?: unknown) => log('info', message, data),
|
||||
warn: (message: string, data?: unknown) => log('warn', message, data),
|
||||
error: (message: string, data?: unknown) => log('error', message, data),
|
||||
debug: (message: string, data?: unknown) => log('debug', message, data),
|
||||
};
|
||||
91
src/utils/validators.test.ts
Normal file
91
src/utils/validators.test.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { validateYoutubeUrl, validateFormat, sanitizeFilename } from './validators';
|
||||
|
||||
describe('validateYoutubeUrl', () => {
|
||||
test('accepte les URLs YouTube valides', () => {
|
||||
const validUrls = [
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
'https://youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
'http://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
'https://youtu.be/dQw4w9WgXcQ',
|
||||
'https://www.youtube.com/shorts/abc123',
|
||||
'youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
];
|
||||
|
||||
validUrls.forEach((url) => {
|
||||
const result = validateYoutubeUrl(url);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('rejette les URLs non-YouTube', () => {
|
||||
const invalidUrls = [
|
||||
'https://vimeo.com/123456',
|
||||
'https://google.com',
|
||||
'not a url',
|
||||
'',
|
||||
'ftp://youtube.com/watch?v=123',
|
||||
];
|
||||
|
||||
invalidUrls.forEach((url) => {
|
||||
const result = validateYoutubeUrl(url);
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('rejette les URLs avec caractères dangereux', () => {
|
||||
const dangerousUrls = [
|
||||
'https://youtube.com/watch?v=123; rm -rf /',
|
||||
'https://youtube.com/watch?v=123 | cat /etc/passwd',
|
||||
'https://youtube.com/watch?v=123`whoami`',
|
||||
'https://youtube.com/watch?v=123$USER',
|
||||
'https://youtube.com/watch?v=123 && echo hacked',
|
||||
];
|
||||
|
||||
dangerousUrls.forEach((url) => {
|
||||
const result = validateYoutubeUrl(url);
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFormat', () => {
|
||||
test('accepte les formats valides', () => {
|
||||
expect(validateFormat('mp3').isValid).toBe(true);
|
||||
expect(validateFormat('flac').isValid).toBe(true);
|
||||
});
|
||||
|
||||
test('rejette les formats invalides', () => {
|
||||
expect(validateFormat('wav').isValid).toBe(false);
|
||||
expect(validateFormat('ogg').isValid).toBe(false);
|
||||
expect(validateFormat('').isValid).toBe(false);
|
||||
expect(validateFormat(null).isValid).toBe(false);
|
||||
expect(validateFormat(123).isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeFilename', () => {
|
||||
test('supprime les caractères dangereux', () => {
|
||||
expect(sanitizeFilename('test<>file')).toBe('testfile');
|
||||
expect(sanitizeFilename('test:file')).toBe('testfile');
|
||||
expect(sanitizeFilename('test/file')).toBe('testfile');
|
||||
expect(sanitizeFilename('test\\file')).toBe('testfile');
|
||||
expect(sanitizeFilename('test|file')).toBe('testfile');
|
||||
expect(sanitizeFilename('test?file')).toBe('testfile');
|
||||
expect(sanitizeFilename('test*file')).toBe('testfile');
|
||||
});
|
||||
|
||||
test('remplace les espaces par des underscores', () => {
|
||||
expect(sanitizeFilename('my song name')).toBe('my_song_name');
|
||||
expect(sanitizeFilename('song with spaces')).toBe('song_with_spaces');
|
||||
});
|
||||
|
||||
test('limite la longueur à 255 caractères', () => {
|
||||
const longName = 'a'.repeat(300);
|
||||
expect(sanitizeFilename(longName).length).toBe(255);
|
||||
});
|
||||
|
||||
test('gère les points multiples', () => {
|
||||
expect(sanitizeFilename('file...name')).toBe('file.name');
|
||||
});
|
||||
});
|
||||
76
src/utils/validators.ts
Normal file
76
src/utils/validators.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import type { ProcessRequest, AudioFormat } from '../types/api';
|
||||
|
||||
const YOUTUBE_URL_REGEX = /^(https?:\/\/)?(www\.)?(youtube\.com\/(watch\?v=|shorts\/)|youtu\.be\/)[\w-]+/;
|
||||
const ALLOWED_FORMATS: AudioFormat[] = ['mp3', 'flac'];
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function validateYoutubeUrl(url: string): ValidationResult {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return { isValid: false, error: 'URL manquante ou invalide' };
|
||||
}
|
||||
|
||||
const trimmedUrl = url.trim();
|
||||
|
||||
if (!YOUTUBE_URL_REGEX.test(trimmedUrl)) {
|
||||
return { isValid: false, error: 'URL YouTube invalide' };
|
||||
}
|
||||
|
||||
if (trimmedUrl.includes(';') || trimmedUrl.includes('|') || trimmedUrl.includes('`') || trimmedUrl.includes('$')) {
|
||||
return { isValid: false, error: 'URL contient des caractères interdits' };
|
||||
}
|
||||
|
||||
if (trimmedUrl.includes('&&') || trimmedUrl.includes('||')) {
|
||||
return { isValid: false, error: 'URL contient des opérateurs shell interdits' };
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
export function validateFormat(format: unknown): ValidationResult {
|
||||
if (!format || typeof format !== 'string') {
|
||||
return { isValid: false, error: 'Format manquant ou invalide' };
|
||||
}
|
||||
|
||||
if (!ALLOWED_FORMATS.includes(format as AudioFormat)) {
|
||||
return { isValid: false, error: `Format non supporté. Utilisez: ${ALLOWED_FORMATS.join(', ')}` };
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
export function sanitizeFilename(filename: string): string {
|
||||
return filename
|
||||
.trim()
|
||||
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '')
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/\.+/g, '.')
|
||||
.substring(0, 255);
|
||||
}
|
||||
|
||||
export function validateProcessRequest(body: unknown): ValidationResult {
|
||||
if (!body || typeof body !== 'object') {
|
||||
return { isValid: false, error: 'Corps de requête invalide' };
|
||||
}
|
||||
|
||||
const request = body as Partial<ProcessRequest>;
|
||||
|
||||
const urlValidation = validateYoutubeUrl(request.url || '');
|
||||
if (!urlValidation.isValid) {
|
||||
return urlValidation;
|
||||
}
|
||||
|
||||
const formatValidation = validateFormat(request.format);
|
||||
if (!formatValidation.isValid) {
|
||||
return formatValidation;
|
||||
}
|
||||
|
||||
if (request.filename && typeof request.filename !== 'string') {
|
||||
return { isValid: false, error: 'Le nom de fichier doit être une chaîne de caractères' };
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"lib": ["ESNext"],
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user