tdarr/fileflows/scripts/05_Finalize_Move.js

282 lines
No EOL
13 KiB
JavaScript

/**
* @name 05_Finalize_Move
* @description Verschiebt das Working File nach /tdarr/converted/, loescht das Original,
* behandelt Untertitel (Text-Subs als .srt-Sidecar, Bild-Subs Disposition neutralisieren).
* SICHERHEITS-FIX 17.07.2026: atomare Sidecar-Erzeugung, 0-Byte-Pruefung,
* nur erfolgreich extrahierte Textspuren entfernen, Remux-Verifikation.
* @author Sascha + Trulla
* @revision 2
* @output OK
* @output Fehler
*/
function Script(SourceRoot, DestRoot)
{
SourceRoot = SourceRoot || '/tdarr/complete/';
DestRoot = DestRoot || '/tdarr/converted/';
let origPath = Variables['file.Orig.FullName'];
let workingFile = Variables['file.FullName'];
if (!origPath || !workingFile) {
Logger.ELog('Pfad-Variablen fehlen (orig=' + origPath + ' work=' + workingFile + ')');
return 2;
}
if (origPath.indexOf(SourceRoot) !== 0) {
Logger.ELog('Original "' + origPath + '" liegt nicht unter ' + SourceRoot + ' - abort');
return 2;
}
let relPath = origPath.substring(SourceRoot.length);
let destAbs = DestRoot + relPath;
let destDir = destAbs.substring(0, destAbs.lastIndexOf('/'));
Logger.ILog('orig: ' + origPath);
Logger.ILog('working: ' + workingFile);
Logger.ILog('dest: ' + destAbs);
Flow.Execute({ command: '/bin/mkdir', argumentList: ['-p', destDir] });
// 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] });
let oldSizeStr = (oldSize && oldSize.standardOutput) ? oldSize.standardOutput.trim() : '?';
Logger.WLog('Ziel existiert bereits (size=' + oldSizeStr + ' bytes) - wird ueberschrieben: ' + destAbs);
}
let mv = Flow.Execute({ command: '/bin/mv', argumentList: ['-f', workingFile, destAbs] });
if (!mv || mv.exitCode !== 0) {
Logger.ELog('mv failed (exit=' + (mv ? mv.exitCode : '?') + ')');
if (mv && mv.standardError) Logger.ELog(mv.standardError);
return 2;
}
Flow.SetWorkingFile(destAbs);
Logger.ILog('Moved -> ' + destAbs);
if (origPath !== destAbs) {
Flow.Execute({ command: '/bin/rm', argumentList: ['-f', origPath] });
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.
// Radarr (importExtraFiles=.srt) zieht sie beim Import mit und benennt synchron um.
// - Bild-Subs (PGS/VobSub): im MKV behalten, aber default/forced-Flags loeschen
// -> Emby brennt nie automatisch ein (kein Transcode). Manuell waehlbar bleiben sie.
// - 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) {
Logger.WLog('Untertitel-Handling uebersprungen (Fehler, Film bleibt unveraendert): ' + e);
}
return 1;
// -- Helfer --------------------------------------------------------------
function handleSubtitles(file) {
var ffprobe = Flow.GetToolPath('ffprobe') || Variables['ffprobe'] || 'ffprobe';
var ffmpeg = Flow.GetToolPath('ffmpeg') || Variables['ffmpeg'] || 'ffmpeg';
var res = Flow.Execute({
command: ffprobe,
argumentList: ['-v', 'quiet', '-print_format', 'json', '-show_streams', file]
});
if (!res || res.exitCode !== 0) { Logger.WLog('Sub: ffprobe fehlgeschlagen - ueberspringe'); return; }
var j;
try { j = JSON.parse(res.standardOutput || res.output || ''); } catch (e) { Logger.WLog('Sub: JSON-Fehler - ueberspringe'); return; }
// relative Subtitle-Streams sammeln (0:s:0, 0:s:1, ...)
var subs = [], sidx = 0;
var textCodecs = ['subrip', 'srt', 'ass', 'ssa', 'mov_text', 'webvtt', 'text', 'subviewer', 'eia_608'];
(j.streams || []).forEach(function (s) {
if (s.codec_type !== 'subtitle') return;
var codec = ('' + (s.codec_name || '')).toLowerCase();
var disp = s.disposition || {};
var tags = s.tags || {};
subs.push({
sIdx: sidx,
codec: codec,
isText: textCodecs.indexOf(codec) !== -1,
lang: ('' + (tags.language || 'und')).toLowerCase(),
forced: (disp.forced === 1) || /forced/i.test('' + (tags.title || '')),
isDefault: (disp.default === 1)
});
sidx++;
});
if (subs.length === 0) { Logger.ILog('Sub: keine Untertitel-Spuren.'); return; }
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
// 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', tmpSidecar]
});
// 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:
// 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; });
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';
var args = ['-hide_banner', '-y', '-i', file, '-map', '0:v?', '-map', '0:a?', '-map', '0:t?'];
// Bild-Subs behalten
var outSubPos = 0;
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');
// 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: ' + 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) {
Logger.WLog('Sub: Remux fehlgeschlagen (exit=' + (rm ? rm.exitCode : '?') + ') - Film bleibt unveraendert.');
if (rm && rm.standardError) Logger.WLog(rm.standardError);
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 + ' (' + tmpSize + ' bytes, video=' + vVideo.length + ', audio=' + vAudio.length + ')');
}
}