Keri sisuni

Tdarr🔗

Ultimate tööriist kui soovid kettaruumi kokku hoida.
Enamus kasutavad seda, et uuesti encode-ida meedia näiteks HEVC peale, aga mul pole GPU sellele teenusele kättesaadav ja CPUga software encode ka ei viitsi oodata.
Kasutan ainult üleliigsete audio ja sub-ide eemaldamiseks, audio eemaldamine vabastab isegi päris palju ruumi, 1385 failist suutis vabastada 127gb.

Update:
Peale skripti parandamist / arendamist mitmel korral ja uute failide lisandumist on 2377 failist ruumi kokku hoitud 221gb.

Seadistus🔗

Tdarr Compose fail
# Tdarr
  tdarr:
    container_name: tdarr
    image: ghcr.io/haveagitgat/tdarr:latest
    restart: unless-stopped
    ports:
      - 8265:8265 # webUI port
      - 8266:8266 # server port
    environment:
      - TZ=Europe/Tallinn
      - PUID=0
      - PGID=0
      - UMASK_SET=002
      - serverIP=192.168.1.21
      - serverPort=8266
      - webUIPort=8265
      - internalNode=true
      - inContainer=true
      - ffmpegVersion=7
      - nodeName=InternalNode
      - auth=false
      - openBrowser=true
      - maxLogSizeMB=10
      - cronPluginUpdate=
    volumes:
      - ./tdarr/server:/app/server
      - ./tdarr/configs:/app/configs
      - ./tdarr/logs:/app/logs
      - ./media:/media
      - ./media/tdarr-cache:/temp

Doc pooleli

Plugin🔗

AI abiga koostasin näidis pluginaid kasutades.
Audio ja Sub eemaldamine kokku pandud kuna kui nad eraldi, siis jooksevad eraldi ehk kirjutab uue faili ilma mõne audio track-ita ja siis teeb seda sama uuesti suppakatega, aja ja ketta write-i raiskamine.

Plugina .js fail
/* eslint-disable no-await-in-loop */
/* eslint no-plusplus: ["error", { "allowForLoopAfterthoughts": true }] */

// This plugin combines 'Tdarr_Plugin_MC93_Migz4CleanSubs' and 'Tdarr_Plugin_henk_Keep_Native_Lang_Plus_Eng'
// to perform both audio and subtitle cleanup in a single FFmpeg operation, avoiding double remuxing.
// Version 2: Corrected syntax and helper function scope.
// Version 3: Added an option to keep English SDH subtitles.
// Version 4: Fixed bug where 2-letter language codes (e.g., 'en') were not recognized in subtitles.
// Version 5: Fixed potential muxing queue overflow error with using a low queue size, updated `-max_muxing_queue_size 9999` to 1048576.
//            Fixed potential issue of losing Global Metadata when only using `-map 0`, added `-map_chapters 0` and `-map_metadata 0` as well.
// Version 6: Fix Commentary tracks not being removed if user input = true. Input value was passed as String, changed to boolean.
//            English SDH being removed for the same reason.
// Version 7: Fix potential issue with `user_langs` parsing not trimming whitespace thus matching "eng, jpn" with " jpn" instead of "jpn".
//            Same applies to subtitles as well.
// Version 8: Removed redundant visual dividers "section_subtitle" and "section_audio" from Tdarr UI.
// Version 9: Fixed issue with only Commentary sub tracks being removed and not audio.
//            Renamed 'remove_commentary_subs' input to 'remove_commentary' to reflect it now covers both audio and subtitle commentary tracks.
// Version 10: Applied 2-letter to 3-letter language code normalization to audio streams, matching existing subtitle logic.
//             Prevents edge case where audio tagged 'en' instead of 'eng' would be incorrectly removed.
// Version 11: Added "smart" language detection for audio streams tagged as 'und' (Unknown).
//             If a track is untagged but its title contains a recognizable language name (e.g. "Russian Dub"),
//             the script will detect that language and apply normal keep/remove logic instead of always keeping it.
// Version 12: Updated dependency axios from 0.27.2 to 1.13.6.
//             In between the versions no change was made to `axios.get()` so the script will work.
// Version 13: Renamed all input keys to be more human-readable (e.g. 'language' -> 'subs_to_keep').
//             Updated all internal references to match the new keys.
// Version 14: Added 'compatibility' and 'descriptive' to commentary keyword list for audio and subtitle removal.
//             Added logic to remove 2-channel audio tracks when a higher channel count track of the same language exists.
//             Commentary/descriptive keyword removal now bypasses language keep rules.
// Version 15: Fixed English SDH = keep also applying to Commentary tracks that were in English and SDH.
// Version 16: Fixed locale data not being registered for @cospired/i18n-iso-languages, causing all language
//             normalization (alpha2ToAlpha3B, getAlpha3BCode) to silently return undefined.
//             Fixed smart language detection using languages.isValid() which only checks ISO codes, not language
//             names — detection now calls getAlpha3BCode() directly.
//             Fixed audio channel map being built before smart detection resolves 'und' tracks, causing incorrect
//             stereo pruning decisions. Audio now does a first pass to resolve all languages before building
//             maxChannelsPerLang and deciding what to remove.
//             Fixed tmdbApi helper receiving response by closure — now passed explicitly as a parameter.
//             Normalized boolean inputs using String(...) === 'true' for stricter and more consistent parsing.
// Version 17: Added underscore to audio Smart Detection Splitter since release groups might use it.

