Skip to content

Tdarr - Plugins Personnalisés

Plugins Tdarr optimisés pour Intel HD 530 (Quick Sync 6.0) et bibliothèque Jellyfin.

Plugin 1 : Media Optimizer - Audio Remux

Objectif : Convertir audio multi-canal (DTS, EAC3, TrueHD) → AAC stéréo, garder FR + Original seulement

Configuration

function details() {
  return {
    id: "Custom_MediaOptimizer_AudioRemux",
    Stage: "Pre-processing",
    Name: "Media Optimizer - Audio Remux FR+VO",
    Type: "Video",
    Operation: "Transcode",
    Description: "Convertit audio 5.1/7.1 vers AAC 2.0, garde FR + Original, strip autres langues et sous-titres non FR/EN",
    Version: "1.0",
    Tags: "audio,french,remux,aac",
    Inputs: [
      {
        name: "target_audio_codec",
        type: "string",
        defaultValue: "aac",
        tooltip: "Codec audio cible (aac ou libfdk_aac si disponible)"
      },
      {
        name: "target_audio_bitrate", 
        type: "string",
        defaultValue: "256k",
        tooltip: "Bitrate audio pour stéréo"
      },
      {
        name: "keep_original_audio",
        type: "boolean",
        defaultValue: true,
        tooltip: "Garder piste audio langue originale"
      }
    ]
  };
}

function plugin(file, librarySettings, inputs, otherArguments) {
  const lib = require('../methods/lib')();
  inputs = lib.loadDefaultValues(inputs, details);

  const response = {
    processFile: false,
    preset: "",
    container: ".mkv",
    handBrakeMode: false,
    FFmpegMode: true,
    reQueueAfter: false,
    infoLog: ""
  };

  // Skip si pas de streams audio
  if (file.ffProbeData.streams.filter(s => s.codec_type === "audio").length === 0) {
    response.infoLog += "⏭️ No audio streams, skipping\n";
    return response;
  }

  // Checker si déjà optimisé (tous audio = AAC 2ch)
  const audioStreams = file.ffProbeData.streams.filter(s => s.codec_type === "audio");
  const needsProcessing = audioStreams.some(s => 
    s.codec_name !== "aac" || s.channels > 2
  );

  if (!needsProcessing) {
    response.infoLog += "✅ Audio déjà optimisé (AAC 2.0)\n";
    return response;
  }

  // Construction FFmpeg command
  let ffmpegCommand = "";

  // Input
  ffmpegCommand += "-i <io> ";

  // Map vidéo (copy)
  ffmpegCommand += "-map 0:v -c:v copy ";

  // Map audio FR (priorité VFF/VOF/VF)
  const frenchAudio = audioStreams.find(s => 
    s.tags && (
      s.tags.language === "fre" || 
      s.tags.language === "fra" || 
      s.tags.title?.includes("French") ||
      s.tags.title?.includes("VFF") ||
      s.tags.title?.includes("VOF")
    )
  );

  if (frenchAudio) {
    ffmpegCommand += `-map 0:${frenchAudio.index} `;
  } else {
    // Fallback première piste audio
    ffmpegCommand += `-map 0:a:0 `;
  }

  // Map audio original si demandé et différent du français
  if (inputs.keep_original_audio) {
    const originalLanguage = file.meta?.OriginalLanguage || "eng";
    const originalAudio = audioStreams.find(s => 
      s.tags && s.tags.language === originalLanguage && s !== frenchAudio
    );

    if (originalAudio) {
      ffmpegCommand += `-map 0:${originalAudio.index} `;
    }
  }

  // Encoder audio en AAC stéréo avec downmix intelligent
  const audioCodec = inputs.target_audio_codec;
  const audioBitrate = inputs.target_audio_bitrate;

  ffmpegCommand += `-c:a ${audioCodec} -ac 2 -b:a ${audioBitrate} `;

  // Downmix matrix pour 5.1 → 2.0 (préserve dialogues et ambiance)
  ffmpegCommand += `-af "pan=stereo|FL=FC+0.30*FL+0.30*BL+0.30*LFE|FR=FC+0.30*FR+0.30*BR+0.30*LFE" `;

  // Map sous-titres FR et EN seulement
  ffmpegCommand += `-map 0:s:fre? -map 0:s:eng? -c:s copy `;

  // Metadata
  ffmpegCommand += `-metadata:s:a:0 language=fra -metadata:s:a:0 title="Français Stéréo" `;
  if (inputs.keep_original_audio) {
    ffmpegCommand += `-metadata:s:a:1 language=original -metadata:s:a:1 title="Original Stéréo" `;
  }

  // Dispositions : default sur piste française
  ffmpegCommand += `-disposition:a:0 default -disposition:s:0 0 `;

  response.processFile = true;
  response.preset = ffmpegCommand;
  response.infoLog += `🔄 Audio remux: ${audioStreams.length} pistes → 2 pistes AAC 2.0\n`;

  return response;
}

