Initial commit of yt2jellyfin-server project

This commit is contained in:
corenthin-lebreton 2026-02-25 22:54:49 +01:00
commit 92cdba42fb
16 changed files with 824 additions and 0 deletions

11
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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;

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

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

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

View 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
View 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
View 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/**/*"]
}