first commit

This commit is contained in:
corenthin-lebreton 2026-02-25 23:38:27 +01:00
commit 45d5343d61
22 changed files with 759 additions and 0 deletions

1
.env.example Normal file
View File

@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:3000

147
Dockerfile Normal file
View File

@ -0,0 +1,147 @@
# Stage 1: Build
FROM node:20-alpine AS builder
# Métadonnées
LABEL maintainer="Yt2Jellyfin"
LABEL description="Frontend React pour Yt2Jellyfin"
# Définir le répertoire de travail
WORKDIR /app
# Copier les fichiers de dépendances
COPY package*.json ./
# Installer les dépendances (avec cache npm)
RUN npm ci --only=production=false
# Copier le code source
COPY . .
# Build de l'application
RUN npm run build
# Stage 2: Production avec Nginx
FROM nginx:1.25-alpine AS production
# Installer des outils de sécurité et nettoyer
RUN apk add --no-cache \
ca-certificates \
tzdata \
&& rm -rf /var/cache/apk/*
# Créer un utilisateur non-root
RUN addgroup -g 1001 -S nginx-app && \
adduser -S -D -H -u 1001 -h /var/cache/nginx -s /sbin/nologin -G nginx-app -g nginx-app nginx-app
# Copier les fichiers buildés depuis le stage builder
COPY --from=builder --chown=nginx-app:nginx-app /app/dist /usr/share/nginx/html
# Configuration Nginx sécurisée
COPY --chown=nginx-app:nginx-app <<EOF /etc/nginx/nginx.conf
user nginx-app;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '\$remote_addr - \$remote_user [\$time_local] "\$request" '
'\$status \$body_bytes_sent "\$http_referer" '
'"\$http_user_agent" "\$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 10M;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml font/truetype font/opentype application/vnd.ms-fontobject image/svg+xml;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Désactiver les tokens de version Nginx
server_tokens off;
# Logs
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# Gestion des assets statiques avec cache
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA routing - toutes les routes vers index.html
location / {
try_files \$uri \$uri/ /index.html;
add_header Cache-Control "no-cache";
}
# Proxy vers le backend (optionnel, si sur même réseau)
location /api {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_cache_bypass \$http_upgrade;
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
# Bloquer l'accès aux fichiers sensibles
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}
}
EOF
# Créer les dossiers nécessaires avec bonnes permissions
RUN mkdir -p /var/cache/nginx /var/log/nginx /var/run && \
chown -R nginx-app:nginx-app /var/cache/nginx /var/log/nginx /var/run /usr/share/nginx/html && \
chmod -R 755 /usr/share/nginx/html
# Exposer le port
EXPOSE 80
# Healthcheck
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
# Passer à l'utilisateur non-root
USER nginx-app
# Démarrer Nginx
CMD ["nginx", "-g", "daemon off;"]

63
README.md Normal file
View File

@ -0,0 +1,63 @@
# Yt2Jellyfin - Frontend
Interface utilisateur pour télécharger et transférer de la musique YouTube vers Jellyfin.
## Stack Technique
- **Vite** - Build tool et dev server
- **React 18** - Bibliothèque UI
- **TypeScript** - Typage statique
- **Tailwind CSS** - Framework CSS utility-first
## Installation
```bash
npm install
```
## Configuration
Créez un fichier `.env` basé sur `.env.example`:
```bash
cp .env.example .env
```
Configurez l'URL de l'API backend:
```
VITE_API_BASE_URL=http://localhost:3000
```
## Développement
```bash
npm run dev
```
L'application sera disponible sur `http://localhost:5173`.
## Build Production
```bash
npm run build
```
Les fichiers de production seront générés dans le dossier `dist/`.
## Structure
```
src/
├── components/ # Composants React
│ ├── Layout.tsx
│ ├── InputForm.tsx
│ └── StatusConsole.tsx
├── types/ # Définitions TypeScript
│ └── api.ts
├── utils/ # Utilitaires
│ ├── api-client.ts
│ └── validators.ts
├── App.tsx # Composant principal
└── main.tsx # Point d'entrée
```

28
eslint.config.js Normal file
View File

@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yt2Jellyfin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

32
package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "yt2jellyfin-client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.15.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"globals": "^15.12.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3",
"typescript-eslint": "^8.15.0",
"vite": "^6.0.3"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

70
src/App.tsx Normal file
View File

@ -0,0 +1,70 @@
import { useState } from 'react';
import { Layout } from './components/Layout';
import { InputForm } from './components/InputForm';
import { StatusConsole, type StatusMessage } from './components/StatusConsole';
import { processYoutubeUrl } from './utils/api-client';
import type { ProcessRequest } from './types/api';
function App() {
const [isLoading, setIsLoading] = useState(false);
const [messages, setMessages] = useState<StatusMessage[]>([]);
const addMessage = (message: string, type: StatusMessage['type']) => {
setMessages((prev) => [
...prev,
{
id: `${Date.now()}-${Math.random()}`,
type,
message,
timestamp: new Date(),
},
]);
};
const handleSubmit = async (request: ProcessRequest) => {
setIsLoading(true);
setMessages([]);
addMessage(`Démarrage du traitement pour: ${request.url}`, 'info');
addMessage(`Format sélectionné: ${request.format.toUpperCase()}`, 'info');
if (request.filename) {
addMessage(`Nom personnalisé: ${request.filename}`, 'info');
}
addMessage('Téléchargement en cours...', 'info');
try {
const response = await processYoutubeUrl(request);
if (response.success) {
addMessage('Téléchargement terminé', 'success');
addMessage('Conversion en cours...', 'info');
addMessage('Conversion terminée', 'success');
addMessage('Transfert vers Jellyfin...', 'info');
addMessage(`Succès ! Fichier transféré: ${response.filename || 'audio'}`, 'success');
} else {
addMessage(`Erreur: ${'error' in response ? response.error : 'Erreur inconnue'}`, 'error');
if ('details' in response && response.details) {
addMessage(`Détails: ${response.details}`, 'error');
}
}
} catch (error) {
addMessage(
`Erreur inattendue: ${error instanceof Error ? error.message : String(error)}`,
'error'
);
} finally {
setIsLoading(false);
}
};
return (
<Layout>
<InputForm onSubmit={handleSubmit} isLoading={isLoading} />
<StatusConsole messages={messages} />
</Layout>
);
}
export default App;

View File

@ -0,0 +1,128 @@
import { useState, type FormEvent } from 'react';
import type { AudioFormat, ProcessRequest } from '../types/api';
import { isValidYoutubeUrl, sanitizeFilename } from '../utils/validators';
interface InputFormProps {
onSubmit: (request: ProcessRequest) => void;
isLoading: boolean;
}
export function InputForm({ onSubmit, isLoading }: InputFormProps) {
const [url, setUrl] = useState('');
const [filename, setFilename] = useState('');
const [format, setFormat] = useState<AudioFormat>('mp3');
const [urlError, setUrlError] = useState('');
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setUrlError('');
if (!isValidYoutubeUrl(url)) {
setUrlError('URL YouTube invalide. Veuillez entrer une URL valide.');
return;
}
const sanitizedFilename = filename.trim()
? sanitizeFilename(filename)
: undefined;
onSubmit({
url: url.trim(),
filename: sanitizedFilename,
format,
});
};
return (
<form onSubmit={handleSubmit} className="bg-gray-800 rounded-lg shadow-xl p-8 mb-8">
<div className="space-y-6">
<div>
<label htmlFor="url" className="block text-sm font-medium text-gray-300 mb-2">
URL YouTube
</label>
<input
id="url"
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://www.youtube.com/watch?v=..."
className="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={isLoading}
required
/>
{urlError && (
<p className="mt-2 text-sm text-red-400">{urlError}</p>
)}
</div>
<div>
<label htmlFor="filename" className="block text-sm font-medium text-gray-300 mb-2">
Nom du fichier (optionnel)
</label>
<input
id="filename"
type="text"
value={filename}
onChange={(e) => setFilename(e.target.value)}
placeholder="Ma chanson préférée"
className="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={isLoading}
/>
<p className="mt-1 text-xs text-gray-400">
Si non spécifié, le titre de la vidéo sera utilisé
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Format audio
</label>
<div className="flex gap-4">
<label className="flex items-center cursor-pointer">
<input
type="radio"
name="format"
value="mp3"
checked={format === 'mp3'}
onChange={(e) => setFormat(e.target.value as AudioFormat)}
className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 focus:ring-blue-500"
disabled={isLoading}
/>
<span className="ml-2 text-gray-300">MP3 (Compression)</span>
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
name="format"
value="flac"
checked={format === 'flac'}
onChange={(e) => setFormat(e.target.value as AudioFormat)}
className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 focus:ring-blue-500"
disabled={isLoading}
/>
<span className="ml-2 text-gray-300">FLAC (Sans perte)</span>
</label>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-semibold py-3 px-6 rounded-lg transition-colors duration-200 flex items-center justify-center"
>
{isLoading ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Traitement en cours...
</>
) : (
'Télécharger et transférer'
)}
</button>
</div>
</form>
);
}

25
src/components/Layout.tsx Normal file
View File

@ -0,0 +1,25 @@
import type { ReactNode } from 'react';
interface LayoutProps {
children: ReactNode;
}
export function Layout({ children }: LayoutProps) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
<div className="container mx-auto px-4 py-8">
<header className="text-center mb-12">
<h1 className="text-5xl font-bold text-white mb-2">
Yt2Jellyfin
</h1>
<p className="text-gray-400 text-lg">
Téléchargez et transférez votre musique vers Jellyfin
</p>
</header>
<main className="max-w-3xl mx-auto">
{children}
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,62 @@
import { useEffect, useRef } from 'react';
export type StatusType = 'info' | 'success' | 'error' | 'warning';
export interface StatusMessage {
id: string;
type: StatusType;
message: string;
timestamp: Date;
}
interface StatusConsoleProps {
messages: StatusMessage[];
}
const STATUS_ICONS: Record<StatusType, string> = {
info: '⏳',
success: '✓',
error: '✗',
warning: '⚠',
};
const STATUS_COLORS: Record<StatusType, string> = {
info: 'text-blue-400',
success: 'text-green-400',
error: 'text-red-400',
warning: 'text-yellow-400',
};
export function StatusConsole({ messages }: StatusConsoleProps) {
const consoleEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
consoleEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
if (messages.length === 0) {
return null;
}
return (
<div className="bg-gray-800 rounded-lg shadow-xl p-6">
<h2 className="text-xl font-semibold text-white mb-4">Console de statut</h2>
<div className="bg-gray-900 rounded-lg p-4 max-h-96 overflow-y-auto font-mono text-sm">
{messages.map((msg) => (
<div key={msg.id} className="mb-2 flex items-start gap-2">
<span className={STATUS_COLORS[msg.type]}>
{STATUS_ICONS[msg.type]}
</span>
<span className="text-gray-400 text-xs min-w-[80px]">
{msg.timestamp.toLocaleTimeString('fr-FR')}
</span>
<span className={`flex-1 ${STATUS_COLORS[msg.type]}`}>
{msg.message}
</span>
</div>
))}
<div ref={consoleEndRef} />
</div>
</div>
);
}

9
src/env.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

30
src/index.css Normal file
View File

@ -0,0 +1,30 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
#root {
width: 100%;
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

19
src/types/api.ts Normal file
View File

@ -0,0 +1,19 @@
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;
}

34
src/utils/api-client.ts Normal file
View File

@ -0,0 +1,34 @@
import type { ProcessRequest, ProcessResponse, ProcessError } from '../types/api';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000';
export async function processYoutubeUrl(
request: ProcessRequest
): Promise<ProcessResponse | ProcessError> {
try {
const response = await fetch(`${API_BASE_URL}/api/process`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
success: false,
error: errorData.error || `HTTP ${response.status}: ${response.statusText}`,
details: errorData.details,
};
}
return await response.json();
} catch (error) {
return {
success: false,
error: 'Erreur de connexion au serveur',
details: error instanceof Error ? error.message : String(error),
};
}
}

16
src/utils/validators.ts Normal file
View File

@ -0,0 +1,16 @@
const YOUTUBE_URL_REGEX = /^(https?:\/\/)?(www\.)?(youtube\.com\/(watch\?v=|shorts\/)|youtu\.be\/)[\w-]+/;
export function isValidYoutubeUrl(url: string): boolean {
if (!url || typeof url !== 'string') {
return false;
}
return YOUTUBE_URL_REGEX.test(url.trim());
}
export function sanitizeFilename(filename: string): string {
return filename
.trim()
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '')
.replace(/\s+/g, '_')
.substring(0, 255);
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

11
tailwind.config.js Normal file
View File

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

23
tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

21
tsconfig.node.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

10
vite.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
strictPort: true,
},
})