module.exports.details = details;
module.exports.plugin = plugin;

Plugin 2 : Media Optimizer - Video Transcode 4K

Objectif : Réencoder HEVC 10-bit → HEVC 8-bit SDR, ou H.264 high-bitrate → HEVC 8-bit

Configuration

function details() {
  return {
    id: "Custom_MediaOptimizer_VideoTranscode4K",
    Stage: "Pre-processing",
    Name: "Media Optimizer - Video Transcode 4K",
    Type: "Video",
    Operation: "Transcode",
    Description: "Réencode HEVC 10-bit ou H.264 >30Mbps vers HEVC 8-bit SDR avec Intel QSV/VAAPI",
    Version: "1.0",
    Tags: "video,4k,hevc,vaapi,hdr",
    Inputs: [
      {
        name: "target_codec",
        type: "string",
        defaultValue: "hevc_vaapi",
        tooltip: "Codec vidéo cible (hevc_vaapi pour Intel QSV)"
      },
      {
        name: "target_quality",
        type: "string",
        defaultValue: "23",
        tooltip: "QP (constant quality) : 18-28, 23 recommandé"
      },
      {
        name: "bitrate_threshold_mbps",
        type: "number",
        defaultValue: 30,
        tooltip: "Réencoder si bitrate source > seuil (Mbps)"
      },
      {
        name: "strip_hdr",
        type: "boolean",
        defaultValue: true,
        tooltip: "Convertir HDR → SDR BT.709"
      }
    ]
  };
}

function plugin(file, librarySettings, inputs, otherArguments) {
  const lib = require('../methods/lib')();
  inputs = lib.loadDefaultValues(inputs, details);

  const response = {
    processFile: false,
    preset: "",
    container: ".mkv",
    handBrakeMode: false,
    FFmpegMode: true,
    reQueueAfter: false,
    infoLog: ""
  };

  const videoStream = file.ffProbeData.streams.find(s => s.codec_type === "video");

  if (!videoStream) {
    response.infoLog += "⏭️ No video stream found\n";
    return response;
  }

  // Décision : besoin de réencodage ?
  let needsTranscode = false;
  let reason = "";

  // Check 1 : HEVC 10-bit
  if (videoStream.codec_name === "hevc" && 
      (videoStream.profile === "Main 10" || videoStream.pix_fmt?.includes("10"))) {
    needsTranscode = true;
    reason += "HEVC 10-bit → 8-bit, ";
  }

  // Check 2 : Bitrate élevé H.264
  const bitrateMbps = file.bit_rate / 1000000; // Convert to Mbps
  if (videoStream.codec_name === "h264" && bitrateMbps > inputs.bitrate_threshold_mbps) {
    needsTranscode = true;
    reason += `H.264 ${bitrateMbps.toFixed(1)}Mbps > ${inputs.bitrate_threshold_mbps}Mbps, `;
  }

  // Check 3 : HDR présent
  const isHDR = videoStream.color_transfer === "smpte2084" || // PQ (HDR10)
                videoStream.color_transfer === "arib-std-b67" || // HLG
                videoStream.tags?.["ENCODER_OPTIONS"]?.includes("HDR");

  if (isHDR && inputs.strip_hdr) {
    needsTranscode = true;
    reason += "HDR → SDR, ";
  }

  // Skip si déjà HEVC 8-bit SDR optimal
  if (videoStream.codec_name === "hevc" && 
      videoStream.profile === "Main" && 
      !isHDR &&
      bitrateMbps <= inputs.bitrate_threshold_mbps) {
    response.infoLog += "✅ Déjà optimisé (HEVC 8-bit SDR)\n";
    return response;
  }

  if (!needsTranscode) {
    response.infoLog += "✅ Vidéo ne nécessite pas de transcoding\n";
    return response;
  }

  // Construction FFmpeg command avec hardware acceleration
  let ffmpegCommand = "";

  // Hardware decode
  ffmpegCommand += "-hwaccel vaapi -hwaccel_device /dev/dri/renderD128 ";

  // Input
  ffmpegCommand += "-i <io> ";

  // Video filters
  let vf = "format=nv12,hwupload";

  // Scale si nécessaire (conserver résolution source)
  const width = videoStream.width;
  const height = videoStream.height;
  vf += `,scale_vaapi=w=${width}:h=${height}:format=nv12`;

  ffmpegCommand += `-vf "${vf}" `;

  // Video encoding avec VAAPI
  ffmpegCommand += `-c:v ${inputs.target_codec} `;
  ffmpegCommand += `-profile:v main `; // HEVC 8-bit Main profile
  ffmpegCommand += `-qp ${inputs.target_quality} `;
  ffmpegCommand += `-bf 3 `; // B-frames

  // Colorspace SDR
  if (inputs.strip_hdr) {
    ffmpegCommand += `-pix_fmt nv12 `;
    ffmpegCommand += `-colorspace bt709 `;
    ffmpegCommand += `-color_primaries bt709 `;
    ffmpegCommand += `-color_trc bt709 `;
  }

  // Copy audio et sous-titres
  ffmpegCommand += `-c:a copy -c:s copy `;

  // Map tous les streams
  ffmpegCommand += `-map 0 `;

  response.processFile = true;
  response.preset = ffmpegCommand;
  response.infoLog += `🔄 Video transcode: ${reason.slice(0, -2)}\n`;
  response.infoLog += `   ${videoStream.width}x${videoStream.height} ${videoStream.codec_name}${inputs.target_codec}\n`;

  return response;
}

