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