diff --git a/fileflows/scripts/05_Finalize_Move.js b/fileflows/scripts/05_Finalize_Move.js index 74d7f88..e997280 100644 --- a/fileflows/scripts/05_Finalize_Move.js +++ b/fileflows/scripts/05_Finalize_Move.js @@ -26,8 +26,7 @@ function Script(SourceRoot, DestRoot) Flow.Execute({ command: '/bin/mkdir', argumentList: ['-p', destDir] }); - // Wenn Ziel schon existiert: explizit loggen und ueberschreiben (typisch bei Re-Encode - // gleichen Films aus altem Run). Kein -n, sonst geht der frische Encode beim Cleanup verloren. + // Wenn Ziel schon existiert: explizit loggen und ueberschreiben let testExist = Flow.Execute({ command: '/usr/bin/test', argumentList: ['-f', destAbs] }); if (testExist && testExist.exitCode === 0) { let oldSize = Flow.Execute({ command: '/usr/bin/stat', argumentList: ['-c', '%s', destAbs] }); @@ -50,7 +49,7 @@ function Script(SourceRoot, DestRoot) Logger.ILog('Deleted original: ' + origPath); } - // ═════════════════════════════════════════════════════════════════════════ + // ======================================================================= // UNTERTITEL-HANDLING (greift fuer ALLE Zweige: AV1, HEVC, reiner Copy) // Ziel: Emby-Auto-Burn-Transcode wegen Untertiteln verhindern. // - Text-Subs (srt/ass): als externe .srt-Sidecar rausziehen UND aus MKV entfernen. @@ -60,7 +59,14 @@ function Script(SourceRoot, DestRoot) // - Forced-Textspur -> eigene .forced.srt. // Remux (Container-only, -c copy) laeuft NUR wenn noetig (Text-Sub vorhanden oder // ein Bild-Sub traegt default/forced). Sonst wird das Video nicht angefasst. - // ═════════════════════════════════════════════════════════════════════════ + // + // SICHERHEITS-FIX 17.07.2026: + // - Sidecar wird atomar erzeugt (.tmp-GUID, dann rename bei Erfolg + 0-Byte-Pruefung) + // - NUR erfolgreich exportierte Textspuren werden beim Remux entfernt + // - Fehlgeschlagene Textspuren bleiben eingebettet + // - Remux-Ergebnis wird per ffprobe verifiziert vor Ersetzen + // - Alle Temp-Dateien werden in Fehlerpfaden sicher aufgeraeumt + // ======================================================================= try { handleSubtitles(destAbs); } catch (e) { @@ -69,7 +75,7 @@ function Script(SourceRoot, DestRoot) return 1; - // ── Helfer ─────────────────────────────────────────────────────────────── + // -- Helfer -------------------------------------------------------------- function handleSubtitles(file) { var ffprobe = Flow.GetToolPath('ffprobe') || Variables['ffprobe'] || 'ffprobe'; var ffmpeg = Flow.GetToolPath('ffmpeg') || Variables['ffmpeg'] || 'ffmpeg'; @@ -91,7 +97,7 @@ function Script(SourceRoot, DestRoot) var disp = s.disposition || {}; var tags = s.tags || {}; subs.push({ - sIdx: sidx, // relativer -map 0:s:N Index + sIdx: sidx, codec: codec, isText: textCodecs.indexOf(codec) !== -1, lang: ('' + (tags.language || 'und')).toLowerCase(), @@ -106,45 +112,102 @@ function Script(SourceRoot, DestRoot) var textSubs = subs.filter(function (s) { return s.isText; }); var imgSubs = subs.filter(function (s) { return !s.isText; }); - // 1) Text-Subs als .srt-Sidecar extrahieren (jeweils eigener ffmpeg-Call, Main unangetastet) - var base = file.replace(/\.[^./]+$/, ''); // Pfad ohne Extension - var seen = {}; // lang+forced -> Zaehler (Mehrfachspuren) + // 1) Text-Subs als .srt-Sidecar extrahieren + // ATOMAR: erst .tmp-GUID, dann rename bei Erfolg + Groessenpruefung + // Nur erfolgreiche werden spaeter aus dem MKV entfernt + var base = file.replace(/\.[^./]+$/, ''); + var seen = {}; + var successfullyExtracted = []; // sIdx-Werte der erfolgreich extrahierten Textspuren + textSubs.forEach(function (s) { var key = s.lang + (s.forced ? '.forced' : ''); var n = seen[key] || 0; seen[key] = n + 1; var suffix = '.' + s.lang + (s.forced ? '.forced' : '') + (n > 0 ? '.' + n : ''); var sidecar = base + suffix + '.srt'; + var tmpSidecar = Flow.TempPath + '/' + Flow.NewGuid() + '.srt'; + var ex = Flow.Execute({ command: ffmpeg, argumentList: ['-hide_banner', '-y', '-i', file, - '-map', '0:s:' + s.sIdx, '-c:s', 'srt', sidecar] + '-map', '0:s:' + s.sIdx, '-c:s', 'srt', tmpSidecar] }); - if (ex && ex.exitCode === 0) Logger.ILog('Sub: Sidecar extrahiert -> ' + sidecar); - else Logger.WLog('Sub: Sidecar-Extraktion fehlgeschlagen (0:s:' + s.sIdx + ', ' + s.codec + ')'); + + // Pruefung: exit code 0, Datei existiert, Groesse > 0 + var ok = false; + if (ex && ex.exitCode === 0) { + var stat = Flow.Execute({ command: '/usr/bin/stat', argumentList: ['-c', '%s', tmpSidecar] }); + var sizeStr = (stat && stat.standardOutput) ? stat.standardOutput.trim() : '0'; + var size = parseInt(sizeStr, 10) || 0; + if (size > 0) { + // Atomar umbenennen + var rn = Flow.Execute({ command: '/bin/mv', argumentList: ['-f', tmpSidecar, sidecar] }); + if (rn && rn.exitCode === 0) { + ok = true; + Logger.ILog('Sub: Sidecar extrahiert -> ' + sidecar + ' (' + size + ' bytes)'); + successfullyExtracted.push(s.sIdx); + } else { + Logger.WLog('Sub: Rename fehlgeschlagen fuer 0:s:' + s.sIdx + ' - Temp bleibt, Spur bleibt eingebettet'); + Flow.Execute({ command: '/bin/rm', argumentList: ['-f', tmpSidecar] }); + } + } else { + Logger.WLog('Sub: Sidecar 0 bytes (0:s:' + s.sIdx + ', ' + s.codec + ') - Spur bleibt eingebettet'); + Flow.Execute({ command: '/bin/rm', argumentList: ['-f', tmpSidecar] }); + } + } else { + Logger.WLog('Sub: Extraktion fehlgeschlagen (0:s:' + s.sIdx + ', ' + s.codec + ', exit=' + (ex ? ex.exitCode : '?') + ') - Spur bleibt eingebettet'); + Flow.Execute({ command: '/bin/rm', argumentList: ['-f', tmpSidecar] }); + } }); - // 2) Remux nur wenn noetig: Text-Subs raus ODER ein Bild-Sub traegt default/forced. + // 2) Remux nur wenn noetig: + // a) mindestens eine Textspur erfolgreich extrahiert -> diese aus dem MKV entfernen + // b) ein Bild-Sub traegt default/forced -> Disposition neutralisieren + // Fehlgeschlagene Textspuren bleiben IMMER eingebettet var badImgDisp = imgSubs.some(function (s) { return s.isDefault || s.forced; }); - if (textSubs.length === 0 && !badImgDisp) { - Logger.ILog('Sub: nur Bild-Subs ohne default/forced -> kein Remux noetig.'); + var needRemux = (successfullyExtracted.length > 0) || badImgDisp; + + if (!needRemux) { + Logger.ILog('Sub: kein Remux noetig (keine erfolgreichen Text-Extraktionen, keine Bild-Sub-Flags).'); return; } + // Map fuer zu entfernende Textspuren (nur erfolgreich extrahierte) + var removeTextIdx = successfullyExtracted; + + // Build ffmpeg args: Video + Audio + Attachments 1:1, Bild-Subs behalten, + // fehlgeschlagene Text-Subs behalten, erfolgreiche Text-Subs weglassen var tmp = Flow.TempPath + '/' + Flow.NewGuid() + '.mkv'; - // Video + Audio + Attachments 1:1; Bild-Subs behalten (mit neutralisierter Disposition), - // Text-Subs weglassen. var args = ['-hide_banner', '-y', '-i', file, '-map', '0:v?', '-map', '0:a?', '-map', '0:t?']; + + // Bild-Subs behalten var outSubPos = 0; - imgSubs.forEach(function (s) { args.push('-map', '0:s:' + s.sIdx); }); + var keepSubIdxs = []; + + // Erfolgreich extrahierte Textspuren werden NICHT gemappt (also entfernt) + // Alle anderen (fehlgeschlagene Text + alle Bild) werden behalten + subs.forEach(function (s) { + if (removeTextIdx.indexOf(s.sIdx) !== -1) return; // skip = entfernen + keepSubIdxs.push(s); + }); + + keepSubIdxs.forEach(function (s) { + args.push('-map', '0:s:' + s.sIdx); + }); + args.push('-c', 'copy'); - // alle behaltenen Bild-Subs: default + forced weg (Emby brennt sonst automatisch ein) - imgSubs.forEach(function () { - args.push('-disposition:s:' + outSubPos, '0'); + + // Bild-Sub Disposition neutralisieren + keepSubIdxs.forEach(function (s) { + if (!s.isText) { + args.push('-disposition:s:' + outSubPos, '0'); + } outSubPos++; }); + args.push('-dn', '-max_muxing_queue_size', '9999', tmp); - Logger.ILog('Sub: Remux (Text raus: ' + textSubs.length + ', Bild behalten: ' + imgSubs.length + + Logger.ILog('Sub: Remux (Text raus: ' + removeTextIdx.length + ', Text behalten: ' + + (textSubs.length - removeTextIdx.length) + ', Bild behalten: ' + imgSubs.length + ', Flags neutralisiert). FFmpeg: ' + args.join(' ')); var rm = Flow.Execute({ command: ffmpeg, argumentList: args }); if (!rm || rm.exitCode !== 0) { @@ -153,12 +216,56 @@ function Script(SourceRoot, DestRoot) Flow.Execute({ command: '/bin/rm', argumentList: ['-f', tmp] }); return; } + + // Verifikation: remuxte Datei mit ffprobe pruefen + var verify = Flow.Execute({ + command: ffprobe, + argumentList: ['-v', 'quiet', '-print_format', 'json', '-show_streams', tmp] + }); + if (!verify || verify.exitCode !== 0) { + Logger.WLog('Sub: Verifikation fehlgeschlagen (ffprobe) - Original bleibt.'); + Flow.Execute({ command: '/bin/rm', argumentList: ['-f', tmp] }); + return; + } + var vj; + try { vj = JSON.parse(verify.standardOutput || verify.output || ''); } catch (e) { + Logger.WLog('Sub: Verifikation JSON-Fehler - Original bleibt.'); + Flow.Execute({ command: '/bin/rm', argumentList: ['-f', tmp] }); + return; + } + + // Pruefe: mindestens 1 Video-Stream und gleiche Audio-Trackzahl + var vVideo = (vj.streams || []).filter(function (s) { return s.codec_type === 'video'; }); + var vAudio = (vj.streams || []).filter(function (s) { return s.codec_type === 'audio'; }); + var oAudio = (j.streams || []).filter(function (s) { return s.codec_type === 'audio'; }); + + if (vVideo.length < 1) { + Logger.WLog('Sub: Verifikation - kein Video-Stream im Remux - Original bleibt.'); + Flow.Execute({ command: '/bin/rm', argumentList: ['-f', tmp] }); + return; + } + if (vAudio.length !== oAudio.length) { + Logger.WLog('Sub: Verifikung - Audio-Trackzahl geaendert (' + oAudio.length + ' -> ' + vAudio.length + ') - Original bleibt.'); + Flow.Execute({ command: '/bin/rm', argumentList: ['-f', tmp] }); + return; + } + + // Dateigroesse pruefen (nicht 0) + var sizeCheck = Flow.Execute({ command: '/usr/bin/stat', argumentList: ['-c', '%s', tmp] }); + var tmpSize = parseInt((sizeCheck && sizeCheck.standardOutput) ? sizeCheck.standardOutput.trim() : '0', 10) || 0; + if (tmpSize < 1024) { + Logger.WLog('Sub: Verifikation - Remux-Datei zu klein (' + tmpSize + ' bytes) - Original bleibt.'); + Flow.Execute({ command: '/bin/rm', argumentList: ['-f', tmp] }); + return; + } + + // Erfolgreich: atomar ersetzen var mv2 = Flow.Execute({ command: '/bin/mv', argumentList: ['-f', tmp, file] }); if (!mv2 || mv2.exitCode !== 0) { Logger.WLog('Sub: Ersetzen der Datei fehlgeschlagen - Original bleibt.'); Flow.Execute({ command: '/bin/rm', argumentList: ['-f', tmp] }); return; } - Logger.ILog('Sub: MKV neu gemuxt -> ' + file); + Logger.ILog('Sub: MKV neu gemuxt -> ' + file + ' (' + tmpSize + ' bytes, video=' + vVideo.length + ', audio=' + vAudio.length + ')'); } -} +} \ No newline at end of file