module.exports.details = details;
module.exports.plugin = plugin;

Plugin 3 : Media Optimizer - Health Check

Objectif : Vérifier intégrité fichier après transcoding

Configuration

function details() {
  return {
    id: "Custom_MediaOptimizer_HealthCheck",
    Stage: "Post-processing",
    Name: "Media Optimizer - Health Check",
    Type: "Video",
    Operation: "Transcode",
    Description: "Vérifie intégrité fichier : durée, taille, lisibilité",
    Version: "1.0",
    Tags: "healthcheck,validation",
    Inputs: [
      {
        name: "min_file_size_ratio",
        type: "number",
        defaultValue: 0.3,
        tooltip: "Taille minimale vs source (0.3 = 30%)"
      },
      {
        name: "max_duration_diff_seconds",
        type: "number",
        defaultValue: 5,
        tooltip: "Différence durée maximale autorisée (secondes)"
      }
    ]
  };
}

function plugin(file, librarySettings, inputs, otherArguments) {
  const lib = require('../methods/lib')();
  inputs = lib.loadDefaultValues(inputs, details);

  const response = {
    processFile: false,
    preset: "",
    container: ".mkv",
    handBrakeMode: false,
    FFmpegMode: false,
    reQueueAfter: false,
    infoLog: ""
  };

  // Récupérer infos fichier original (avant transcode)
  const originalSize = otherArguments?.originalLibraryFile?.file_size || 0;
  const originalDuration = otherArguments?.originalLibraryFile?.ffProbeData?.format?.duration || 0;

  const currentSize = file.file_size;
  const currentDuration = file.ffProbeData?.format?.duration || 0;

  // Check 1: Taille fichier
  if (originalSize > 0) {
    const sizeRatio = currentSize / originalSize;

    if (sizeRatio < inputs.min_file_size_ratio) {
      response.infoLog += `❌ ERREUR: Fichier trop petit (${(sizeRatio*100).toFixed(1)}% vs ${inputs.min_file_size_ratio*100}% attendu)\n`;
      response.infoLog += `   Original: ${(originalSize/1e9).toFixed(2)} GB, Actuel: ${(currentSize/1e9).toFixed(2)} GB\n`;
      // Marquer pour requeue (retry)
      response.reQueueAfter = true;
      return response;
    }

    response.infoLog += `✅ Taille OK: ${(sizeRatio*100).toFixed(1)}% de l'original (${(currentSize/1e9).toFixed(2)} GB)\n`;
  }

  // Check 2: Durée
  if (originalDuration > 0 && currentDuration > 0) {
    const durationDiff = Math.abs(currentDuration - originalDuration);

    if (durationDiff > inputs.max_duration_diff_seconds) {
      response.infoLog += `❌ ERREUR: Durée invalide (diff ${durationDiff.toFixed(1)}s)\n`;
      response.infoLog += `   Original: ${originalDuration.toFixed(1)}s, Actuel: ${currentDuration.toFixed(1)}s\n`;
      response.reQueueAfter = true;
      return response;
    }

    response.infoLog += `✅ Durée OK: ${currentDuration.toFixed(0)}s (diff ${durationDiff.toFixed(1)}s)\n`;
  }

  // Check 3: Streams vidéo/audio présents
  const hasVideo = file.ffProbeData.streams.some(s => s.codec_type === "video");
  const hasAudio = file.ffProbeData.streams.some(s => s.codec_type === "audio");

  if (!hasVideo) {
    response.infoLog += `❌ ERREUR: Aucun stream vidéo détecté\n`;
    response.reQueueAfter = true;
    return response;
  }

  if (!hasAudio) {
    response.infoLog += `⚠️ WARNING: Aucun stream audio détecté\n`;
  }

  response.infoLog += `✅ Health check PASSED\n`;
  response.infoLog += `   Video: ${hasVideo ? "✅" : "❌"}, Audio: ${hasAudio ? "✅" : "❌"}\n`;

  return response;
}