module.exports.dependencies = ['axios@1.13.6', '@cospired/i18n-iso-languages'];

const details = () => ({
  id: 'Tdarr_Plugin_Combined_AudioSub_Cleanup',
  Stage: 'Pre-processing',
  Name: 'Combined Audio and Subtitle Cleanup',
  Type: 'Audio, Subtitle',
  Operation: 'Transcode',
  Description: `This plugin combines two functions into one to avoid extra remuxing.
    \n\n[Audio Cleanup]: Removes all audio tracks except for the 'native' language (requires TMDB/Sonarr/Radarr) and English. Commentary/descriptive audio tracks are removed regardless of language. 2-channel tracks are removed if a higher channel count track of the same language exists. Tracks tagged as unknown ('und') are checked against their title for a recognizable language name before defaulting to being kept.
    \n\n[Subtitle Cleanup]: Keeps only specified subtitle language tracks, removes commentary/descriptive/SDH tracks (with an exception for English SDH), and can tag tracks with an unknown language. Now correctly handles both 2-letter and 3-letter language codes.`,
  Version: '17.0',
  Tags: 'pre-processing,ffmpeg,configurable,audio,subtitle',
  Inputs: [
    {
      name: 'subs_to_keep',
      type: 'string',
      defaultValue: 'eng,jpn',
      inputUI: {
        type: 'text',
        order: 1,
      },
      tooltip: `[Subtitles] Specify language tag/s for the subtitle tracks you'd like to KEEP.
                    \\nMust follow ISO-639-2 3-letter format.
              \\nExample: eng
              \\nExample: eng,jpn`,
    },
    {
      name: 'remove_commentary_tracks',
      type: 'boolean',
      defaultValue: true,
      inputUI: {
        type: 'dropdown',
        options: ['true', 'false'],
        order: 2,
      },
      tooltip: `[Audio & Subtitles] If 'true', tracks containing 'commentary', 'description', 'descriptive', or 'compatibility' in their title will be removed from both audio and subtitle streams, regardless of language. Non-English SDH subtitle tracks are also removed.`,
    },
    {
      name: 'keep_english_sdh',
      type: 'boolean',
      defaultValue: true,
      inputUI: {
        type: 'dropdown',
        options: ['true', 'false'],
        order: 3,
      },
      tooltip: `[Subtitles] If 'true', this will PREVENT the removal of English SDH subtitles, even if they are marked for removal otherwise. Commentary tracks are always removed regardless of this setting.`,
    },
    {
      name: 'tag_unknown_subs',
      type: 'string',
      defaultValue: '',
      inputUI: {
        type: 'text',
        order: 4,
      },
      tooltip: `[Subtitles] Specify a single language for subtitle tracks with no language ('und') to be tagged with.
                    \\nLeave empty to disable.
              \\nExample: eng`,
    },
    {
      name: 'remove_stereo_if_surround_exists',
      type: 'boolean',
      defaultValue: true,
      inputUI: {
        type: 'dropdown',
        options: ['true', 'false'],
        order: 5,
      },
      tooltip: `[Audio] If 'true', 2-channel (stereo) audio tracks will be removed if a higher channel count track of the same language already exists.`,
    },
    {
      name: 'languages_to_keep',
      type: 'string',
      defaultValue: '',
      inputUI: {
        type: 'text',
        order: 6,
      },
      tooltip:
        '[Audio] Input a comma-separated list of extra ISO-639-2 languages to KEEP. The original language and English are always kept.'
        + '\\nExample: nld,nor',
    },
    {
      name: 'tmdb_api_key',
      type: 'string',
      defaultValue: '',
      inputUI: {
        type: 'text',
        order: 7,
      },
      tooltip:
        '[Audio] Input your TMDB api (v3) key here. (https://www.themoviedb.org/)',
    },
    {
      name: 'priority',
      type: 'string',
      defaultValue: 'Radarr',
      inputUI: {
        type: 'text',
        order: 8,
      },
      tooltip:
        '[Audio] Priority for either Radarr or Sonarr to get the IMDB ID. Leaving it empty defaults to Radarr first.'
        + '\\nExample: Sonarr',
    },
    {
      name: 'radarr_url',
      type: 'string',
      defaultValue: '127.0.0.1:7878',
      inputUI: {
        type: 'text',
        order: 9,
      },
      tooltip:
        '[Audio] Input your Radarr url here. (Without http://). Do include the port.'
        + '\\nExample: 192.168.1.2:7878',
    },
    {
      name: 'radarr_api_key',
      type: 'string',
      defaultValue: '',
      inputUI: {
        type: 'text',
        order: 10,
      },
      tooltip: '[Audio] Input your Radarr api key here.',
    },
    {
      name: 'sonarr_url',
      type: 'string',
      defaultValue: '127.0.0.1:8989',
      inputUI: {
        type: 'text',
        order: 11,
      },
      tooltip:
        '[Audio] Input your Sonarr url here. (Without http://). Do include the port.'
        + '\\nExample: 192.168.1.2:8989',
    },
    {
      name: 'sonarr_api_key',
      type: 'string',
      defaultValue: '',
      inputUI: {
        type: 'text',
        order: 12,
      },
      tooltip: '[Audio] Input your Sonarr api key here.',
    },
  ],
});

