From 2eb34a452713092a943460dec0cdc76e20532a9c Mon Sep 17 00:00:00 2001 From: sascha Date: Fri, 17 Jul 2026 19:34:11 +0200 Subject: [PATCH] chore: add 05_Finalize_Move.js from live FileFlows export --- fileflows/scripts/05_Finalize_Move.js | 164 ++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 fileflows/scripts/05_Finalize_Move.js diff --git a/fileflows/scripts/05_Finalize_Move.js b/fileflows/scripts/05_Finalize_Move.js new file mode 100644 index 0000000..74d7f88 --- /dev/null +++ b/fileflows/scripts/05_Finalize_Move.js @@ -0,0 +1,164 @@ +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 (typisch bei Re-Encode + // gleichen Films aus altem Run). Kein -n, sonst geht der frische Encode beim Cleanup verloren. + 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. + // ═════════════════════════════════════════════════════════════════════════ + 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, // relativer -map 0:s:N Index + 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 (jeweils eigener ffmpeg-Call, Main unangetastet) + var base = file.replace(/\.[^./]+$/, ''); // Pfad ohne Extension + var seen = {}; // lang+forced -> Zaehler (Mehrfachspuren) + 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 ex = Flow.Execute({ + command: ffmpeg, + argumentList: ['-hide_banner', '-y', '-i', file, + '-map', '0:s:' + s.sIdx, '-c:s', 'srt', sidecar] + }); + if (ex && ex.exitCode === 0) Logger.ILog('Sub: Sidecar extrahiert -> ' + sidecar); + else Logger.WLog('Sub: Sidecar-Extraktion fehlgeschlagen (0:s:' + s.sIdx + ', ' + s.codec + ')'); + }); + + // 2) Remux nur wenn noetig: Text-Subs raus ODER ein Bild-Sub traegt default/forced. + 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.'); + return; + } + + 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?']; + var outSubPos = 0; + imgSubs.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'); + outSubPos++; + }); + args.push('-dn', '-max_muxing_queue_size', '9999', tmp); + + Logger.ILog('Sub: Remux (Text raus: ' + textSubs.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; + } + 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); + } +}