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;