const plugin = async (file, librarySettings, inputs, otherArguments) => {
  //--------------------------------------------------------------------------
  // Begin Helper Functions
  //--------------------------------------------------------------------------

  const tmdbApi = async (filename, api_key, axios, response) => {
    let fileName;
    if (filename) {
      if (filename.slice(0, 2) === 'tt') {
        fileName = filename;
      } else {
        const idRegex = /(tt\d{7,8})/;
        const fileMatch = filename.match(idRegex);
        if (fileMatch) fileName = fileMatch[1];
      }
    }

    if (fileName) {
      try {
        const result = await axios
          .get(
            `https://api.themoviedb.org/3/find/${fileName}?api_key=${api_key}&language=en-US&external_source=imdb_id`,
          )
          .then((resp) => (resp.data.movie_results.length > 0
            ? resp.data.movie_results[0]
            : resp.data.tv_results[0]));

        if (!result) {
          response.infoLog += '☒ [Audio] No movie/tv result found on TMDB for this ID.\n';
        }
        return result;
      } catch (err) {
        response.infoLog += `☒ [Audio] Error communicating with TMDB API: ${err.message}\n`;
      }
    }
    return null;
  };

  const parseArrResponse = (body, arr) => {
    switch (arr) {
      case 'radarr':
        return body.movie;
      case 'sonarr':
        return body.series;
      default:
        return null;
    }
  };

  const isCommentaryTitle = (title) => {
    return (
      title.includes('commentary')
      || title.includes('description')
      || title.includes('descriptive')
      || title.includes('compatibility')
    );
  };

  //--------------------------------------------------------------------------
  // End Helper Functions
  //--------------------------------------------------------------------------

  const lib = require('../methods/lib')();
  // eslint-disable-next-line no-param-reassign
  inputs = lib.loadDefaultValues(inputs, details);
  const axios = require('axios').default;
  const languages = require('@cospired/i18n-iso-languages');
  languages.registerLocale(require('@cospired/i18n-iso-languages/langs/en.json'));

  const response = {
    processFile: false,
    preset: '',
    container: `.${file.container}`,
    handBrakeMode: false,
    FFmpegMode: true,
    reQueueAfter: false,
    infoLog: '',
  };

  if (file.fileMedium !== 'video') {
    response.infoLog += '☒ File is not a video file.\n';
    return response;
  }

  const removeCommentary = String(inputs.remove_commentary_tracks) === 'true';
  const keepEnglishSdh = String(inputs.keep_english_sdh) === 'true';
  const removeStereoIfSurroundExists = String(inputs.remove_stereo_if_surround_exists) === 'true';

  let subCommand = '';
  let subRequiresProcessing = false;
  let audioCommand = '';
  let audioRequiresProcessing = false;

  // --------------------------------------------------------------------------
  // START SUBTITLE PROCESSING LOGIC
  // --------------------------------------------------------------------------
  if (inputs.subs_to_keep === '') {
    response.infoLog += '☒ [Subtitles] Language/s to keep have not been configured, skipping subtitle cleanup.\n';
  } else {
    const subLangsToKeep = inputs.subs_to_keep.split(',').map(l => l.trim());
    let subtitleIdx = 0;

    for (let i = 0; i < file.ffProbeData.streams.length; i++) {
      const stream = file.ffProbeData.streams[i];
      if (stream.codec_type.toLowerCase() !== 'subtitle') {
        continue;
      }

      let originalLang = 'und';
      if (stream.tags && stream.tags.language) {
        originalLang = stream.tags.language.toLowerCase();
      }

      // Normalize language code to 3-letters for consistent matching
      let normalizedLang = originalLang;
      if (originalLang.length === 2) {
        const converted = languages.alpha2ToAlpha3B(originalLang);
        if (converted) {
          normalizedLang = converted;
        }
      }

      let title = '';
      if (stream.tags && stream.tags.title) {
        title = stream.tags.title.toLowerCase();
      }

      const isCommentary = isCommentaryTitle(title);
      const isSdhTrack = title.includes('sdh');
      // Commentary always takes priority — an English SDH commentary track is never protected
      const isEnglishSdhToKeep = isSdhTrack && !isCommentary && normalizedLang === 'eng' && keepEnglishSdh;

      if (isEnglishSdhToKeep) {
        response.infoLog += `☑ [Subtitles] Keeping English SDH stream 0:s:${subtitleIdx}.\n`;
      }

      const isRemovableCommentary = removeCommentary && (
        isCommentary
        || (isSdhTrack && !isEnglishSdhToKeep)
      );

      const isWrongLang = subLangsToKeep.indexOf(normalizedLang) === -1;

      if (!isEnglishSdhToKeep && (isRemovableCommentary || isWrongLang)) {
        subCommand += `-map -0:s:${subtitleIdx} `;
        response.infoLog += `☒ [Subtitles] Removing stream 0:s:${subtitleIdx} (lang: ${originalLang}, title: '${title}').\n`;
        subRequiresProcessing = true;
      } else if (inputs.tag_unknown_subs !== '' && normalizedLang === 'und') {
        subCommand += `-metadata:s:s:${subtitleIdx} language=${inputs.tag_unknown_subs} `;
        response.infoLog += `☒ [Subtitles] Tagging undefined stream 0:s:${subtitleIdx} as '${inputs.tag_unknown_subs}'.\n`;
        subRequiresProcessing = true;
      }

      subtitleIdx += 1;
    }

    if (!subRequiresProcessing) {
      response.infoLog += '☑ [Subtitles] No subtitle changes required.\n';
    }
  }

  // --------------------------------------------------------------------------
  // START AUDIO PROCESSING LOGIC
  // --------------------------------------------------------------------------
  if (!inputs.tmdb_api_key) {
    response.infoLog += '☒ [Audio] TMDB API key is not configured, skipping audio cleanup.\n';
  } else {
    let prio = ['radarr', 'sonarr'];
    if (inputs.priority && inputs.priority.toLowerCase() === 'sonarr') {
      prio = ['sonarr', 'radarr'];
    }

    const fileNameEncoded = encodeURIComponent(file.meta.FileName);
    let imdbId = null;
    let tmdbResult = null;

    for (const arr of prio) {
      if (imdbId) break;
      switch (arr) {
        case 'radarr':
          if (inputs.radarr_api_key && inputs.radarr_url) {
            try {
              const radarrResponse = await axios.get(`http://${inputs.radarr_url}/api/v3/parse?apikey=${inputs.radarr_api_key}&title=${fileNameEncoded}`);
              const radarrResult = parseArrResponse(radarrResponse.data, 'radarr');
              if (radarrResult && radarrResult.imdbId) {
                imdbId = radarrResult.imdbId;
                response.infoLog += `☑ [Audio] Grabbed ID (${imdbId}) from Radarr.\n`;
              }
            } catch (err) {
              response.infoLog += `ⓘ [Audio] Could not get ID from Radarr: ${err.message}\n`;
            }
          }
          break;
        case 'sonarr':
          if (inputs.sonarr_api_key && inputs.sonarr_url) {
            try {
              const sonarrResponse = await axios.get(`http://${inputs.sonarr_url}/api/v3/parse?apikey=${inputs.sonarr_api_key}&title=${fileNameEncoded}`);
              const sonarrResult = parseArrResponse(sonarrResponse.data, 'sonarr');
              if (sonarrResult && sonarrResult.imdbId) {
                imdbId = sonarrResult.imdbId;
                response.infoLog += `☑ [Audio] Grabbed ID (${imdbId}) from Sonarr.\n`;
              }
            } catch (err) {
              response.infoLog += `ⓘ [Audio] Could not get ID from Sonarr: ${err.message}\n`;
            }
          }
          break;
        default:
          break;
      }
    }

    if (!imdbId) {
      const idRegex = /(tt\d{7,8})/;
      const fileMatch = file.meta.FileName.match(idRegex);
      if (fileMatch) {
        imdbId = fileMatch[1];
        response.infoLog += `☑ [Audio] Found IMDB ID (${imdbId}) in filename.\n`;
      }
    }

    if (imdbId) {
      tmdbResult = await tmdbApi(imdbId, inputs.tmdb_api_key, axios, response);
    }

    if (tmdbResult) {
      let audioLangsToKeep = ['eng', 'und'];
      const originalLangAlpha2 = tmdbResult.original_language === 'cn' ? 'zh' : tmdbResult.original_language;
      const originalLangAlpha3 = languages.alpha2ToAlpha3B(originalLangAlpha2);

      response.infoLog += `ⓘ [Audio] Original language from TMDB is '${originalLangAlpha2}' (${originalLangAlpha3}).\n`;
      if (originalLangAlpha3) {
        audioLangsToKeep.push(originalLangAlpha3);
      }

      if (inputs.languages_to_keep) {
        audioLangsToKeep.push(...inputs.languages_to_keep.split(',').map(l => l.trim()));
      }
      audioLangsToKeep = [...new Set(audioLangsToKeep)];
      response.infoLog += `☑ [Audio] Languages to keep: ${audioLangsToKeep.join(', ')}\n`;

      // --------------------------------------------------------------------------
      // AUDIO FIRST PASS: resolve all languages (including smart detection for 'und')
      // This must happen before building maxChannelsPerLang so that tracks whose
      // language is resolved from their title are counted under the correct language.
      // --------------------------------------------------------------------------
      const resolvedAudioStreams = [];
      let audioIdx = 0;

      for (const stream of file.ffProbeData.streams) {
        if (stream.codec_type !== 'audio') continue;

        let originalAudioLang = 'und';
        if (stream.tags && stream.tags.language) {
          originalAudioLang = stream.tags.language.toLowerCase();
        }

        let normalizedAudioLang = originalAudioLang;
        if (originalAudioLang.length === 2) {
          const converted = languages.alpha2ToAlpha3B(originalAudioLang);
          if (converted) {
            normalizedAudioLang = converted;
          }
        }

        let audioTitle = '';
        if (stream.tags && stream.tags.title) {
          audioTitle = stream.tags.title.toLowerCase();
        }

        // Smart detection: if language is still 'und', try to guess from the track title
        if (normalizedAudioLang === 'und' && audioTitle.length > 0) {
          const titleWords = audioTitle.split(/[\s,._-]+/);
          for (const word of titleWords) {
            try {
              const detectedCode = languages.getAlpha3BCode(word, 'en');
              if (detectedCode) {
                response.infoLog += `ⓘ [Audio] Detected language '${detectedCode}' from title word '${word}' for stream 0:a:${audioIdx}.\n`;
                normalizedAudioLang = detectedCode;
                break;
              }
            } catch (e) {
              // languages library method not available, skip smart detection
            }
          }
        }

        resolvedAudioStreams.push({
          stream,
          idx: audioIdx,
          originalLang: originalAudioLang,
          normalizedLang: normalizedAudioLang,
          title: audioTitle,
          channels: stream.channels || 0,
        });

        audioIdx++;
      }

      // --------------------------------------------------------------------------
      // AUDIO SECOND PASS: build maxChannelsPerLang from resolved languages,
      // skipping commentary tracks so they don't inflate the channel map.
      // --------------------------------------------------------------------------
      const maxChannelsPerLang = {};

      for (const s of resolvedAudioStreams) {
        if (removeCommentary && isCommentaryTitle(s.title)) continue;

        if (!maxChannelsPerLang[s.normalizedLang] || s.channels > maxChannelsPerLang[s.normalizedLang]) {
          maxChannelsPerLang[s.normalizedLang] = s.channels;
        }
      }

      // --------------------------------------------------------------------------
      // AUDIO THIRD PASS: decide what to remove
      // --------------------------------------------------------------------------
      let keptAudioTracks = 0;
      const audioStreamsToRemove = [];

      for (const s of resolvedAudioStreams) {
        const isCommentaryAudio = removeCommentary && isCommentaryTitle(s.title);

        const isStereoDowngrade = removeStereoIfSurroundExists
          && s.channels <= 2
          && maxChannelsPerLang[s.normalizedLang] > 2;

        if (isCommentaryAudio) {
          audioStreamsToRemove.push({ idx: s.idx, lang: s.normalizedLang, reason: 'commentary' });
        } else if (isStereoDowngrade) {
          audioStreamsToRemove.push({ idx: s.idx, lang: s.normalizedLang, reason: `stereo (${s.channels}ch, max for lang is ${maxChannelsPerLang[s.normalizedLang]}ch)` });
        } else if (audioLangsToKeep.includes(s.normalizedLang)) {
          keptAudioTracks++;
        } else {
          audioStreamsToRemove.push({ idx: s.idx, lang: s.normalizedLang, reason: 'language' });
        }
      }

      if (audioStreamsToRemove.length > 0 && keptAudioTracks > 0) {
        audioRequiresProcessing = true;
        for (const s of audioStreamsToRemove) {
          audioCommand += `-map -0:a:${s.idx} `;
          response.infoLog += `☒ [Audio] Removing stream 0:a:${s.idx} (lang: ${s.lang}, reason: ${s.reason}).\n`;
        }
      } else if (keptAudioTracks === 0 && audioStreamsToRemove.length > 0) {
        response.infoLog += '☒ [Audio] All audio tracks were marked for removal. Cancelling audio changes to prevent a file with no audio.\n';
      } else {
        response.infoLog += '☑ [Audio] No audio changes required.\n';
      }
    } else {
      response.infoLog += '☒ [Audio] Could not find TMDB info for this file. Skipping audio cleanup.\n';
    }
  }

  // --------------------------------------------------------------------------
  // FINALIZATION
  // --------------------------------------------------------------------------
  if (subRequiresProcessing || audioRequiresProcessing) {
    response.processFile = true;
    const ffmpegCommandInsert = `${subCommand}${audioCommand}`;
    response.preset = `, -map 0 -map_metadata 0 -map_chapters 0 ${ffmpegCommandInsert}-c copy -max_muxing_queue_size 1048576`;
    response.reQueueAfter = true;
    response.infoLog += '\n☑ Final command generated for audio/subtitle cleanup.\n';
  } else {
    response.processFile = false;
    response.infoLog += '\n☑ No changes required for either audio or subtitles. File is OK.\n';
  }

  return response;
};

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