Harakiri88

AAC Plugin Tdarr

Apr 8th, 2025 (edited)
392
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 14.43 KB | None | 0 0
  1. /* eslint-disable no-console */
  2. const details = () => ({
  3. id: 'Tdarr_Plugin_Custom_AAC_Only_Safe_V2',
  4. Stage: 'Pre-processing',
  5. Name: 'Convert to AAC Stereo (Safe + Comments Protected)',
  6. Type: 'Audio',
  7. Operation: 'Transcode',
  8. 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.',
  9. Version: '2.5',
  10. Tags: 'pre-processing,ffmpeg,audio only,aac title cleanup,language deduplication',
  11. Inputs: [],
  12. });
  13.  
  14. // An AAC track (mono or stereo) is re-encoded only if its bitrate exceeds the target by more than 15%.
  15. // The real bitrate after encoding never matches the target exactly (e.g. 128018 instead of 128000),
  16. // so an exact comparison would requeue the file forever and lower the quality at every pass.
  17. const TOLERANCE = 1.15;
  18.  
  19. // Bitrate used when the source does not report one
  20. const SAFE_BITRATE_FALLBACK = 192000;
  21. const SAFE_BITRATE_FALLBACK_MONO = 96000;
  22.  
  23. // The ffmpeg native AAC encoder does not accept sample rates above 96 kHz;
  24. // for stereo at max 192 kbps anything above 48 kHz is wasted anyway.
  25. const MAX_SAMPLE_RATE = 48000;
  26.  
  27. // Image-based subtitles cannot be converted to mov_text, so they are skipped in MP4
  28. const IMAGE_SUBS = ['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvb_subtitle', 'xsub'];
  29.  
  30. // mkvmerge statistics tags cleared on re-encoded tracks so they do not keep describing the old track
  31. const STALE_STAT_TAGS = ['BPS', 'BPS-eng', 'NUMBER_OF_BYTES', 'NUMBER_OF_BYTES-eng', 'NUMBER_OF_FRAMES', 'NUMBER_OF_FRAMES-eng'];
  32.  
  33. // Containers where AAC cannot be stored
  34. const UNSUPPORTED_CONTAINERS = ['webm'];
  35.  
  36. // Dialogue-friendly downmix, keyed by ffprobe channel_layout.
  37. // The standard -ac 2 matrix puts the center (dialogue) at -3 dB and sums fronts + surrounds,
  38. // so dialogue ends up about 4.6 dB below effects. Here the center gets full weight,
  39. // fronts 0.707 and surrounds 0.5; LFE is dropped as in the standard downmix.
  40. // "<" normalizes the gains so the mix can never clip (overall level is a few dB lower).
  41. // Layouts not listed here fall back to the standard -ac 2 downmix.
  42. const DOWNMIX = {
  43. '5.1': 'pan=stereo|FL<1.0*FC+0.707*FL+0.5*BL|FR<1.0*FC+0.707*FR+0.5*BR',
  44. '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',
  45. '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',
  46. };
  47.  
  48. // Reads a stream tag, also checking language-suffixed variants (e.g. BPS-eng)
  49. const getTag = (s, name) => {
  50. const tags = s.tags || {};
  51. if (tags[name] !== undefined) return tags[name];
  52. const key = Object.keys(tags).find((k) => k.toUpperCase().startsWith(`${name.toUpperCase()}-`));
  53. return key ? tags[key] : undefined;
  54. };
  55.  
  56. // Real stream bitrate measured by ffprobe (always reliable, often missing in MKV)
  57. const getStreamBitrate = (s) => parseInt(s.bit_rate, 10) || 0;
  58.  
  59. // BPS statistics tag written by mkvmerge. It can be STALE: ffmpeg copies it unchanged
  60. // onto a re-encoded track, so it is never used to decide whether to re-encode AAC.
  61. const getTagBitrate = (s) => parseInt(getTag(s, 'BPS'), 10) || 0;
  62.  
  63. // Stream duration in seconds: ffprobe duration, falling back to the MKV DURATION tag (HH:MM:SS.nnnnnnnnn)
  64. const getDuration = (s) => {
  65. const d = parseFloat(s.duration);
  66. if (!Number.isNaN(d) && d > 0) return d;
  67. const tag = getTag(s, 'DURATION');
  68. if (!tag) return 0;
  69. const parts = String(tag).split(':').map(Number);
  70. if (parts.length !== 3 || parts.some(Number.isNaN)) return 0;
  71. return parts[0] * 3600 + parts[1] * 60 + parts[2];
  72. };
  73.  
  74. const isAudio = (s) => s.codec_type && s.codec_type.toLowerCase() === 'audio';
  75. const isCommentaryTitle = (title) => /comment|director|commento/i.test(title || '');
  76.  
  77. const plugin = (file, librarySettings, inputs, otherArguments) => {
  78. const lib = require('../methods/lib')();
  79. // eslint-disable-next-line no-param-reassign
  80. inputs = lib.loadDefaultValues(inputs, details);
  81.  
  82. const response = {
  83. processFile: false,
  84. container: `.${file.container}`,
  85. handBrakeMode: false,
  86. FFmpegMode: true,
  87. reQueueAfter: true,
  88. infoLog: '',
  89. };
  90.  
  91. try {
  92. // --- Language labels used for track titles ---
  93. const langMap = {
  94. ita: 'Italiano', eng: 'English', jpn: 'Giapponese', kor: 'Coreano',
  95. fra: 'Francese', spa: 'Spagnolo', deu: 'Tedesco', ger: 'Tedesco',
  96. por: 'Portoghese', nld: 'Olandese', pol: 'Polacco', ron: 'Rumeno',
  97. ces: 'Ceco', cze: 'Ceco', hun: 'Ungherese', swe: 'Svedese',
  98. fin: 'Finlandese', nor: 'Norvegese', dan: 'Danese', ell: 'Greco',
  99. gre: 'Greco', srp: 'Serbo', hrv: 'Croato', slv: 'Sloveno',
  100. bul: 'Bulgaro', ukr: 'Ucraino', rus: 'Russo', und: 'Sconosciuto',
  101. };
  102.  
  103. const getLangLabel = (code, mono) => {
  104. const cleanCode = (code || 'und').toLowerCase();
  105. // Keep only letters so an odd language tag cannot break the quoted title
  106. const langName = langMap[cleanCode] || cleanCode.replace(/[^a-z]/g, '').toUpperCase() || 'UND';
  107. return `${langName} (AAC ${mono ? 'Mono' : 'Stereo'})`;
  108. };
  109.  
  110. const streams = file.ffProbeData.streams || [];
  111. const container = String(file.container).toLowerCase();
  112. const isMp4 = ['mp4', 'm4v', 'mov'].includes(container);
  113. const isMkv = container === 'mkv';
  114.  
  115. if (UNSUPPORTED_CONTAINERS.includes(container)) {
  116. response.infoLog += `⚠ Container ${container} non supporta AAC. File ignorato.\n`;
  117. return response;
  118. }
  119.  
  120. const audioStreams = streams.filter(isAudio);
  121.  
  122. if (audioStreams.length === 0) {
  123. response.infoLog += '⚠ Nessuna traccia audio trovata nel file.\n';
  124. return response;
  125. }
  126.  
  127. // --- Smart deduplication, decided before building the command ---
  128. // Tracks with the same language and a duration within 2 seconds form a group.
  129. // In each group the first STEREO track is kept (multichannel is dropped); if the group has
  130. // no stereo track, the first track is kept. Commentaries, 'und' tracks and tracks with
  131. // unknown duration are never removed (safety first).
  132. const groups = [];
  133. audioStreams.forEach((s) => {
  134. const lang = (getTag(s, 'language') || 'und').toLowerCase();
  135. const duration = getDuration(s);
  136. if (lang === 'und' || duration <= 0 || isCommentaryTitle(getTag(s, 'title'))) return;
  137. const group = groups.find((g) => g.lang === lang && Math.abs(g.duration - duration) <= 2);
  138. if (group) group.members.push(s);
  139. else groups.push({ lang, duration, members: [s] });
  140. });
  141.  
  142. const replacedBy = new Map(); // removed stream index -> kept stream index
  143. const giveDefault = new Set(); // kept stream indexes that inherit the default flag
  144. groups.forEach((g) => {
  145. if (g.members.length < 2) return;
  146. const winner = g.members.find((m) => m.channels === 2) || g.members[0];
  147. g.members.forEach((m) => {
  148. if (m === winner) return;
  149. replacedBy.set(m.index, winner.index);
  150. // The removed track was the default one: move the flag to the track that replaces it
  151. if (m.disposition && m.disposition.default === 1
  152. && !(winner.disposition && winner.disposition.default === 1)) {
  153. giveDefault.add(winner.index);
  154. }
  155. });
  156. });
  157.  
  158. // "?" tolerates files without a video track
  159. let ffmpegCommandInsert = '-map 0:v? -c:v copy ';
  160. let audioIdx = 0;
  161. let convert = false;
  162.  
  163. for (let i = 0; i < streams.length; i++) {
  164. const s = streams[i];
  165. if (!isAudio(s)) continue;
  166.  
  167. const codec = (s.codec_name || 'unknown').toLowerCase();
  168. const channels = s.channels || 2;
  169. const lang = getTag(s, 'language') || 'und';
  170. const isCommentary = isCommentaryTitle(getTag(s, 'title'));
  171.  
  172. if (replacedBy.has(s.index)) {
  173. response.infoLog += `🔁 Skipping duplicate language '${lang}' (Stream ${i}, ${channels}ch) - tenuta la traccia Stream ${replacedBy.get(s.index)}.\n`;
  174. // Removing a track is a change too: without this the duplicate stays
  175. // whenever all the other tracks are already compliant
  176. convert = true;
  177. continue;
  178. }
  179.  
  180. // Mono stays mono, everything else becomes stereo
  181. const isMono = (channels === 1);
  182. const outCh = isMono ? 1 : 2;
  183. const isAac = (codec === 'aac');
  184. const aacCopyCandidate = isAac && channels <= 2;
  185.  
  186. // Sample rate from the source, capped at 48 kHz (see MAX_SAMPLE_RATE)
  187. const srcRate = parseInt(s.sample_rate, 10) || 48000;
  188. const sampleRate = Math.min(srcRate, MAX_SAMPLE_RATE);
  189.  
  190. // AAC mono/stereo: only the real stream bitrate counts (a stale BPS tag would cause a requeue loop).
  191. // Other codecs are re-encoded anyway, so the BPS tag is fine to pick the target bitrate.
  192. const streamBitrate = getStreamBitrate(s);
  193. let bitrate = aacCopyCandidate ? streamBitrate : (streamBitrate || getTagBitrate(s));
  194. const bitrateKnown = bitrate > 0;
  195. if (!bitrateKnown) {
  196. const fallback = isMono ? SAFE_BITRATE_FALLBACK_MONO : SAFE_BITRATE_FALLBACK;
  197. response.infoLog += `⚠ Stream ${i} (${codec}): Bitrate non rilevato. `;
  198. response.infoLog += aacCopyCandidate
  199. ? 'Traccia AAC copiata senza ricodifica.\n'
  200. : `Imposto standard ${fallback / 1000}kbps.\n`;
  201. bitrate = fallback;
  202. }
  203.  
  204. let newLabel = getLangLabel(lang, isMono);
  205. if (isCommentary) {
  206. newLabel += ' [Commentary]';
  207. response.infoLog += `🎙 Commentary track detected (Stream ${i})\n`;
  208. }
  209.  
  210. let targetBitrate;
  211. if (bitrate >= 192000) targetBitrate = 192000;
  212. else if (bitrate >= 128000) targetBitrate = 128000;
  213. else targetBitrate = bitrate;
  214.  
  215. // AAC mono/stereo is copied unless its known bitrate is clearly above the target
  216. let shouldEncode = true;
  217. if (aacCopyCandidate) {
  218. shouldEncode = bitrateKnown && bitrate > targetBitrate * TOLERANCE;
  219. }
  220.  
  221. if (shouldEncode) {
  222. ffmpegCommandInsert += `-map 0:${s.index} -c:a:${audioIdx} aac `;
  223. // Per-stream options so they apply only to this output track
  224. const downmix = channels > 2 ? DOWNMIX[s.channel_layout] : undefined;
  225. if (downmix) {
  226. ffmpegCommandInsert += `-filter:a:${audioIdx} "${downmix}" `;
  227. } else {
  228. ffmpegCommandInsert += `-ac:a:${audioIdx} ${outCh} `;
  229. if (channels > 2) {
  230. response.infoLog += `⚠ Stream ${i}: layout '${s.channel_layout || 'sconosciuto'}' senza downmix dedicato, uso quello standard.\n`;
  231. }
  232. }
  233. ffmpegCommandInsert += `-ar:a:${audioIdx} ${sampleRate} -b:a:${audioIdx} ${targetBitrate} `;
  234. if (isMkv) {
  235. STALE_STAT_TAGS.forEach((t) => { ffmpegCommandInsert += `-metadata:s:a:${audioIdx} ${t}= `; });
  236. }
  237. convert = true;
  238. response.infoLog += `✅ Keep Stream ${s.index} (${lang}/${codec}/${channels}ch) -> aac-${targetBitrate}bps${downmix ? ' dialogue downmix' : ''} [${newLabel}]\n`;
  239. } else {
  240. ffmpegCommandInsert += `-map 0:${s.index} -c:a:${audioIdx} copy `;
  241. response.infoLog += `✅ Keep Stream ${s.index} (${lang}/${codec}/${channels}ch) -> copy @ ${bitrateKnown ? bitrate : 'unknown'}bps [${newLabel}]\n`;
  242. }
  243.  
  244. if (giveDefault.has(s.index)) {
  245. ffmpegCommandInsert += `-disposition:a:${audioIdx} default `;
  246. response.infoLog += `⭐ Stream ${s.index} diventa la traccia predefinita (quella rimossa era default).\n`;
  247. }
  248.  
  249. ffmpegCommandInsert += `-metadata:s:a:${audioIdx} title="${newLabel}" `;
  250. audioIdx++;
  251. }
  252.  
  253. if (audioIdx === 0) {
  254. response.infoLog += '⚠ Tutti gli stream audio saltati? Recupero il primo stream per sicurezza.\n';
  255. const s = audioStreams[0];
  256. const safeSampleRate = Math.min(parseInt(s.sample_rate, 10) || 48000, MAX_SAMPLE_RATE);
  257. 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)" `;
  258. convert = true;
  259. }
  260.  
  261. // --- Subtitles and attachments ---
  262. if (isMp4) {
  263. // MP4 only supports text subtitles as mov_text
  264. let subIdx = 0;
  265. for (let i = 0; i < streams.length; i++) {
  266. const s = streams[i];
  267. if (!s.codec_type || s.codec_type.toLowerCase() !== 'subtitle') continue;
  268. if (IMAGE_SUBS.includes(s.codec_name)) {
  269. response.infoLog += `⚠ Sottotitolo ${s.codec_name} (Stream ${i}) rimosso: non supportato in MP4.\n`;
  270. } else {
  271. ffmpegCommandInsert += `-map 0:${i} -c:s:${subIdx} mov_text `;
  272. subIdx++;
  273. }
  274. }
  275. } else {
  276. // MKV: copy every subtitle and keep font attachments (needed by ASS subtitles)
  277. ffmpegCommandInsert += '-map 0:s? -c:s copy -map 0:t? -c:t copy ';
  278. }
  279.  
  280. if (convert) {
  281. response.processFile = true;
  282. response.preset = `, ${ffmpegCommandInsert}-max_muxing_queue_size 9999 `;
  283. } else {
  284. response.infoLog += '☑ File già conforme. Nessuna conversione necessaria.\n';
  285. }
  286. } catch (err) {
  287. response.processFile = false;
  288. response.infoLog += `❌ CRITICAL ERROR: ${err.message}\n${err.stack}`;
  289. return response;
  290. }
  291.  
  292. return response;
  293. };
  294.  
  295. module.exports.details = details;
  296. module.exports.plugin = plugin;
Advertisement
Add Comment
Please, Sign In to add comment