Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- /* eslint-disable no-console */
- const details = () => ({
- id: 'Tdarr_Plugin_Custom_AAC_Only_Safe_V2',
- Stage: 'Pre-processing',
- Name: 'Convert to AAC Stereo (Safe + Comments Protected)',
- Type: 'Audio',
- Operation: 'Transcode',
- Description: 'Converts all audio to AAC (mono stays mono, everything else stereo) at 192kbps max, with a dialogue-friendly 5.1/7.1 downmix. Removes duplicate languages preferring the stereo track, but PROTECTS commentaries. Handles missing bitrate info safely.',
- Version: '2.5',
- Tags: 'pre-processing,ffmpeg,audio only,aac title cleanup,language deduplication',
- Inputs: [],
- });
- // An AAC track (mono or stereo) is re-encoded only if its bitrate exceeds the target by more than 15%.
- // The real bitrate after encoding never matches the target exactly (e.g. 128018 instead of 128000),
- // so an exact comparison would requeue the file forever and lower the quality at every pass.
- const TOLERANCE = 1.15;
- // Bitrate used when the source does not report one
- const SAFE_BITRATE_FALLBACK = 192000;
- const SAFE_BITRATE_FALLBACK_MONO = 96000;
- // The ffmpeg native AAC encoder does not accept sample rates above 96 kHz;
- // for stereo at max 192 kbps anything above 48 kHz is wasted anyway.
- const MAX_SAMPLE_RATE = 48000;
- // Image-based subtitles cannot be converted to mov_text, so they are skipped in MP4
- const IMAGE_SUBS = ['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvb_subtitle', 'xsub'];
- // mkvmerge statistics tags cleared on re-encoded tracks so they do not keep describing the old track
- const STALE_STAT_TAGS = ['BPS', 'BPS-eng', 'NUMBER_OF_BYTES', 'NUMBER_OF_BYTES-eng', 'NUMBER_OF_FRAMES', 'NUMBER_OF_FRAMES-eng'];
- // Containers where AAC cannot be stored
- const UNSUPPORTED_CONTAINERS = ['webm'];
- // Dialogue-friendly downmix, keyed by ffprobe channel_layout.
- // The standard -ac 2 matrix puts the center (dialogue) at -3 dB and sums fronts + surrounds,
- // so dialogue ends up about 4.6 dB below effects. Here the center gets full weight,
- // fronts 0.707 and surrounds 0.5; LFE is dropped as in the standard downmix.
- // "<" normalizes the gains so the mix can never clip (overall level is a few dB lower).
- // Layouts not listed here fall back to the standard -ac 2 downmix.
- const DOWNMIX = {
- '5.1': 'pan=stereo|FL<1.0*FC+0.707*FL+0.5*BL|FR<1.0*FC+0.707*FR+0.5*BR',
- '5.1(side)': 'pan=stereo|FL<1.0*FC+0.707*FL+0.5*SL|FR<1.0*FC+0.707*FR+0.5*SR',
- '7.1': 'pan=stereo|FL<1.0*FC+0.707*FL+0.5*SL+0.5*BL|FR<1.0*FC+0.707*FR+0.5*SR+0.5*BR',
- };
- // Reads a stream tag, also checking language-suffixed variants (e.g. BPS-eng)
- const getTag = (s, name) => {
- const tags = s.tags || {};
- if (tags[name] !== undefined) return tags[name];
- const key = Object.keys(tags).find((k) => k.toUpperCase().startsWith(`${name.toUpperCase()}-`));
- return key ? tags[key] : undefined;
- };
- // Real stream bitrate measured by ffprobe (always reliable, often missing in MKV)
- const getStreamBitrate = (s) => parseInt(s.bit_rate, 10) || 0;
- // BPS statistics tag written by mkvmerge. It can be STALE: ffmpeg copies it unchanged
- // onto a re-encoded track, so it is never used to decide whether to re-encode AAC.
- const getTagBitrate = (s) => parseInt(getTag(s, 'BPS'), 10) || 0;
- // Stream duration in seconds: ffprobe duration, falling back to the MKV DURATION tag (HH:MM:SS.nnnnnnnnn)
- const getDuration = (s) => {
- const d = parseFloat(s.duration);
- if (!Number.isNaN(d) && d > 0) return d;
- const tag = getTag(s, 'DURATION');
- if (!tag) return 0;
- const parts = String(tag).split(':').map(Number);
- if (parts.length !== 3 || parts.some(Number.isNaN)) return 0;
- return parts[0] * 3600 + parts[1] * 60 + parts[2];
- };
- const isAudio = (s) => s.codec_type && s.codec_type.toLowerCase() === 'audio';
- const isCommentaryTitle = (title) => /comment|director|commento/i.test(title || '');
- const plugin = (file, librarySettings, inputs, otherArguments) => {
- const lib = require('../methods/lib')();
- // eslint-disable-next-line no-param-reassign
- inputs = lib.loadDefaultValues(inputs, details);
- const response = {
- processFile: false,
- container: `.${file.container}`,
- handBrakeMode: false,
- FFmpegMode: true,
- reQueueAfter: true,
- infoLog: '',
- };
- try {
- // --- Language labels used for track titles ---
- const langMap = {
- ita: 'Italiano', eng: 'English', jpn: 'Giapponese', kor: 'Coreano',
- fra: 'Francese', spa: 'Spagnolo', deu: 'Tedesco', ger: 'Tedesco',
- por: 'Portoghese', nld: 'Olandese', pol: 'Polacco', ron: 'Rumeno',
- ces: 'Ceco', cze: 'Ceco', hun: 'Ungherese', swe: 'Svedese',
- fin: 'Finlandese', nor: 'Norvegese', dan: 'Danese', ell: 'Greco',
- gre: 'Greco', srp: 'Serbo', hrv: 'Croato', slv: 'Sloveno',
- bul: 'Bulgaro', ukr: 'Ucraino', rus: 'Russo', und: 'Sconosciuto',
- };
- const getLangLabel = (code, mono) => {
- const cleanCode = (code || 'und').toLowerCase();
- // Keep only letters so an odd language tag cannot break the quoted title
- const langName = langMap[cleanCode] || cleanCode.replace(/[^a-z]/g, '').toUpperCase() || 'UND';
- return `${langName} (AAC ${mono ? 'Mono' : 'Stereo'})`;
- };
- const streams = file.ffProbeData.streams || [];
- const container = String(file.container).toLowerCase();
- const isMp4 = ['mp4', 'm4v', 'mov'].includes(container);
- const isMkv = container === 'mkv';
- if (UNSUPPORTED_CONTAINERS.includes(container)) {
- response.infoLog += `⚠ Container ${container} non supporta AAC. File ignorato.\n`;
- return response;
- }
- const audioStreams = streams.filter(isAudio);
- if (audioStreams.length === 0) {
- response.infoLog += '⚠ Nessuna traccia audio trovata nel file.\n';
- return response;
- }
- // --- Smart deduplication, decided before building the command ---
- // Tracks with the same language and a duration within 2 seconds form a group.
- // In each group the first STEREO track is kept (multichannel is dropped); if the group has
- // no stereo track, the first track is kept. Commentaries, 'und' tracks and tracks with
- // unknown duration are never removed (safety first).
- const groups = [];
- audioStreams.forEach((s) => {
- const lang = (getTag(s, 'language') || 'und').toLowerCase();
- const duration = getDuration(s);
- if (lang === 'und' || duration <= 0 || isCommentaryTitle(getTag(s, 'title'))) return;
- const group = groups.find((g) => g.lang === lang && Math.abs(g.duration - duration) <= 2);
- if (group) group.members.push(s);
- else groups.push({ lang, duration, members: [s] });
- });
- const replacedBy = new Map(); // removed stream index -> kept stream index
- const giveDefault = new Set(); // kept stream indexes that inherit the default flag
- groups.forEach((g) => {
- if (g.members.length < 2) return;
- const winner = g.members.find((m) => m.channels === 2) || g.members[0];
- g.members.forEach((m) => {
- if (m === winner) return;
- replacedBy.set(m.index, winner.index);
- // The removed track was the default one: move the flag to the track that replaces it
- if (m.disposition && m.disposition.default === 1
- && !(winner.disposition && winner.disposition.default === 1)) {
- giveDefault.add(winner.index);
- }
- });
- });
- // "?" tolerates files without a video track
- let ffmpegCommandInsert = '-map 0:v? -c:v copy ';
- let audioIdx = 0;
- let convert = false;
- for (let i = 0; i < streams.length; i++) {
- const s = streams[i];
- if (!isAudio(s)) continue;
- const codec = (s.codec_name || 'unknown').toLowerCase();
- const channels = s.channels || 2;
- const lang = getTag(s, 'language') || 'und';
- const isCommentary = isCommentaryTitle(getTag(s, 'title'));
- if (replacedBy.has(s.index)) {
- response.infoLog += `🔁 Skipping duplicate language '${lang}' (Stream ${i}, ${channels}ch) - tenuta la traccia Stream ${replacedBy.get(s.index)}.\n`;
- // Removing a track is a change too: without this the duplicate stays
- // whenever all the other tracks are already compliant
- convert = true;
- continue;
- }
- // Mono stays mono, everything else becomes stereo
- const isMono = (channels === 1);
- const outCh = isMono ? 1 : 2;
- const isAac = (codec === 'aac');
- const aacCopyCandidate = isAac && channels <= 2;
- // Sample rate from the source, capped at 48 kHz (see MAX_SAMPLE_RATE)
- const srcRate = parseInt(s.sample_rate, 10) || 48000;
- const sampleRate = Math.min(srcRate, MAX_SAMPLE_RATE);
- // AAC mono/stereo: only the real stream bitrate counts (a stale BPS tag would cause a requeue loop).
- // Other codecs are re-encoded anyway, so the BPS tag is fine to pick the target bitrate.
- const streamBitrate = getStreamBitrate(s);
- let bitrate = aacCopyCandidate ? streamBitrate : (streamBitrate || getTagBitrate(s));
- const bitrateKnown = bitrate > 0;
- if (!bitrateKnown) {
- const fallback = isMono ? SAFE_BITRATE_FALLBACK_MONO : SAFE_BITRATE_FALLBACK;
- response.infoLog += `⚠ Stream ${i} (${codec}): Bitrate non rilevato. `;
- response.infoLog += aacCopyCandidate
- ? 'Traccia AAC copiata senza ricodifica.\n'
- : `Imposto standard ${fallback / 1000}kbps.\n`;
- bitrate = fallback;
- }
- let newLabel = getLangLabel(lang, isMono);
- if (isCommentary) {
- newLabel += ' [Commentary]';
- response.infoLog += `🎙 Commentary track detected (Stream ${i})\n`;
- }
- let targetBitrate;
- if (bitrate >= 192000) targetBitrate = 192000;
- else if (bitrate >= 128000) targetBitrate = 128000;
- else targetBitrate = bitrate;
- // AAC mono/stereo is copied unless its known bitrate is clearly above the target
- let shouldEncode = true;
- if (aacCopyCandidate) {
- shouldEncode = bitrateKnown && bitrate > targetBitrate * TOLERANCE;
- }
- if (shouldEncode) {
- ffmpegCommandInsert += `-map 0:${s.index} -c:a:${audioIdx} aac `;
- // Per-stream options so they apply only to this output track
- const downmix = channels > 2 ? DOWNMIX[s.channel_layout] : undefined;
- if (downmix) {
- ffmpegCommandInsert += `-filter:a:${audioIdx} "${downmix}" `;
- } else {
- ffmpegCommandInsert += `-ac:a:${audioIdx} ${outCh} `;
- if (channels > 2) {
- response.infoLog += `⚠ Stream ${i}: layout '${s.channel_layout || 'sconosciuto'}' senza downmix dedicato, uso quello standard.\n`;
- }
- }
- ffmpegCommandInsert += `-ar:a:${audioIdx} ${sampleRate} -b:a:${audioIdx} ${targetBitrate} `;
- if (isMkv) {
- STALE_STAT_TAGS.forEach((t) => { ffmpegCommandInsert += `-metadata:s:a:${audioIdx} ${t}= `; });
- }
- convert = true;
- response.infoLog += `✅ Keep Stream ${s.index} (${lang}/${codec}/${channels}ch) -> aac-${targetBitrate}bps${downmix ? ' dialogue downmix' : ''} [${newLabel}]\n`;
- } else {
- ffmpegCommandInsert += `-map 0:${s.index} -c:a:${audioIdx} copy `;
- response.infoLog += `✅ Keep Stream ${s.index} (${lang}/${codec}/${channels}ch) -> copy @ ${bitrateKnown ? bitrate : 'unknown'}bps [${newLabel}]\n`;
- }
- if (giveDefault.has(s.index)) {
- ffmpegCommandInsert += `-disposition:a:${audioIdx} default `;
- response.infoLog += `⭐ Stream ${s.index} diventa la traccia predefinita (quella rimossa era default).\n`;
- }
- ffmpegCommandInsert += `-metadata:s:a:${audioIdx} title="${newLabel}" `;
- audioIdx++;
- }
- if (audioIdx === 0) {
- response.infoLog += '⚠ Tutti gli stream audio saltati? Recupero il primo stream per sicurezza.\n';
- const s = audioStreams[0];
- const safeSampleRate = Math.min(parseInt(s.sample_rate, 10) || 48000, MAX_SAMPLE_RATE);
- ffmpegCommandInsert += `-map 0:${s.index} -c:a:0 aac -ac:a:0 2 -ar:a:0 ${safeSampleRate} -b:a:0 128k -metadata:s:a:0 title="Unknown (Rescue)" `;
- convert = true;
- }
- // --- Subtitles and attachments ---
- if (isMp4) {
- // MP4 only supports text subtitles as mov_text
- let subIdx = 0;
- for (let i = 0; i < streams.length; i++) {
- const s = streams[i];
- if (!s.codec_type || s.codec_type.toLowerCase() !== 'subtitle') continue;
- if (IMAGE_SUBS.includes(s.codec_name)) {
- response.infoLog += `⚠ Sottotitolo ${s.codec_name} (Stream ${i}) rimosso: non supportato in MP4.\n`;
- } else {
- ffmpegCommandInsert += `-map 0:${i} -c:s:${subIdx} mov_text `;
- subIdx++;
- }
- }
- } else {
- // MKV: copy every subtitle and keep font attachments (needed by ASS subtitles)
- ffmpegCommandInsert += '-map 0:s? -c:s copy -map 0:t? -c:t copy ';
- }
- if (convert) {
- response.processFile = true;
- response.preset = `, ${ffmpegCommandInsert}-max_muxing_queue_size 9999 `;
- } else {
- response.infoLog += '☑ File già conforme. Nessuna conversione necessaria.\n';
- }
- } catch (err) {
- response.processFile = false;
- response.infoLog += `❌ CRITICAL ERROR: ${err.message}\n${err.stack}`;
- return response;
- }
- return response;
- };
- module.exports.details = details;
- module.exports.plugin = plugin;
Advertisement
Add Comment
Please, Sign In to add comment