module.exports.details = details;
module.exports.plugin = plugin;

Flow Tdarr Recommandé

Configuration Library

Library: Movies Optimized
  Source: /media/movies
  Cache: /temp/movies-cache

  Transcode Flow:
    Stage 1 - Pre-processing:
      1. [Built-in] File Size Filter (>500 MB)
      2. [Custom] Media Optimizer - Audio Remux
         - Condition: Audio non-AAC OU >2 channels
         - Action: Remux audio vers AAC 2.0

      3. [Custom] Media Optimizer - Video Transcode 4K
         - Condition: HEVC 10-bit OU H.264 >30Mbps OU HDR
         - Action: Transcode vers HEVC 8-bit SDR

    Stage 2 - Post-processing:
      4. [Custom] Media Optimizer - Health Check
         - Action: Valider intégrité
         - Error: Re-queue si échec

    Options:
      - Replace original: 
      - Create backup: ✅ (/media/.tdarr-originals/)
      - Hold after queue: ❌ (traitement automatique)

Monitoring et Métriques

Dashboard Tdarr

Métriques clés: - Files in queue - Processing speed (avg transcodes/hour) - Worker health (% CPU, errors) - Library statistics: - % files optimized - Total space saved (GB) - Avg file size reduction (%)

Logs Utiles

# Logs Tdarr main
docker logs -f tdarr

# Logs transcoding spécifiques
tail -f /opt/docker/tdarr/logs/node.log

# Filtrer erreurs
docker logs tdarr 2>&1 | grep -i "error\|failed"

Commandes Debug

# Test plugin manuellement sur un fichier
docker exec -it tdarr node \
  /app/server/Tdarr_Plugin_Custom_MediaOptimizer_AudioRemux.js \
  /media/movies/test.mkv

# Vérifier VAAPI dans container
docker exec tdarr ls -la /dev/dri/

# Test hardware encode
docker exec tdarr ffmpeg -hwaccel vaapi \
  -hwaccel_device /dev/dri/renderD128 \
  -f lavfi -i testsrc=duration=10:size=1920x1080:rate=30 \
  -c:v hevc_vaapi -qp 23 -t 5 /tmp/test.mkv

Troubleshooting

Erreur: "Failed to initialize VAAPI"

# Vérifier device présent
docker exec tdarr ls -la /dev/dri/renderD128

# Vérifier permissions
docker exec tdarr id tdarr
# Doit inclure groups 104 (render) et 44 (video)

# Recréer container avec bons groups
docker compose down tdarr
docker compose up -d tdarr

Erreur: "libva error: va_getDriverName() failed with unknown libva error,driver_name=(null)"

# Installer drivers dans container (si nécessaire)
docker exec -u root tdarr apt update
docker exec -u root tdarr apt install -y intel-media-va-driver libva2
docker restart tdarr

Performance Lente

# Vérifier charge GPU
intel_gpu_top

# Si GPU idle pendant transcode → Pas de HW accel
# Solution: Vérifier args FFmpeg dans logs plugin
docker logs tdarr | grep "ffmpeg.*vaapi"

Références