Files
Tdarr-plugin/LocalFlowPlugins/audio/audioSelectorClamping/1.0.0/index.js
T
PickleRick c2074be826 feat: Tdarr Local Flow Plugin Suite v1.0
7 atomic plugins for automated x265 transcoding pipeline:
- utility/mediaAnalyzerGOP    : GOP calc, FPS detection, film/series/anime routing
- utility/setAnimationFlag    : manual animation flag override
- utility/extractChaptersXml  : mkvextract chapters to XML
- audio/audioSelectorClamping : codec scoring, clamped Opus bitrates, 2 outputs
- audio/backupSourceAudio     : lossless DTS/TrueHD ITA backup (-c:a copy)
- subtitles/extractSubtitlesAndOcr : SRT extract + SubtitleEdit OCR for PGS
- video/transcode2PassX265    : 2-pass x265, Lanczos 1080p bound, Opus mux

Test suite: 34 unit tests + 3 integration tests (FFmpeg real files)
FlowHelpers mock for local testing included
Docs: integration-guide.md with canvas wiring and deploy instructions
2026-09-18 23:23:05 +02:00

207 lines
7.0 KiB
JavaScript

'use strict';
/* eslint-disable no-param-reassign */
/**
* audioSelectorClamping
* Selects the best ITA and ENG audio tracks using codec+bitrate scoring,
* then calculates clamped Opus target bitrates.
*
* Scoring: score = (codecWeight * 10000) + bitrateKbps
* Weights: TrueHD=100, DTS-HD MA=95, FLAC=90, EAC3=75, DTS=60,
* AC3=45, AAC=35, Opus=30
*
* Clamping:
* Multichannel (ch > 2):
* Film: min(filmMultiChannelBitrate, sourceBitrateKbps)
* Series: sourceBitrateKbps >= threshold ? 384 : 256
* Stereo (ch <= 2):
* min(stereoBitrateMax, sourceBitrateKbps) [no upcoding]
*/
const CODEC_WEIGHTS = {
truehd: 100,
flac: 90,
eac3: 75,
dts: 60,
ac3: 45,
aac: 35,
opus: 30,
mp3: 20,
vorbis: 20,
};
const getCodecWeight = (stream) => {
const codec = (stream.codec_name || '').toLowerCase();
const profile = (stream.profile || '').toLowerCase();
// DTS-HD MA detection via profile field
if (codec === 'dts' && (profile.includes('ma') || profile.includes('dts-hd'))) return 95;
return CODEC_WEIGHTS[codec] || 5;
};
const getSourceBitrateKbps = (stream) => {
const br = parseInt(stream.bit_rate || '0', 10);
return br > 0 ? Math.round(br / 1000) : 0;
};
const calcOpusBitrate = (stream, isMovie, inputs) => {
const srcKbps = getSourceBitrateKbps(stream);
const ch = parseInt(stream.channels || '2', 10);
const stereoMax = parseInt(inputs.stereoBitrateMax || '128', 10);
const filmTarget = parseInt(inputs.filmMultiChannelBitrate || '640', 10);
const serThreshold = parseInt(inputs.seriesMultiChannelThreshold || '384', 10);
if (ch <= 2) {
// Stereo: min(max, source) — no upcoding
return srcKbps > 0 ? Math.min(stereoMax, srcKbps) : stereoMax;
}
// Multichannel
if (isMovie) {
return srcKbps > 0 ? Math.min(filmTarget, srcKbps) : filmTarget;
}
// Series
// If bitrate is unknown (0), assume high-quality source -> use 384k
const effectiveKbps = srcKbps > 0 ? srcKbps : serThreshold;
return effectiveKbps >= serThreshold ? 384 : 256;
};
const details = () => ({
name: 'Audio Selector & Opus Bitrate Clamping',
description:
'Selects the best ITA and ENG audio tracks using weighted codec+bitrate scoring. '
+ 'Calculates clamped Opus target bitrates. '
+ 'Output 1: tracks found. Output 2: no ITA or ENG tracks found.',
style: { borderColor: '#4CAF50' },
tags: 'audio',
isStartPlugin: false,
pType: '',
requiresVersion: '2.11.01',
sidebarPosition: 2,
icon: 'faVolumeUp',
inputs: [
{
label: 'Film Multichannel Bitrate (kbps)',
name: 'filmMultiChannelBitrate',
type: 'string',
defaultValue: '640',
inputUI: { type: 'text' },
tooltip: 'Max Opus bitrate in kbps for multichannel (>2ch) Film audio. Source bitrate is used if lower.',
},
{
label: 'Series Multichannel Threshold (kbps)',
name: 'seriesMultiChannelThreshold',
type: 'string',
defaultValue: '384',
inputUI: { type: 'text' },
tooltip: 'For series: if source bitrate >= this value, Opus target is 384k, otherwise 256k.',
},
{
label: 'Stereo Max Bitrate (kbps)',
name: 'stereoBitrateMax',
type: 'string',
defaultValue: '128',
inputUI: { type: 'text' },
tooltip: 'Max Opus bitrate for stereo tracks. Source bitrate is used if lower (no upcoding).',
},
],
outputs: [
{ number: 1, tooltip: 'ITA or ENG track found — continue pipeline' },
{ number: 2, tooltip: 'No ITA or ENG tracks found — handle separately' },
],
});
const plugin = async (args) => {
const { jobLog, inputFileObj, variables } = args;
const filePath = inputFileObj._id;
jobLog(`[audioSelectorClamping] Processing: ${filePath}\n`);
const isMovie = variables.isMovie === 'true';
jobLog(`[audioSelectorClamping] isMovie=${isMovie}\n`);
const streams = (inputFileObj.ffProbeData && inputFileObj.ffProbeData.streams) || [];
const audioStreams = streams.filter((s) => s.codec_type === 'audio');
jobLog(`[audioSelectorClamping] Found ${audioStreams.length} audio stream(s).\n`);
let bestIta = null;
let bestEng = null;
let bestItaScore = -1;
let bestEngScore = -1;
audioStreams.forEach((stream) => {
const lang = ((stream.tags && stream.tags.language) || '').toLowerCase();
if (lang !== 'ita' && lang !== 'eng' && lang !== 'it' && lang !== 'en') return;
const normLang = (lang === 'it') ? 'ita' : (lang === 'en') ? 'eng' : lang;
const weight = getCodecWeight(stream);
const srcKbps = getSourceBitrateKbps(stream);
const score = weight * 10000 + srcKbps;
jobLog(
`[audioSelectorClamping] Stream #${stream.index} lang=${normLang} `
+ `codec=${stream.codec_name} profile="${stream.profile || ''}" `
+ `src=${srcKbps}kbps ch=${stream.channels} score=${score}\n`,
);
if (normLang === 'ita' && score > bestItaScore) {
bestItaScore = score;
bestIta = { stream, normLang };
}
if (normLang === 'eng' && score > bestEngScore) {
bestEngScore = score;
bestEng = { stream, normLang };
}
});
if (!bestIta && !bestEng) {
jobLog('[audioSelectorClamping] WARNING: No ITA or ENG tracks found. Routing to output 2.\n');
variables.audioHasIta = 'false';
variables.audioHasEng = 'false';
return { outputNumber: 2, outputFileObj: inputFileObj, variables };
}
// Export ITA
if (bestIta) {
const { stream } = bestIta;
const opusBitrate = calcOpusBitrate(stream, isMovie, args.inputs);
variables.audioHasIta = 'true';
variables.audioItaStreamIndex = String(stream.index);
variables.audioItaOpusBitrate = String(opusBitrate);
variables.audioItaChannels = String(stream.channels || '2');
variables.audioItaCodecName = (stream.codec_name || '').toLowerCase();
jobLog(
`[audioSelectorClamping] ITA selected: #${stream.index} ${stream.codec_name} `
+ `${getSourceBitrateKbps(stream)}kbps -> Opus ${opusBitrate}kbps\n`,
);
} else {
variables.audioHasIta = 'false';
jobLog('[audioSelectorClamping] No ITA track found.\n');
}
// Export ENG
if (bestEng) {
const { stream } = bestEng;
const opusBitrate = calcOpusBitrate(stream, isMovie, args.inputs);
variables.audioHasEng = 'true';
variables.audioEngStreamIndex = String(stream.index);
variables.audioEngOpusBitrate = String(opusBitrate);
variables.audioEngChannels = String(stream.channels || '2');
variables.audioEngCodecName = (stream.codec_name || '').toLowerCase();
jobLog(
`[audioSelectorClamping] ENG selected: #${stream.index} ${stream.codec_name} `
+ `${getSourceBitrateKbps(stream)}kbps -> Opus ${opusBitrate}kbps\n`,
);
} else {
variables.audioHasEng = 'false';
jobLog('[audioSelectorClamping] No ENG track found.\n');
}
jobLog('[audioSelectorClamping] Done.\n');
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
};
module.exports.details = details;
module.exports.plugin = plugin;