From 3fe15aa1d93d101c6a2acb8b394221eb82dfdd96 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Feb 2026 14:55:11 +0100
Subject: [PATCH 001/173] Add Compress/Extract UI: context menus, templates,
and dialog logic
- Add Compress option to folder and file context menus
- Add Extract option to archive files (zip, tar, gz, etc.)
- Create compress/extract dialog templates in Templates.php
- Implement doAction cases 16 (compress) and 17 (extract)
- Add auto-detection for archive format based on path (zstd for appdata, zip for shares)
- Add updateArchiveName() helper to sync archive name with format selection
- Add validation for compress/extract actions in Start button handler
---
emhttp/plugins/dynamix/Browse.page | 61 +++++++++++++++++++-
emhttp/plugins/dynamix/include/Templates.php | 39 +++++++++++++
2 files changed, 99 insertions(+), 1 deletion(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index f52893e8b6..c77a420454 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -124,6 +124,7 @@ function folderContextMenu(id, button) {
opts.push({text:"_(Rename)_", icon:"fa-edit", action:function(e){e.preventDefault();doAction(2,"_(Rename)_",id.dfm_proxy());}});
+ opts.push({text:"_(Compress)_", icon:"fa-file-archive-o", action:function(e){e.preventDefault();doAction(16,"_(Compress)_",id.dfm_proxy());}});
opts.push({divider:true});
opts.push({text:"_(Owner)_", icon:"fa-user-o", action:function(e){e.preventDefault();doAction(11,"_(Change Owner)_",id.dfm_proxy());}});
opts.push({text:"_(Permission)_", icon:"fa-address-book-o", action:function(e){e.preventDefault();doAction(12,"_(Change Permission)_",id.dfm_proxy());}});
@@ -151,6 +152,14 @@ function fileContextMenu(id, button) {
opts.push({text:"_(Copy)_", icon:"fa-copy", action:function(e){e.preventDefault();doAction(8,"_(Copy)_",id.dfm_proxy());}});
opts.push({text:"_(Move)_", icon:"fa-paste", action:function(e){e.preventDefault();doAction(9,"_(Move)_",id.dfm_proxy());}});
opts.push({text:"_(Rename)_", icon:"fa-edit", action:function(e){e.preventDefault();doAction(7,"_(Rename)_",id.dfm_proxy());}});
+ opts.push({text:"_(Compress)_", icon:"fa-file-archive-o", action:function(e){e.preventDefault();doAction(16,"_(Compress)_",id.dfm_proxy());}});
+ // Check if file is an archive
+ var fileName = $('#'+id).attr('data');
+ var archiveExts = ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar', 'tgz', 'tbz2', 'txz', 'zst'];
+ var ext = fileExtension(fileName).toLowerCase();
+ if (archiveExts.includes(ext) || fileName.match(/\.(tar\.(gz|bz2|xz|zst))$/i)) {
+ opts.push({text:"_(Extract)_", icon:"fa-archive", action:function(e){e.preventDefault();doAction(17,"_(Extract)_",id.dfm_proxy());}});
+ }
opts.push({divider:true});
opts.push({text:"_(Owner)_", icon:"fa-user-o", action:function(e){e.preventDefault();doAction(11,"_(Change Owner)_",id.dfm_proxy());}});
opts.push({text:"_(Permission)_", icon:"fa-address-book-o", action:function(e){e.preventDefault();doAction(12,"_(Change Permission)_",id.dfm_proxy());}});
@@ -284,6 +293,18 @@ function fileExtension(file) {
return file.indexOf('.')>=0 ? file.split('.').pop() : '';
}
+function updateArchiveName() {
+ var sourceName = $('#dfm_source').text().trim();
+ // Remove trailing slash if present (for folders)
+ sourceName = sourceName.replace(/\/$/, '');
+ // Get just the filename/foldername without path
+ var baseName = fileName(sourceName);
+ // Remove existing extension from base name
+ baseName = baseName.replace(/\.(zip|tar|tar\.gz|tar\.bz2|tar\.xz|tar\.zst|tgz|tbz2|txz)$/i, '');
+ var format = $('#dfm_format').val();
+ $('#dfm_archive_name').val(baseName + '.' + format);
+}
+
function fileEdit(id) {
const known = ["","cfg","conf",=dfm_array(file($editor,FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES))?>];
var source = $('#'+id.dfm_proxy()).attr('data').dfm_quote();
@@ -797,6 +818,30 @@ function doAction(action, title, id) {
dfm.window.find('.dfm_loc').html(' ').css({'line-height':'normal'});
dfm.window.find('.dfm_text').html('').css({'line-height':'normal'});
break;
+ case 16: // compress
+ dfm.window.html($('#dfm_templateCompress').html());
+ dfm_createSource(source.dfm_strip());
+ // Detect if source is in appdata/system path for default format selection
+ var isAppdata = source.match(/\/mnt\/[^\/]+\/(appdata|system)\//);
+ $('#dfm_format').val(isAppdata ? 'tar.zst' : 'zip');
+ // Set default archive name based on source name
+ var archiveName = name + '.' + $('#dfm_format').val();
+ $('#dfm_archive_name').val(archiveName);
+ // Set target to current directory
+ dfm.window.find('#dfm_target').val(dir).attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).attr('data-pickfilter','SHOW_POPULAR').fileTreeAttach(null,null,function(path){
+ dfm.window.find('#dfm_target').val(path).change();
+ });
+ dfm.window.find('#dfm_target').addClass('dfm-target-with-tree');
+ break;
+ case 17: // extract
+ dfm.window.html($('#dfm_templateExtract').html());
+ dfm_createSource(source);
+ // Set target to current directory
+ dfm.window.find('#dfm_target').val(dir).attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).attr('data-pickfilter','SHOW_POPULAR').fileTreeAttach(null,null,function(path){
+ dfm.window.find('#dfm_target').val(path).change();
+ });
+ dfm.window.find('#dfm_target').addClass('dfm-target-with-tree');
+ break;
}
dfm.window.dialog({
classes: {'ui-dialog': 'ui-dfm ui-corner-all'},
@@ -852,6 +897,11 @@ function doAction(action, title, id) {
dfm.window.find('.dfm_loc').html(' ');
dfm.window.find('#dfm_files').html('');
break;
+ case 16: // compress
+ case 17: // extract
+ // validate target directory exists and is valid
+ var valid = ud ? /^\/mnt\/.+/ : (user ? /^\/mnt\/(user0?|disks|remotes)\/.+/ : /^\/mnt\/(?!.*(user0?|rootshare)\/).+$|^\/boot\/.+/);
+ break;
default:
// disallow mixing of disk and user shares
var valid = ud ? /^\/mnt\/.+/ : (user ? /^\/mnt\/(user0?|disks|remotes)\/.+/ : /^\/mnt\/(?!.*(user0?|rootshare)\/).+$|^\/boot\/.+/);
@@ -859,6 +909,10 @@ function doAction(action, title, id) {
}
if (!target || !valid.test(target)) {errorTarget(); return;}
} else target = '';
+ // For compress: collect additional parameters
+ var format = action == 16 ? dfm.window.find('#dfm_format').val() : '';
+ var archive_name = action == 16 ? dfm.window.find('#dfm_archive_name').val() : '';
+ if (action == 16 && !archive_name) {errorTarget(); return;}
dfm.window.find('.dfm_text').removeClass('orange-text').html("_(Running)_...");
dfm.window.find('#dfm_target').prop('disabled',true);
dfm.window.find('#dfm_sparse').prop('disabled',true);
@@ -872,7 +926,12 @@ function doAction(action, title, id) {
}
dfm_footer('hide');
dfm_fileManager('start');
- $.post('/webGui/include/Control.php',{mode:'file',action:action,title:encodeURIComponent(title),source:encodeURIComponent(source),target:encodeURIComponent(target),hdlink:hdlink,sparse:dfm.window.find('#dfm_sparse').val(),exist:dfm.window.find('#dfm_exist').val(),zfs:encodeURIComponent(zfs)},function(){
+ var postData = {mode:'file',action:action,title:encodeURIComponent(title),source:encodeURIComponent(source),target:encodeURIComponent(target),hdlink:hdlink,sparse:dfm.window.find('#dfm_sparse').val(),exist:dfm.window.find('#dfm_exist').val(),zfs:encodeURIComponent(zfs)};
+ if (action == 16) {
+ postData.format = encodeURIComponent(format);
+ postData.archive_name = encodeURIComponent(archive_name);
+ }
+ $.post('/webGui/include/Control.php',postData,function(){
$.post('/webGui/include/Control.php',{mode:'read'},function(data){
try {
dfm_read = data ? JSON.parse(data) : {};
diff --git a/emhttp/plugins/dynamix/include/Templates.php b/emhttp/plugins/dynamix/include/Templates.php
index 9a9f06db16..118b3bdc87 100644
--- a/emhttp/plugins/dynamix/include/Templates.php
+++ b/emhttp/plugins/dynamix/include/Templates.php
@@ -363,4 +363,43 @@ function getMode(file){
!-->
+
+
+_(Source)_:
+:
+
+_(Archive format)_:
+:
+
+_(Archive name)_:
+:
+
+
+:
+
+
+: _(save to)_ ...
+
+_(Target folder)_:
+:
+
+
+
From 8730ff6c2e4c897cee722e013aca2f34bd6b125b Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Feb 2026 14:57:16 +0100
Subject: [PATCH 002/173] Add Compress/Extract backend: Control.php and
file_manager worker implementation
- Control.php: Add format and archive_name parameters for compress action
- file_manager: Implement case 16 (compress) with support for zip, tar, tar.gz, tar.zst
- file_manager: Implement case 17 (extract) with auto-detection of archive type
- Support for multiple archive formats: zip, tar, tar.gz, tar.bz2, tar.xz, tar.zst, 7z, rar
- Add progress tracking via tee to status file
- Add warning texts for compress/extract actions in Browse.page
---
emhttp/plugins/dynamix/Browse.page | 4 +-
emhttp/plugins/dynamix/include/Control.php | 5 ++
emhttp/plugins/dynamix/nchan/file_manager | 87 ++++++++++++++++++++++
3 files changed, 95 insertions(+), 1 deletion(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index c77a420454..b2592b6ec6 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -94,7 +94,9 @@ function addActionWarning(action, isBulk) {
8: "=_("This copies the selected file")?>",
9: "=_("This moves the selected file")?>",
11: "=_("This changes the owner of the source recursively")?>",
- 12: "=_("This changes the permission of the source recursively")?>"
+ 12: "=_("This changes the permission of the source recursively")?>",
+ 16: "=_("This compresses the source to an archive file")?>",
+ 17: "=_("This extracts the archive to the target folder")?>"
};
var warningText = warningTexts[action];
if (warningText) {
diff --git a/emhttp/plugins/dynamix/include/Control.php b/emhttp/plugins/dynamix/include/Control.php
index edb56ac55f..232a48a525 100644
--- a/emhttp/plugins/dynamix/include/Control.php
+++ b/emhttp/plugins/dynamix/include/Control.php
@@ -244,6 +244,11 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
'exist' => empty($_POST['exist']) ? '--ignore-existing' : '',
'zfs' => rawurldecode($_POST['zfs'] ?? '')
];
+ // Add compress-specific parameters
+ if ($data['action'] === 16) {
+ $data['format'] = rawurldecode($_POST['format'] ?? 'zip');
+ $data['archive_name'] = rawurldecode($_POST['archive_name'] ?? '');
+ }
if (isset($_POST['task'])) {
// add task to queue
$data['task'] = rawurldecode($_POST['task']);
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 97c82b97a0..c49fdee410 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -672,6 +672,93 @@ while (true) {
exec("find ".source($source)." -iname ".escapeshellarg($target)." 1>$status 2>$null & echo \$!", $pid);
}
break;
+ case 16: // compress
+
+ // return status of running action
+ if (!empty($pid)) {
+ $file_line = exec("tail -1 $status");
+ $reply['status'] = json_encode([
+ 'action' => $action,
+ 'text' => [_('Compressing').'... '.mb_strimhalf($file_line, 70, '...')]
+ ]);
+
+ // start action
+ } else {
+ $format = $format ?? 'zip';
+ $archive_name = $archive_name ?? 'archive.zip';
+ $archive_path = rtrim($target, '/').'/'.$archive_name;
+
+ // Get source basename and parent directory
+ $source_path = rtrim($source[0], '/');
+ $source_parent = dirname($source_path);
+ $source_basename = basename($source_path);
+
+ // Build compression command based on format
+ $cmd = "cd ".quoted($source_parent)." && ";
+ switch ($format) {
+ case 'zip':
+ $cmd .= "zip -r ".quoted($archive_path)." ".quoted($source_basename)." 2>&1 | tee $status";
+ break;
+ case 'tar':
+ $cmd .= "tar -cvf ".quoted($archive_path)." ".quoted($source_basename)." 2>&1 | tee $status";
+ break;
+ case 'tar.gz':
+ $cmd .= "tar -czvf ".quoted($archive_path)." ".quoted($source_basename)." 2>&1 | tee $status";
+ break;
+ case 'tar.zst':
+ $cmd .= "tar -I zstd -cvf ".quoted($archive_path)." ".quoted($source_basename)." 2>&1 | tee $status";
+ break;
+ default:
+ $cmd .= "zip -r ".quoted($archive_path)." ".quoted($source_basename)." 2>&1 | tee $status";
+ }
+ $cmd .= " & echo \$!";
+ exec($cmd, $pid);
+ }
+ break;
+ case 17: // extract
+
+ // return status of running action
+ if (!empty($pid)) {
+ $file_line = exec("tail -1 $status");
+ $reply['status'] = json_encode([
+ 'action' => $action,
+ 'text' => [_('Extracting').'... '.mb_strimhalf($file_line, 70, '...')]
+ ]);
+
+ // start action
+ } else {
+ $archive = $source[0];
+ $dest = rtrim($target, '/');
+
+ // Create destination directory if it doesn't exist
+ exec("mkdir -p ".quoted($dest));
+
+ // Detect archive type and extract accordingly
+ $cmd = "cd ".quoted($dest)." && ";
+ if (preg_match('/\.(tar\.gz|tgz)$/i', $archive)) {
+ $cmd .= "tar -xzvf ".quoted($archive)." 2>&1 | tee $status";
+ } elseif (preg_match('/\.(tar\.bz2|tbz2)$/i', $archive)) {
+ $cmd .= "tar -xjvf ".quoted($archive)." 2>&1 | tee $status";
+ } elseif (preg_match('/\.(tar\.xz|txz)$/i', $archive)) {
+ $cmd .= "tar -xJvf ".quoted($archive)." 2>&1 | tee $status";
+ } elseif (preg_match('/\.(tar\.zst)$/i', $archive)) {
+ $cmd .= "tar -I zstd -xvf ".quoted($archive)." 2>&1 | tee $status";
+ } elseif (preg_match('/\.tar$/i', $archive)) {
+ $cmd .= "tar -xvf ".quoted($archive)." 2>&1 | tee $status";
+ } elseif (preg_match('/\.zip$/i', $archive)) {
+ $cmd .= "unzip ".quoted($archive)." 2>&1 | tee $status";
+ } elseif (preg_match('/\.7z$/i', $archive)) {
+ $cmd .= "7z x ".quoted($archive)." 2>&1 | tee $status";
+ } elseif (preg_match('/\.rar$/i', $archive)) {
+ $cmd .= "unrar x ".quoted($archive)." 2>&1 | tee $status";
+ } else {
+ // Try to detect by file command as fallback
+ $cmd .= "unzip ".quoted($archive)." 2>&1 | tee $status";
+ }
+ $cmd .= " & echo \$!";
+ exec($cmd, $pid);
+ }
+ break;
case 99: // kill running background process
if (!empty($pid)) exec("kill $pid");
delete_file($active, $pid_file, $status, $error);
From 64869e66f7dbdc64ff9dcf2166c9c1b1f02472eb Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Feb 2026 15:10:24 +0100
Subject: [PATCH 003/173] Fix compress bugs: correct selector scope, add bulk
compress toolbar button, add debug logging
---
emhttp/plugins/dynamix/Browse.page | 63 +++++++++++++++++++++++++++---
1 file changed, 57 insertions(+), 6 deletions(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index b2592b6ec6..9f68967d23 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -825,15 +825,18 @@ function doAction(action, title, id) {
dfm_createSource(source.dfm_strip());
// Detect if source is in appdata/system path for default format selection
var isAppdata = source.match(/\/mnt\/[^\/]+\/(appdata|system)\//);
- $('#dfm_format').val(isAppdata ? 'tar.zst' : 'zip');
+ dfm.window.find('#dfm_format').val(isAppdata ? 'tar.zst' : 'zip');
// Set default archive name based on source name
- var archiveName = name + '.' + $('#dfm_format').val();
- $('#dfm_archive_name').val(archiveName);
+ var archiveName = name + '.' + dfm.window.find('#dfm_format').val();
+ dfm.window.find('#dfm_archive_name').val(archiveName);
+ // Bind format change to update archive name
+ dfm.window.find('#dfm_format').on('change', updateArchiveName);
// Set target to current directory
dfm.window.find('#dfm_target').val(dir).attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).attr('data-pickfilter','SHOW_POPULAR').fileTreeAttach(null,null,function(path){
dfm.window.find('#dfm_target').val(path).change();
});
dfm.window.find('#dfm_target').addClass('dfm-target-with-tree');
+ console.log('Compress dialog opened', {source: source, dir: dir, archiveName: archiveName});
break;
case 17: // extract
dfm.window.html($('#dfm_templateExtract').html());
@@ -914,7 +917,10 @@ function doAction(action, title, id) {
// For compress: collect additional parameters
var format = action == 16 ? dfm.window.find('#dfm_format').val() : '';
var archive_name = action == 16 ? dfm.window.find('#dfm_archive_name').val() : '';
- if (action == 16 && !archive_name) {errorTarget(); return;}
+ if (action == 16) {
+ console.log('Compress parameters:', {action: action, source: source, target: target, format: format, archive_name: archive_name});
+ if (!archive_name) {errorTarget(); return;}
+ }
dfm.window.find('.dfm_text').removeClass('orange-text').html("_(Running)_...");
dfm.window.find('#dfm_target').prop('disabled',true);
dfm.window.find('#dfm_sparse').prop('disabled',true);
@@ -932,6 +938,7 @@ function doAction(action, title, id) {
if (action == 16) {
postData.format = encodeURIComponent(format);
postData.archive_name = encodeURIComponent(archive_name);
+ console.log('Sending compress request to Control.php:', postData);
}
$.post('/webGui/include/Control.php',postData,function(){
$.post('/webGui/include/Control.php',{mode:'read'},function(data){
@@ -1143,6 +1150,25 @@ function doActions(action, title) {
dfm.window.find('.dfm_loc').html(' ').css({'line-height':'normal'});
dfm.window.find('.dfm_text').html('').css({'line-height':'normal'});
break;
+ case 16: // compress object(s)
+ dfm.window.html($('#dfm_templateCompress').html());
+ dfm_createSource(source);
+ // Use first source name for archive name suggestion
+ var firstSource = bulk ? source[0] : source[0];
+ var firstPath = firstSource.substr(1).split('/');
+ var firstName = firstPath.pop() || firstPath.pop();
+ // Detect if source is in appdata/system path
+ var isAppdata = firstSource.match(/\/mnt\/[^\/]+(appdata|system)\//);
+ dfm.window.find('#dfm_format').val(isAppdata ? 'tar.zst' : 'zip');
+ var archiveName = (bulk ? 'archive' : firstName) + '.' + dfm.window.find('#dfm_format').val();
+ dfm.window.find('#dfm_archive_name').val(archiveName);
+ dfm.window.find('#dfm_format').on('change', updateArchiveName);
+ dfm.window.find('#dfm_target').val(dir).attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).attr('data-pickfilter','SHOW_POPULAR').fileTreeAttach(null,null,function(path){
+ dfm.window.find('#dfm_target').val(path).change();
+ });
+ dfm.window.find('#dfm_target').addClass('dfm-target-with-tree');
+ console.log('Bulk compress dialog opened', {sources: source, bulk: bulk, archiveName: archiveName});
+ break;
}
dfm.window.find('#dfm_source').attr('size',Math.min(dfm.tsize[action],source.length));
dfm.window.dialog({
@@ -1201,6 +1227,13 @@ function doActions(action, title) {
dfm.window.find('.dfm_loc').html(' ');
dfm.window.find('#dfm_files').html('');
break;
+ case 16: // compress
+ var valid = /.+/;
+ var format = dfm.window.find('#dfm_format').val();
+ var archive_name = dfm.window.find('#dfm_archive_name').val();
+ console.log('Bulk compress Start clicked:', {target: target, format: format, archive_name: archive_name, bulk: bulk});
+ if (!archive_name) {errorTarget(); return;}
+ break;
default:
// disallow mixing of disk and user shares
var valid = ud ? /^\/mnt\/.+/ : (user ? /^\/mnt\/(user0?|disks|remotes)\/.+/ : /^\/mnt\/(?!.*(user0?|rootshare)\/).+$|^\/boot\/.+/);
@@ -1222,7 +1255,13 @@ function doActions(action, title) {
dfm.window.find('.dfm_text').removeClass('orange-text').html("_(Running)_...");
dfm_footer('hide');
dfm_fileManager('start');
- $.post('/webGui/include/Control.php',{mode:'file',action:action,title:encodeURIComponent(title),source:encodeURIComponent(source.join('\r')),target:encodeURIComponent(target),hdlink:hdlink,sparse:dfm.window.find('#dfm_sparse').val(),exist:dfm.window.find('#dfm_exist').val(),zfs:encodeURIComponent(zfs.join('\r'))},function(){
+ var postData = {mode:'file',action:action,title:encodeURIComponent(title),source:encodeURIComponent(source.join('\r')),target:encodeURIComponent(target),hdlink:hdlink,sparse:dfm.window.find('#dfm_sparse').val(),exist:dfm.window.find('#dfm_exist').val(),zfs:encodeURIComponent(zfs.join('\r'))};
+ if (action == 16) {
+ postData.format = encodeURIComponent(format);
+ postData.archive_name = encodeURIComponent(archive_name);
+ console.log('Sending bulk compress request:', postData);
+ }
+ $.post('/webGui/include/Control.php',postData,function(){
$.post('/webGui/include/Control.php',{mode:'read'},function(data){
try {
dfm_read = data ? JSON.parse(data) : {};
@@ -1262,6 +1301,12 @@ function doActions(action, title) {
target = target.join(',')+',ugo+X';
bulk = false;
break;
+ case 16: // compress
+ var valid = /.+/;
+ var format_queue = dfm.window.find('#dfm_format').val();
+ var archive_name_queue = dfm.window.find('#dfm_archive_name').val();
+ if (!archive_name_queue) {errorTarget(); return;}
+ break;
default:
// disallow mixing of disk and user shares
var valid = ud ? /^\/mnt\/.+/ : (user ? /^\/mnt\/(user0?|disks|remotes)\/.+/ : /^\/mnt\/(?!.*(user0?|rootshare)\/).+$|^\/boot\/.+/);
@@ -1269,7 +1314,12 @@ function doActions(action, title) {
}
if (!target || !valid.test(target)) {errorTarget(); return;}
} else target = '';
- $.post('/webGui/include/Control.php',{mode:'file',task:encodeURIComponent(title.toLowerCase()),action:action,title:encodeURIComponent(title),source:encodeURIComponent(source.join('\r')),target:encodeURIComponent(target),hdlink:hdlink,sparse:dfm.window.find('#dfm_sparse').val(),exist:dfm.window.find('#dfm_exist').val(),zfs:encodeURIComponent(zfs.join('\r'))});
+ var postDataQueue = {mode:'file',task:encodeURIComponent(title.toLowerCase()),action:action,title:encodeURIComponent(title),source:encodeURIComponent(source.join('\r')),target:encodeURIComponent(target),hdlink:hdlink,sparse:dfm.window.find('#dfm_sparse').val(),exist:dfm.window.find('#dfm_exist').val(),zfs:encodeURIComponent(zfs.join('\r'))};
+ if (action == 16) {
+ postDataQueue.format = encodeURIComponent(format_queue);
+ postDataQueue.archive_name = encodeURIComponent(archive_name_queue);
+ }
+ $.post('/webGui/include/Control.php',postDataQueue);
dfm.window.dialog('close');
dfm.window.dialog('destroy');
$('.dfm_control.jobs').prop('disabled',false);
@@ -1568,6 +1618,7 @@ $(window).bind('resize',function(){
+
disabled}?>>
disabled}?>>
From f04dfe3d312214173c23f9907ad3dedd79da9159 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Feb 2026 15:14:35 +0100
Subject: [PATCH 004/173] Fix compress parameter extraction: preserve format
and archive_name across extract calls, add debug logging
---
emhttp/plugins/dynamix/nchan/file_manager | 17 +++++++++++++++--
1 file changed, 15 insertions(+), 2 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index c49fdee410..202b81137e 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -366,8 +366,8 @@ $delete_empty_dirs = null;
// infinite loop to monitor and execute file operations
// Note: exec() uses /bin/sh which is symlinked to bash in unraid and a requirement for process substitution syntax >(...)
while (true) {
- unset($action, $source, $target, $H, $sparse, $exist, $zfs);
- if (!isset($pid)) $pid = false;
+ unset($action, $source, $target, $H, $sparse, $exist, $zfs, $format, $archive_name);
+ if (!isset($pid)) $pid = false;
// read job parameters from JSON file: $action, $title, $source, $target, $H, $sparse, $exist, $zfs (set by emhttp/plugins/dynamix/include/Control.php)
if (file_exists($active)) {
@@ -375,6 +375,9 @@ while (true) {
$data = json_decode($json, true);
if (is_array($data)) {
extract($data);
+ // Preserve compress-specific parameters before plugin config extract
+ $compress_format = $format ?? null;
+ $compress_archive_name = $archive_name ?? null;
} elseif ($json !== false && trim($json) !== '') {
// Log JSON parse failure for debugging (non-empty file that failed to parse)
exec('logger -t file_manager "Warning: Failed to parse active job JSON: ' . escapeshellarg(substr($json, 0, 100)) . '"');
@@ -390,6 +393,9 @@ while (true) {
if (isset($action)) {
// check for language changes
extract(parse_plugin_cfg('dynamix', true));
+ // Restore compress parameters after plugin config extract
+ if (isset($compress_format)) $format = $compress_format;
+ if (isset($compress_archive_name)) $archive_name = $compress_archive_name;
if ($display['locale'] != $locale_init) {
$locale_init = $display['locale'];
update_translation($locale_init);
@@ -688,6 +694,9 @@ while (true) {
$archive_name = $archive_name ?? 'archive.zip';
$archive_path = rtrim($target, '/').'/'.$archive_name;
+ // Debug logging
+ exec('logger -t file_manager "Compress action started: format=' . escapeshellarg($format) . ', archive_name=' . escapeshellarg($archive_name) . ', source=' . escapeshellarg($source[0]) . ', target=' . escapeshellarg($target) . '"');
+
// Get source basename and parent directory
$source_path = rtrim($source[0], '/');
$source_parent = dirname($source_path);
@@ -712,6 +721,10 @@ while (true) {
$cmd .= "zip -r ".quoted($archive_path)." ".quoted($source_basename)." 2>&1 | tee $status";
}
$cmd .= " & echo \$!";
+
+ // Debug: log the command before execution
+ exec('logger -t file_manager "Executing compress command: ' . escapeshellarg($cmd) . '"');
+
exec($cmd, $pid);
}
break;
From 16e13b29c610709de4b9071f5c86254ee70274a9 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Feb 2026 15:17:50 +0100
Subject: [PATCH 005/173] Fix compress: use escapeshellarg for basenames,
support multiple sources
---
emhttp/plugins/dynamix/nchan/file_manager | 33 ++++++++++++++++-------
1 file changed, 23 insertions(+), 10 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 202b81137e..59fe96eb02 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -695,30 +695,43 @@ while (true) {
$archive_path = rtrim($target, '/').'/'.$archive_name;
// Debug logging
- exec('logger -t file_manager "Compress action started: format=' . escapeshellarg($format) . ', archive_name=' . escapeshellarg($archive_name) . ', source=' . escapeshellarg($source[0]) . ', target=' . escapeshellarg($target) . '"');
+ exec('logger -t file_manager "Compress action started: format=' . escapeshellarg($format) . ', archive_name=' . escapeshellarg($archive_name) . ', sources=' . escapeshellarg(implode(', ', $source)) . ', target=' . escapeshellarg($target) . '"');
- // Get source basename and parent directory
- $source_path = rtrim($source[0], '/');
- $source_parent = dirname($source_path);
- $source_basename = basename($source_path);
+ // For multiple sources, find common parent directory
+ // For single source, use its parent directory
+ if (count($source) > 1) {
+ // Use target directory as working directory for bulk operations
+ $source_parent = rtrim($target, '/');
+ // Get basenames of all sources (they should all be in target dir)
+ $source_basenames = array_map(function($s) {
+ return basename(rtrim($s, '/'));
+ }, $source);
+ } else {
+ // Single source - use its parent directory
+ $source_path = rtrim($source[0], '/');
+ $source_parent = dirname($source_path);
+ $source_basenames = [basename($source_path)];
+ }
// Build compression command based on format
+ // Note: Using escapeshellarg() directly for basenames since quoted() expects full paths
+ $escaped_basenames = implode(' ', array_map('escapeshellarg', $source_basenames));
$cmd = "cd ".quoted($source_parent)." && ";
switch ($format) {
case 'zip':
- $cmd .= "zip -r ".quoted($archive_path)." ".quoted($source_basename)." 2>&1 | tee $status";
+ $cmd .= "zip -r ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
break;
case 'tar':
- $cmd .= "tar -cvf ".quoted($archive_path)." ".quoted($source_basename)." 2>&1 | tee $status";
+ $cmd .= "tar -cvf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
break;
case 'tar.gz':
- $cmd .= "tar -czvf ".quoted($archive_path)." ".quoted($source_basename)." 2>&1 | tee $status";
+ $cmd .= "tar -czvf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
break;
case 'tar.zst':
- $cmd .= "tar -I zstd -cvf ".quoted($archive_path)." ".quoted($source_basename)." 2>&1 | tee $status";
+ $cmd .= "tar -I zstd -cvf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
break;
default:
- $cmd .= "zip -r ".quoted($archive_path)." ".quoted($source_basename)." 2>&1 | tee $status";
+ $cmd .= "zip -r ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
}
$cmd .= " & echo \$!";
From 80ab6d7129bb8095cda295e72794ef41311c9064 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 22 Feb 2026 13:53:03 +0100
Subject: [PATCH 006/173] Work in progress: improve compress progress display
---
emhttp/plugins/dynamix/nchan/file_manager | 34 +++++++++++++++++++++--
1 file changed, 31 insertions(+), 3 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 59fe96eb02..c439133ce2 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -682,10 +682,30 @@ while (true) {
// return status of running action
if (!empty($pid)) {
- $file_line = exec("tail -1 $status");
+ // Get last line from status file (current file being processed)
+ $file_line = trim(exec("tail -1 $status 2>$null"));
+
+ // Get current archive size to show progress
+ $archive_path = rtrim($target, '/').'/'.$archive_name;
+ $archive_size = '';
+ if (file_exists($archive_path)) {
+ $size_bytes = filesize($archive_path);
+ $archive_size = ' ['.bytes_to_size($size_bytes).']';
+ }
+
+ // Show either the file being compressed or just "Compressing..." with archive size
+ if (!empty($file_line) && strlen($file_line) > 2) {
+ // Clean up compression tool output (remove prefixes like "adding:", "a ", etc.)
+ $clean_line = preg_replace('/^(adding:|deflated|stored|a\s+)/i', '', $file_line);
+ $clean_line = trim($clean_line);
+ $text = _('Compressing').': '.mb_strimhalf($clean_line, 50, '...').$archive_size;
+ } else {
+ $text = _('Compressing').'...'.$archive_size;
+ }
+
$reply['status'] = json_encode([
'action' => $action,
- 'text' => [_('Compressing').'... '.mb_strimhalf($file_line, 70, '...')]
+ 'text' => [$text]
]);
// start action
@@ -719,6 +739,7 @@ while (true) {
$cmd = "cd ".quoted($source_parent)." && ";
switch ($format) {
case 'zip':
+ // zip outputs progress by default when creating archives
$cmd .= "zip -r ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
break;
case 'tar':
@@ -728,7 +749,14 @@ while (true) {
$cmd .= "tar -czvf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
break;
case 'tar.zst':
- $cmd .= "tar -I zstd -cvf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
+ // Use zstd with tar, -v for verbose output to show filenames
+ $cmd .= "tar --use-compress-program=zstd -cvf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
+ break;
+ case 'tar.bz2':
+ $cmd .= "tar -cvjf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
+ break;
+ case 'tar.xz':
+ $cmd .= "tar -cvJf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
break;
default:
$cmd .= "zip -r ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
From fb9d538afe5ef266244c136794d1e8048bc6705e Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 13:07:06 +0100
Subject: [PATCH 007/173] Add zip progress tracking with parse_zip_progress()
function
- Implement parse_zip_progress() to parse structured log format
- Create zip_wrapper script for real-time zip progress parsing
- Integrate zip_wrapper into compress case 16
- Parse DOT|timestamp entries to calculate speed and ETA
- Display progress: Completed: X%, Archive: YMB, Speed: ZMB/s, ETA: H:MM:SS
- Keep tar formats using simple status display for now
---
emhttp/plugins/dynamix/nchan/file_manager | 170 ++++++++++++++++++---
emhttp/plugins/dynamix/scripts/zip_wrapper | 50 ++++++
2 files changed, 201 insertions(+), 19 deletions(-)
create mode 100755 emhttp/plugins/dynamix/scripts/zip_wrapper
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index c439133ce2..d8950246e6 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -165,6 +165,131 @@ function calculate_eta($transferred, $percent, $speed, $last_rsync_eta_seconds =
* @param bool $reset If true, resets static state variables (call before new operation)
* @return array Associative array with progress information: 'text', 'percent', 'eta', 'speed', etc.
*/
+function parse_zip_progress($status, $action_label, $archive_path, $dot_size_mb = 100, $reset = false) {
+ static $total_dots_estimate = null;
+ static $first_dot_time = null;
+ static $last_eta_seconds = null;
+
+ // Reset static variables when starting a new archive
+ if ($reset) {
+ $total_dots_estimate = null;
+ $first_dot_time = null;
+ $last_eta_seconds = null;
+ return [];
+ }
+
+ // Initialize text array with action label
+ $text[0] = $action_label . "... ";
+
+ // Check if status file exists
+ if (!file_exists($status)) {
+ return $text;
+ }
+
+ // Read structured log (FILE|path|size, DOT|timestamp, INFO|message, DONE)
+ $lines = file($status, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
+ if (empty($lines)) {
+ return $text;
+ }
+
+ $current_file = '';
+ $dot_timestamps = [];
+ $done = false;
+
+ // Parse log lines
+ foreach ($lines as $line) {
+ $parts = explode('|', $line, 3);
+ if (count($parts) < 2) continue;
+
+ $type = $parts[0];
+ switch ($type) {
+ case 'FILE':
+ // FILE|filename|size
+ $current_file = $parts[1] ?? '';
+ break;
+ case 'DOT':
+ // DOT|timestamp
+ $dot_timestamps[] = intval($parts[1]);
+ break;
+ case 'INFO':
+ // INFO|message (for future use)
+ break;
+ case 'DONE':
+ $done = true;
+ break;
+ }
+ }
+
+ // Display current filename
+ if ($current_file) {
+ $text[0] .= mb_strimhalf($current_file, 70, '...');
+ }
+
+ // Calculate progress if we have dots
+ $dot_count = count($dot_timestamps);
+ if ($dot_count > 0) {
+ // Get archive size for progress indication
+ $archive_size_bytes = file_exists($archive_path) ? filesize($archive_path) : 0;
+ $archive_size = bytes_to_size($archive_size_bytes);
+
+ // Calculate speed from dot timestamps
+ $speed = '';
+ $eta = 'N/A';
+ $percent = 'N/A';
+
+ if ($dot_count >= 2) {
+ // Calculate speed from first to last dot
+ $time_span = $dot_timestamps[$dot_count - 1] - $dot_timestamps[0];
+ if ($time_span > 0) {
+ // dots/second * dot_size_mb = MB/s
+ $dots_per_second = ($dot_count - 1) / $time_span;
+ $speed_mb = $dots_per_second * $dot_size_mb;
+ $speed = bytes_to_size($speed_mb * 1024 * 1024) . '/s';
+
+ // Estimate total dots if we have archive size
+ // (this is rough - actual compression ratio unknown until done)
+ if ($archive_size_bytes > 0 && $total_dots_estimate === null) {
+ // Estimate based on current progress vs archive size
+ // Since we don't know final size, assume current compression ratio
+ $bytes_per_dot = $archive_size_bytes / $dot_count;
+ if ($bytes_per_dot > 0) {
+ $estimated_final_size = $archive_size_bytes * 1.5; // rough estimate
+ $total_dots_estimate = max($dot_count + 5, intval($estimated_final_size / $bytes_per_dot));
+ }
+ }
+
+ // Calculate ETA if we have estimate
+ if ($total_dots_estimate && $total_dots_estimate > $dot_count) {
+ $remaining_dots = $total_dots_estimate - $dot_count;
+ $eta_seconds = $remaining_dots / $dots_per_second;
+
+ // Apply hysteresis to prevent ETA from jumping
+ if ($last_eta_seconds !== null) {
+ $eta_seconds = ($last_eta_seconds * 0.7) + ($eta_seconds * 0.3);
+ }
+ $last_eta_seconds = $eta_seconds;
+
+ $eta = seconds_to_time(intval($eta_seconds));
+ $percent = intval(($dot_count / $total_dots_estimate) * 100) . '%';
+ }
+ }
+ }
+
+ // Build progress text
+ $progress_parts = [];
+ $progress_parts[] = _('Completed') . ": " . $percent;
+ $progress_parts[] = _('Archive') . ": " . $archive_size;
+ if ($speed) {
+ $progress_parts[] = _('Speed') . ": " . $speed;
+ }
+ $progress_parts[] = _('ETA') . ": " . $eta;
+
+ $text[1] = implode(", ", $progress_parts);
+ }
+
+ return $text;
+}
+
function parse_rsync_progress($status, $action_label, $reset = false) {
static $last_rsync_eta_seconds = null;
static $total_size = null;
@@ -682,30 +807,36 @@ while (true) {
// return status of running action
if (!empty($pid)) {
- // Get last line from status file (current file being processed)
- $file_line = trim(exec("tail -1 $status 2>$null"));
-
- // Get current archive size to show progress
+ $format = $format ?? 'zip';
+ $archive_name = $archive_name ?? 'archive.zip';
$archive_path = rtrim($target, '/').'/'.$archive_name;
- $archive_size = '';
- if (file_exists($archive_path)) {
- $size_bytes = filesize($archive_path);
- $archive_size = ' ['.bytes_to_size($size_bytes).']';
- }
- // Show either the file being compressed or just "Compressing..." with archive size
- if (!empty($file_line) && strlen($file_line) > 2) {
- // Clean up compression tool output (remove prefixes like "adding:", "a ", etc.)
- $clean_line = preg_replace('/^(adding:|deflated|stored|a\s+)/i', '', $file_line);
- $clean_line = trim($clean_line);
- $text = _('Compressing').': '.mb_strimhalf($clean_line, 50, '...').$archive_size;
+ // For zip format, use structured progress parser
+ if ($format === 'zip') {
+ $zip_status = '/var/tmp/zip.progress';
+ $text = parse_zip_progress($zip_status, _('Compressing'), $archive_path, 100);
} else {
- $text = _('Compressing').'...'.$archive_size;
+ // For tar formats, use simple status display for now
+ $file_line = trim(exec("tail -1 $status 2>$null"));
+
+ $archive_size = '';
+ if (file_exists($archive_path)) {
+ $size_bytes = filesize($archive_path);
+ $archive_size = ' ['.bytes_to_size($size_bytes).']';
+ }
+
+ if (!empty($file_line) && strlen($file_line) > 2) {
+ $clean_line = preg_replace('/^(adding:|deflated|stored|a\s+)/i', '', $file_line);
+ $clean_line = trim($clean_line);
+ $text = [_('Compressing').': '.mb_strimhalf($clean_line, 50, '...').$archive_size];
+ } else {
+ $text = [_('Compressing').'...'.$archive_size];
+ }
}
$reply['status'] = json_encode([
'action' => $action,
- 'text' => [$text]
+ 'text' => $text
]);
// start action
@@ -739,8 +870,9 @@ while (true) {
$cmd = "cd ".quoted($source_parent)." && ";
switch ($format) {
case 'zip':
- // zip outputs progress by default when creating archives
- $cmd .= "zip -r ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
+ // Use zip_wrapper for structured progress output
+ $zip_wrapper = '/usr/local/emhttp/plugins/dynamix/scripts/zip_wrapper';
+ $cmd .= "$zip_wrapper ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
break;
case 'tar':
$cmd .= "tar -cvf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
diff --git a/emhttp/plugins/dynamix/scripts/zip_wrapper b/emhttp/plugins/dynamix/scripts/zip_wrapper
new file mode 100755
index 0000000000..00f73c97b3
--- /dev/null
+++ b/emhttp/plugins/dynamix/scripts/zip_wrapper
@@ -0,0 +1,50 @@
+#!/bin/bash
+# Wrapper for zip to parse progress dots into structured log format
+# Usage: zip_wrapper
+
+archive="$1"
+format="$2"
+dot_size_mb="${3:-100}"
+shift 3
+source_files=("$@")
+
+status_file="/var/tmp/zip.progress"
+> "$status_file"
+
+# Run zip with dot progress and parse output character-by-character
+zip --recurse-paths --display-usize --display-dots --dot-size "${dot_size_mb}m" "$archive" -- "${source_files[@]}" 2>&1 | while IFS= read -r -n1 char || [[ -n $char ]]; do
+
+ # Line mode - accumulate characters until we detect dot mode or line end
+ if [[ ! $dot_mode ]]; then
+ line_buffer+="$char"
+
+ # Detect start of dot progress: "adding: path/file (SIZE) "
+ # Pattern matches when line ends with file size like "(1.2G) " or "(200M) "
+ if [[ $line_buffer =~ adding:\ (.*)\ \(([0-9.]+[KMGT]?)\)\ $ ]]; then
+ src_file="${BASH_REMATCH[1]}"
+ src_size="${BASH_REMATCH[2]}"
+ printf 'FILE|%s|%s\n' "$src_file" "$src_size" >> "$status_file"
+ line_buffer=""
+ dot_mode=1
+
+ # Line completed without entering dot mode - write as info line
+ elif [[ ! $char ]]; then
+ [[ -n $line_buffer ]] && printf 'INFO|%s\n' "$line_buffer" >> "$status_file"
+ line_buffer=""
+ fi
+
+ # Dot mode - log each dot with timestamp, exit on non-dot character
+ elif [[ $dot_mode ]]; then
+ if [[ $char == '.' ]]; then
+ printf 'DOT|%s\n' "$(date +%s)" >> "$status_file"
+ else
+ # Back to line mode
+ line_buffer="$char"
+ dot_mode=
+ fi
+ fi
+
+done
+
+# Write completion marker
+printf 'DONE\n' >> "$status_file"
From 2b1eec798bbd5b5527b8ba8baee1dbbab6d68f7e Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 13:12:04 +0100
Subject: [PATCH 008/173] Fix zip_wrapper and parse_zip_progress issues
- Remove unused $format parameter from zip_wrapper
- Fix shellcheck redirect warning (use : > instead of >)
- Use pipe-safe format: FILE|size|filename (filename at end)
- Add proper PHPDoc comment for parse_zip_progress
- Update PHP parser to read filename from third field
---
emhttp/plugins/dynamix/nchan/file_manager | 34 +++++++++++++++-------
emhttp/plugins/dynamix/scripts/zip_wrapper | 13 ++++-----
2 files changed, 30 insertions(+), 17 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index d8950246e6..317b74fe48 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -155,15 +155,18 @@ function calculate_eta($transferred, $percent, $speed, $last_rsync_eta_seconds =
}
/**
- * Parse rsync progress output and track transfer statistics.
+ * Parse zip progress output from structured log format.
*
- * Uses static variables to maintain state across multiple calls during a single operation.
- * Call with $reset=true before starting a new copy/move operation to clear previous state.
+ * Reads structured log file created by zip_wrapper script with format:
+ * FILE|size|filename, DOT|timestamp, INFO|message, DONE
+ * Filename is at the end to handle pipe characters in filenames.
*
- * @param string $status Raw rsync output line to parse
- * @param string $action_label Label to display for the current action (e.g. "Copying", "Moving")
- * @param bool $reset If true, resets static state variables (call before new operation)
- * @return array Associative array with progress information: 'text', 'percent', 'eta', 'speed', etc.
+ * @param string $status Path to structured log file
+ * @param string $action_label Label to display for the action (e.g. "Compressing")
+ * @param string $archive_path Path to archive file for size calculation
+ * @param int $dot_size_mb Size of each progress dot in MB (default 100)
+ * @param bool $reset If true, resets static state variables
+ * @return array Text array with progress information
*/
function parse_zip_progress($status, $action_label, $archive_path, $dot_size_mb = 100, $reset = false) {
static $total_dots_estimate = null;
@@ -186,7 +189,7 @@ function parse_zip_progress($status, $action_label, $archive_path, $dot_size_mb
return $text;
}
- // Read structured log (FILE|path|size, DOT|timestamp, INFO|message, DONE)
+ // Read structured log (FILE|size|filename, DOT|timestamp, INFO|message, DONE)
$lines = file($status, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (empty($lines)) {
return $text;
@@ -204,8 +207,8 @@ function parse_zip_progress($status, $action_label, $archive_path, $dot_size_mb
$type = $parts[0];
switch ($type) {
case 'FILE':
- // FILE|filename|size
- $current_file = $parts[1] ?? '';
+ // FILE|size|filename (filename at end to handle pipes in filename)
+ $current_file = $parts[2] ?? '';
break;
case 'DOT':
// DOT|timestamp
@@ -290,6 +293,17 @@ function parse_zip_progress($status, $action_label, $archive_path, $dot_size_mb
return $text;
}
+/**
+ * Parse rsync progress output and track transfer statistics.
+ *
+ * Uses static variables to maintain state across multiple calls during a single operation.
+ * Call with $reset=true before starting a new copy/move operation to clear previous state.
+ *
+ * @param string $status Raw rsync output line to parse
+ * @param string $action_label Label to display for the current action (e.g. "Copying", "Moving")
+ * @param bool $reset If true, resets static state variables (call before new operation)
+ * @return array Associative array with progress information: 'text', 'percent', 'eta', 'speed', etc.
+ */
function parse_rsync_progress($status, $action_label, $reset = false) {
static $last_rsync_eta_seconds = null;
static $total_size = null;
diff --git a/emhttp/plugins/dynamix/scripts/zip_wrapper b/emhttp/plugins/dynamix/scripts/zip_wrapper
index 00f73c97b3..1bb21c0046 100755
--- a/emhttp/plugins/dynamix/scripts/zip_wrapper
+++ b/emhttp/plugins/dynamix/scripts/zip_wrapper
@@ -1,18 +1,16 @@
#!/bin/bash
# Wrapper for zip to parse progress dots into structured log format
-# Usage: zip_wrapper
+# Usage: zip_wrapper
archive="$1"
-format="$2"
-dot_size_mb="${3:-100}"
-shift 3
+shift
source_files=("$@")
status_file="/var/tmp/zip.progress"
-> "$status_file"
+: > "$status_file"
# Run zip with dot progress and parse output character-by-character
-zip --recurse-paths --display-usize --display-dots --dot-size "${dot_size_mb}m" "$archive" -- "${source_files[@]}" 2>&1 | while IFS= read -r -n1 char || [[ -n $char ]]; do
+zip --recurse-paths --display-usize --display-dots --dot-size 100m "$archive" -- "${source_files[@]}" 2>&1 | while IFS= read -r -n1 char || [[ -n $char ]]; do
# Line mode - accumulate characters until we detect dot mode or line end
if [[ ! $dot_mode ]]; then
@@ -23,7 +21,8 @@ zip --recurse-paths --display-usize --display-dots --dot-size "${dot_size_mb}m"
if [[ $line_buffer =~ adding:\ (.*)\ \(([0-9.]+[KMGT]?)\)\ $ ]]; then
src_file="${BASH_REMATCH[1]}"
src_size="${BASH_REMATCH[2]}"
- printf 'FILE|%s|%s\n' "$src_file" "$src_size" >> "$status_file"
+ # Format: FILE|size|filename (filename at end to handle pipes in filenames)
+ printf 'FILE|%s|%s\n' "$src_size" "$src_file" >> "$status_file"
line_buffer=""
dot_mode=1
From d34461884f4796e00d691db4975c1047d45ac202 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 13:36:55 +0100
Subject: [PATCH 009/173] Add debug logging to parse_zip_progress for
troubleshooting
---
emhttp/plugins/dynamix/nchan/file_manager | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 317b74fe48..1f29ec45df 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -186,15 +186,19 @@ function parse_zip_progress($status, $action_label, $archive_path, $dot_size_mb
// Check if status file exists
if (!file_exists($status)) {
+ exec('logger -t file_manager "parse_zip_progress: status file does not exist: ' . escapeshellarg($status) . '"');
return $text;
}
// Read structured log (FILE|size|filename, DOT|timestamp, INFO|message, DONE)
$lines = file($status, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (empty($lines)) {
+ exec('logger -t file_manager "parse_zip_progress: status file is empty"');
return $text;
}
+ exec('logger -t file_manager "parse_zip_progress: read ' . count($lines) . ' lines from status file"');
+
$current_file = '';
$dot_timestamps = [];
$done = false;
@@ -228,6 +232,8 @@ function parse_zip_progress($status, $action_label, $archive_path, $dot_size_mb
$text[0] .= mb_strimhalf($current_file, 70, '...');
}
+ exec('logger -t file_manager "parse_zip_progress: current_file=' . escapeshellarg($current_file) . ', dot_count=' . count($dot_timestamps) . '"');
+
// Calculate progress if we have dots
$dot_count = count($dot_timestamps);
if ($dot_count > 0) {
@@ -825,10 +831,15 @@ while (true) {
$archive_name = $archive_name ?? 'archive.zip';
$archive_path = rtrim($target, '/').'/'.$archive_name;
+ // Debug logging
+ exec('logger -t file_manager "Status check: format=' . escapeshellarg($format) . ', archive_name=' . escapeshellarg($archive_name) . ', target=' . escapeshellarg($target) . '"');
+
// For zip format, use structured progress parser
if ($format === 'zip') {
$zip_status = '/var/tmp/zip.progress';
+ exec('logger -t file_manager "Calling parse_zip_progress: status_file=' . escapeshellarg($zip_status) . ', exists=' . (file_exists($zip_status) ? 'yes' : 'no') . '"');
$text = parse_zip_progress($zip_status, _('Compressing'), $archive_path, 100);
+ exec('logger -t file_manager "parse_zip_progress returned: ' . escapeshellarg(json_encode($text)) . '"');
} else {
// For tar formats, use simple status display for now
$file_line = trim(exec("tail -1 $status 2>$null"));
From 954c03572a5a02435a02ce155e534d8ca566c858 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 13:42:50 +0100
Subject: [PATCH 010/173] Add extensive debug logging to trace status check
flow
---
emhttp/plugins/dynamix/nchan/file_manager | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 1f29ec45df..d5d7ddd8e3 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -536,11 +536,15 @@ while (true) {
$reply = [];
if (isset($action)) {
+ exec('logger -t file_manager "Loop iteration: action=' . escapeshellarg($action) . ', pid=' . escapeshellarg($pid ?: 'empty') . ', active_exists=' . (file_exists($active) ? 'yes' : 'no') . '"');
+
// check for language changes
extract(parse_plugin_cfg('dynamix', true));
// Restore compress parameters after plugin config extract
if (isset($compress_format)) $format = $compress_format;
if (isset($compress_archive_name)) $archive_name = $compress_archive_name;
+
+ exec('logger -t file_manager "After restore: action=' . escapeshellarg($action) . ', format=' . escapeshellarg($format ?? 'unset') . ', archive_name=' . escapeshellarg($archive_name ?? 'unset') . '"');
if ($display['locale'] != $locale_init) {
$locale_init = $display['locale'];
update_translation($locale_init);
@@ -824,9 +828,11 @@ while (true) {
}
break;
case 16: // compress
+ exec('logger -t file_manager "Case 16: pid=' . escapeshellarg($pid ?: 'empty') . ', format=' . escapeshellarg($format ?? 'unset') . ', archive_name=' . escapeshellarg($archive_name ?? 'unset') . '"');
// return status of running action
if (!empty($pid)) {
+ exec('logger -t file_manager "Inside status check block: pid=' . escapeshellarg($pid) . '"');
$format = $format ?? 'zip';
$archive_name = $archive_name ?? 'archive.zip';
$archive_path = rtrim($target, '/').'/'.$archive_name;
@@ -866,6 +872,7 @@ while (true) {
// start action
} else {
+ exec('logger -t file_manager "Start compress: pid is empty, starting new job"');
$format = $format ?? 'zip';
$archive_name = $archive_name ?? 'archive.zip';
$archive_path = rtrim($target, '/').'/'.$archive_name;
From 0f293f00f4078bf830f01220219366a802f66d3b Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 13:45:20 +0100
Subject: [PATCH 011/173] Add debug logging around pid handling and cleanup
---
emhttp/plugins/dynamix/nchan/file_manager | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index d5d7ddd8e3..915affd8a1 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -931,6 +931,7 @@ while (true) {
exec('logger -t file_manager "Executing compress command: ' . escapeshellarg($cmd) . '"');
exec($cmd, $pid);
+ exec('logger -t file_manager "After exec: pid=' . escapeshellarg(json_encode($pid)) . '"');
}
break;
case 17: // extract
@@ -987,6 +988,7 @@ while (true) {
continue 2;
}
$pid = pid_exists($pid??0);
+ exec('logger -t file_manager "After pid_exists: action=' . escapeshellarg($action) . ', pid=' . escapeshellarg($pid ?: 'false') . ', pid_file_will_be_created=' . ($pid !== false ? 'yes' : 'no') . '"');
// Store PID to survive file_manager restarts
if ($pid !== false) {
@@ -994,6 +996,7 @@ while (true) {
}
if ($pid === false) {
+ exec('logger -t file_manager "PID is false - entering cleanup: delete_empty_dirs=' . escapeshellarg($delete_empty_dirs ?? 'null') . ', action=' . escapeshellarg($action) . '"');
if (!empty($delete_empty_dirs)) {
exec("find ".quoted($source)." -type d -empty -print -delete 1>$status 2>$null & echo \$!", $pid);
$delete_empty_dirs = false;
@@ -1021,6 +1024,7 @@ while (true) {
}
}
if (file_exists($error)) $reply['error'] = trim(file_get_contents($error));
+ exec('logger -t file_manager "Job complete - deleting active file for action=' . escapeshellarg($action) . '"');
delete_file($active, $pid_file, $status, $error);
unset($pid);
$delete_empty_dirs = null;
From cffb522cb47441b3b3e4ecc194e571d522259d3a Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 13:48:35 +0100
Subject: [PATCH 012/173] Fix zip PID issue by removing pipeline
- Change from pipeline to direct redirect
- Avoids PID mismatch (pipeline returns wrong PID)
- zip_wrapper logs to /var/tmp/zip.progress independently
---
emhttp/plugins/dynamix/nchan/file_manager | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 915affd8a1..e1c1f4f561 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -902,9 +902,8 @@ while (true) {
$cmd = "cd ".quoted($source_parent)." && ";
switch ($format) {
case 'zip':
- // Use zip_wrapper for structured progress output
$zip_wrapper = '/usr/local/emhttp/plugins/dynamix/scripts/zip_wrapper';
- $cmd .= "$zip_wrapper ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
+ $cmd .= "$zip_wrapper ".quoted($archive_path)." ".$escaped_basenames." >$null 2>&1";
break;
case 'tar':
$cmd .= "tar -cvf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
From 105cfd4a0a2cb8ff33f79380ea7d96d16dd0f3a4 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 14:10:59 +0100
Subject: [PATCH 013/173] Add PID and process tree debug logging for zip
progress tracking
---
emhttp/plugins/dynamix/nchan/file_manager | 11 ++++++++++-
emhttp/plugins/dynamix/scripts/zip_wrapper | 7 +++++++
2 files changed, 17 insertions(+), 1 deletion(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index e1c1f4f561..7c314736af 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -930,7 +930,16 @@ while (true) {
exec('logger -t file_manager "Executing compress command: ' . escapeshellarg($cmd) . '"');
exec($cmd, $pid);
- exec('logger -t file_manager "After exec: pid=' . escapeshellarg(json_encode($pid)) . '"');
+ exec('logger -t file_manager "After exec: pid=' . escapeshellarg(json_encode($pid)) . ', raw_pid_array=' . escapeshellarg(print_r($pid, true)) . '"');
+
+ // Check immediately if PID exists
+ if (!empty($pid) && is_array($pid)) {
+ $pid_to_check = $pid[0];
+ exec('logger -t file_manager "Checking if PID ' . escapeshellarg($pid_to_check) . ' exists: ' . (file_exists("/proc/$pid_to_check") ? 'yes' : 'no') . '"');
+ if (file_exists("/proc/$pid_to_check")) {
+ exec('logger -t file_manager "Process ' . escapeshellarg($pid_to_check) . ' cmdline: ' . escapeshellarg(file_get_contents("/proc/$pid_to_check/cmdline")) . '"');
+ }
+ }
}
break;
case 17: // extract
diff --git a/emhttp/plugins/dynamix/scripts/zip_wrapper b/emhttp/plugins/dynamix/scripts/zip_wrapper
index 1bb21c0046..42d06cbf9a 100755
--- a/emhttp/plugins/dynamix/scripts/zip_wrapper
+++ b/emhttp/plugins/dynamix/scripts/zip_wrapper
@@ -9,6 +9,10 @@ source_files=("$@")
status_file="/var/tmp/zip.progress"
: > "$status_file"
+# Debug: log wrapper PID and process tree
+logger -t zip_wrapper "Started with PID=$$, PPID=$PPID, archive=$archive"
+logger -t zip_wrapper "Process tree: $(pstree -pals $$ 2>&1 || echo 'pstree failed')"
+
# Run zip with dot progress and parse output character-by-character
zip --recurse-paths --display-usize --display-dots --dot-size 100m "$archive" -- "${source_files[@]}" 2>&1 | while IFS= read -r -n1 char || [[ -n $char ]]; do
@@ -47,3 +51,6 @@ done
# Write completion marker
printf 'DONE\n' >> "$status_file"
+
+# Debug: log wrapper completion
+logger -t zip_wrapper "Completed, PID=$$"
From 5a245a8381dc57382399872f4144a04553d3b412 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 14:53:14 +0100
Subject: [PATCH 014/173] Rewrite zip progress parser: incremental reading with
total size calculation
---
emhttp/plugins/dynamix/nchan/file_manager | 276 ++++++++++++----------
1 file changed, 146 insertions(+), 130 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 7c314736af..53d47d5589 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -157,26 +157,39 @@ function calculate_eta($transferred, $percent, $speed, $last_rsync_eta_seconds =
/**
* Parse zip progress output from structured log format.
*
- * Reads structured log file created by zip_wrapper script with format:
- * FILE|size|filename, DOT|timestamp, INFO|message, DONE
- * Filename is at the end to handle pipe characters in filenames.
+ * Reads structured log file incrementally with format:
+ * TOTAL|bytes (first line with total source size)
+ * FILE|size|filename (filename at end to handle pipes in filename)
+ * DOT|timestamp (each 100MB compressed)
+ * INFO|message (optional)
*
* @param string $status Path to structured log file
* @param string $action_label Label to display for the action (e.g. "Compressing")
- * @param string $archive_path Path to archive file for size calculation
* @param int $dot_size_mb Size of each progress dot in MB (default 100)
* @param bool $reset If true, resets static state variables
* @return array Text array with progress information
*/
-function parse_zip_progress($status, $action_label, $archive_path, $dot_size_mb = 100, $reset = false) {
- static $total_dots_estimate = null;
+function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset = false) {
+ static $last_line_number = 0;
+ static $total_bytes = null;
+ static $processed_bytes = 0;
+ static $current_file_name = '';
+ static $current_file_size = 0;
+ static $current_file_dots = 0;
static $first_dot_time = null;
+ static $last_dot_time = null;
static $last_eta_seconds = null;
// Reset static variables when starting a new archive
if ($reset) {
- $total_dots_estimate = null;
+ $last_line_number = 0;
+ $total_bytes = null;
+ $processed_bytes = 0;
+ $current_file_name = '';
+ $current_file_size = 0;
+ $current_file_dots = 0;
$first_dot_time = null;
+ $last_dot_time = null;
$last_eta_seconds = null;
return [];
}
@@ -186,113 +199,118 @@ function parse_zip_progress($status, $action_label, $archive_path, $dot_size_mb
// Check if status file exists
if (!file_exists($status)) {
- exec('logger -t file_manager "parse_zip_progress: status file does not exist: ' . escapeshellarg($status) . '"');
return $text;
}
- // Read structured log (FILE|size|filename, DOT|timestamp, INFO|message, DONE)
- $lines = file($status, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
- if (empty($lines)) {
- exec('logger -t file_manager "parse_zip_progress: status file is empty"');
+ // Get total line count to know if there are new lines
+ $total_lines = intval(exec("wc -l < " . escapeshellarg($status) . " 2>/dev/null || echo 0"));
+ if ($total_lines == 0) {
return $text;
}
- exec('logger -t file_manager "parse_zip_progress: read ' . count($lines) . ' lines from status file"');
-
- $current_file = '';
- $dot_timestamps = [];
- $done = false;
-
- // Parse log lines
- foreach ($lines as $line) {
- $parts = explode('|', $line, 3);
- if (count($parts) < 2) continue;
-
- $type = $parts[0];
- switch ($type) {
- case 'FILE':
- // FILE|size|filename (filename at end to handle pipes in filename)
- $current_file = $parts[2] ?? '';
- break;
- case 'DOT':
- // DOT|timestamp
- $dot_timestamps[] = intval($parts[1]);
- break;
- case 'INFO':
- // INFO|message (for future use)
- break;
- case 'DONE':
- $done = true;
- break;
+ // Read only new lines since last call (incremental)
+ if ($last_line_number < $total_lines) {
+ $start_line = $last_line_number + 1;
+ $new_lines = [];
+ exec("tail -n +" . escapeshellarg($start_line) . " " . escapeshellarg($status) . " 2>/dev/null", $new_lines);
+
+ // Process new lines
+ foreach ($new_lines as $line) {
+ $parts = explode('|', $line, 3);
+ if (count($parts) < 2) continue;
+
+ $type = $parts[0];
+ switch ($type) {
+ case 'TOTAL':
+ // First line: total size of all source files
+ $total_bytes = floatval($parts[1]);
+ break;
+
+ case 'FILE':
+ // New file started - previous file is complete
+ if ($current_file_size > 0) {
+ // Add completed file to processed bytes
+ $processed_bytes += $current_file_size;
+ }
+
+ // Start tracking new file
+ $current_file_size = size_to_bytes($parts[1]);
+ $current_file_name = $parts[2] ?? '';
+ $current_file_dots = 0;
+ break;
+
+ case 'DOT':
+ // Progress dot for current file
+ $timestamp = intval($parts[1]);
+ $current_file_dots++;
+
+ if ($first_dot_time === null) {
+ $first_dot_time = $timestamp;
+ }
+ $last_dot_time = $timestamp;
+ break;
+
+ case 'INFO':
+ // Info message (currently unused)
+ break;
+ }
}
+
+ $last_line_number = $total_lines;
}
// Display current filename
- if ($current_file) {
- $text[0] .= mb_strimhalf($current_file, 70, '...');
+ if ($current_file_name) {
+ $text[0] .= mb_strimhalf($current_file_name, 70, '...');
}
- exec('logger -t file_manager "parse_zip_progress: current_file=' . escapeshellarg($current_file) . ', dot_count=' . count($dot_timestamps) . '"');
-
- // Calculate progress if we have dots
- $dot_count = count($dot_timestamps);
- if ($dot_count > 0) {
- // Get archive size for progress indication
- $archive_size_bytes = file_exists($archive_path) ? filesize($archive_path) : 0;
- $archive_size = bytes_to_size($archive_size_bytes);
-
- // Calculate speed from dot timestamps
+ // Calculate progress if we have total size
+ if ($total_bytes && $total_bytes > 0) {
+ // Current progress = completed files + dots of current file
+ $dots_bytes = $current_file_dots * $dot_size_mb * 1024 * 1024;
+ $current_progress_bytes = $processed_bytes + $dots_bytes;
+
+ // Calculate percentage
+ $percent = min(100, intval(($current_progress_bytes / $total_bytes) * 100));
+
+ // Calculate speed and ETA from dot timestamps
$speed = '';
$eta = 'N/A';
- $percent = 'N/A';
-
- if ($dot_count >= 2) {
- // Calculate speed from first to last dot
- $time_span = $dot_timestamps[$dot_count - 1] - $dot_timestamps[0];
- if ($time_span > 0) {
- // dots/second * dot_size_mb = MB/s
- $dots_per_second = ($dot_count - 1) / $time_span;
- $speed_mb = $dots_per_second * $dot_size_mb;
- $speed = bytes_to_size($speed_mb * 1024 * 1024) . '/s';
-
- // Estimate total dots if we have archive size
- // (this is rough - actual compression ratio unknown until done)
- if ($archive_size_bytes > 0 && $total_dots_estimate === null) {
- // Estimate based on current progress vs archive size
- // Since we don't know final size, assume current compression ratio
- $bytes_per_dot = $archive_size_bytes / $dot_count;
- if ($bytes_per_dot > 0) {
- $estimated_final_size = $archive_size_bytes * 1.5; // rough estimate
- $total_dots_estimate = max($dot_count + 5, intval($estimated_final_size / $bytes_per_dot));
- }
- }
-
- // Calculate ETA if we have estimate
- if ($total_dots_estimate && $total_dots_estimate > $dot_count) {
- $remaining_dots = $total_dots_estimate - $dot_count;
- $eta_seconds = $remaining_dots / $dots_per_second;
-
+
+ if ($first_dot_time !== null && $last_dot_time !== null && $last_dot_time > $first_dot_time) {
+ $time_span = $last_dot_time - $first_dot_time;
+ $dots_processed = $current_file_dots; // approximate total dots across all files
+
+ if ($time_span > 0 && $dots_processed > 1) {
+ // Calculate speed: dots * dot_size / time
+ $speed_bytes = ($dots_processed * $dot_size_mb * 1024 * 1024) / $time_span;
+ $speed = bytes_to_size($speed_bytes) . '/s';
+
+ // Calculate ETA
+ $remaining_bytes = $total_bytes - $current_progress_bytes;
+ if ($speed_bytes > 0) {
+ $eta_seconds = $remaining_bytes / $speed_bytes;
+
// Apply hysteresis to prevent ETA from jumping
if ($last_eta_seconds !== null) {
$eta_seconds = ($last_eta_seconds * 0.7) + ($eta_seconds * 0.3);
}
$last_eta_seconds = $eta_seconds;
-
+
$eta = seconds_to_time(intval($eta_seconds));
- $percent = intval(($dot_count / $total_dots_estimate) * 100) . '%';
}
}
}
-
+
// Build progress text
$progress_parts = [];
- $progress_parts[] = _('Completed') . ": " . $percent;
- $progress_parts[] = _('Archive') . ": " . $archive_size;
+ $progress_parts[] = _('Completed') . ": " . $percent . '%';
+ $progress_parts[] = _('Total') . ": " . bytes_to_size($total_bytes);
if ($speed) {
$progress_parts[] = _('Speed') . ": " . $speed;
}
$progress_parts[] = _('ETA') . ": " . $eta;
-
+
$text[1] = implode(", ", $progress_parts);
}
@@ -536,15 +554,11 @@ while (true) {
$reply = [];
if (isset($action)) {
- exec('logger -t file_manager "Loop iteration: action=' . escapeshellarg($action) . ', pid=' . escapeshellarg($pid ?: 'empty') . ', active_exists=' . (file_exists($active) ? 'yes' : 'no') . '"');
-
// check for language changes
extract(parse_plugin_cfg('dynamix', true));
// Restore compress parameters after plugin config extract
if (isset($compress_format)) $format = $compress_format;
if (isset($compress_archive_name)) $archive_name = $compress_archive_name;
-
- exec('logger -t file_manager "After restore: action=' . escapeshellarg($action) . ', format=' . escapeshellarg($format ?? 'unset') . ', archive_name=' . escapeshellarg($archive_name ?? 'unset') . '"');
if ($display['locale'] != $locale_init) {
$locale_init = $display['locale'];
update_translation($locale_init);
@@ -828,24 +842,16 @@ while (true) {
}
break;
case 16: // compress
- exec('logger -t file_manager "Case 16: pid=' . escapeshellarg($pid ?: 'empty') . ', format=' . escapeshellarg($format ?? 'unset') . ', archive_name=' . escapeshellarg($archive_name ?? 'unset') . '"');
// return status of running action
if (!empty($pid)) {
- exec('logger -t file_manager "Inside status check block: pid=' . escapeshellarg($pid) . '"');
$format = $format ?? 'zip';
$archive_name = $archive_name ?? 'archive.zip';
$archive_path = rtrim($target, '/').'/'.$archive_name;
- // Debug logging
- exec('logger -t file_manager "Status check: format=' . escapeshellarg($format) . ', archive_name=' . escapeshellarg($archive_name) . ', target=' . escapeshellarg($target) . '"');
-
// For zip format, use structured progress parser
if ($format === 'zip') {
- $zip_status = '/var/tmp/zip.progress';
- exec('logger -t file_manager "Calling parse_zip_progress: status_file=' . escapeshellarg($zip_status) . ', exists=' . (file_exists($zip_status) ? 'yes' : 'no') . '"');
- $text = parse_zip_progress($zip_status, _('Compressing'), $archive_path, 100);
- exec('logger -t file_manager "parse_zip_progress returned: ' . escapeshellarg(json_encode($text)) . '"');
+ $text = parse_zip_progress($status, _('Compressing'), 100);
} else {
// For tar formats, use simple status display for now
$file_line = trim(exec("tail -1 $status 2>$null"));
@@ -872,14 +878,11 @@ while (true) {
// start action
} else {
- exec('logger -t file_manager "Start compress: pid is empty, starting new job"');
+ parse_zip_progress(null, null, 100, true); // Reset static variables
$format = $format ?? 'zip';
$archive_name = $archive_name ?? 'archive.zip';
$archive_path = rtrim($target, '/').'/'.$archive_name;
- // Debug logging
- exec('logger -t file_manager "Compress action started: format=' . escapeshellarg($format) . ', archive_name=' . escapeshellarg($archive_name) . ', sources=' . escapeshellarg(implode(', ', $source)) . ', target=' . escapeshellarg($target) . '"');
-
// For multiple sources, find common parent directory
// For single source, use its parent directory
if (count($source) > 1) {
@@ -897,49 +900,65 @@ while (true) {
}
// Build compression command based on format
- // Note: Using escapeshellarg() directly for basenames since quoted() expects full paths
$escaped_basenames = implode(' ', array_map('escapeshellarg', $source_basenames));
- $cmd = "cd ".quoted($source_parent)." && ";
+
switch ($format) {
case 'zip':
- $zip_wrapper = '/usr/local/emhttp/plugins/dynamix/scripts/zip_wrapper';
- $cmd .= "$zip_wrapper ".quoted($archive_path)." ".$escaped_basenames." >$null 2>&1";
+ // Inline zip wrapper with structured progress output
+ $cmd = <</dev/null | awk '{sum+=\$1} END {print sum}')
+ printf 'TOTAL|%s\\n' "\$total_bytes" > $status
+
+ zip --recurse-paths --display-usize --display-dots --dot-size 100m {$archive_path} -- {$escaped_basenames} 2>&1 | while IFS= read -r -n1 char || [[ -n \$char ]]; do
+ if [[ ! \$dot_mode ]]; then
+ line_buffer+="\$char"
+
+ if [[ \$line_buffer =~ adding:\\ (.*)\\ \\(([0-9.]+[KMGT]?)\\)\\ $ ]]; then
+ src_file="\${BASH_REMATCH[1]}"
+ src_size="\${BASH_REMATCH[2]}"
+ printf 'FILE|%s|%s\\n' "\$src_size" "\$src_file" >> $status
+ line_buffer=""
+ dot_mode=1
+ elif [[ ! \$char ]]; then
+ [[ -n \$line_buffer ]] && printf 'INFO|%s\\n' "\$line_buffer" >> $status
+ line_buffer=""
+ fi
+ elif [[ \$dot_mode ]]; then
+ if [[ \$char == '.' ]]; then
+ printf 'DOT|%s\\n' "\$(date +%s)" >> $status
+ else
+ line_buffer="\$char"
+ dot_mode=
+ fi
+ fi
+ done
+ } 2>$error & echo \$!
+ BASH;
break;
case 'tar':
- $cmd .= "tar -cvf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
+ $cmd = "cd ".quoted($source_parent)." && tar -cvf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status & echo \$!";
break;
case 'tar.gz':
- $cmd .= "tar -czvf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
+ $cmd = "cd ".quoted($source_parent)." && tar -czvf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status & echo \$!";
break;
case 'tar.zst':
- // Use zstd with tar, -v for verbose output to show filenames
- $cmd .= "tar --use-compress-program=zstd -cvf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
+ $cmd = "cd ".quoted($source_parent)." && tar --use-compress-program=zstd -cvf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status & echo \$!";
break;
case 'tar.bz2':
- $cmd .= "tar -cvjf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
+ $cmd = "cd ".quoted($source_parent)." && tar -cvjf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status & echo \$!";
break;
case 'tar.xz':
- $cmd .= "tar -cvJf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
+ $cmd = "cd ".quoted($source_parent)." && tar -cvJf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status & echo \$!";
break;
default:
- $cmd .= "zip -r ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status";
+ $cmd = "cd ".quoted($source_parent)." && zip -r ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status & echo \$!";
}
- $cmd .= " & echo \$!";
-
- // Debug: log the command before execution
- exec('logger -t file_manager "Executing compress command: ' . escapeshellarg($cmd) . '"');
exec($cmd, $pid);
- exec('logger -t file_manager "After exec: pid=' . escapeshellarg(json_encode($pid)) . ', raw_pid_array=' . escapeshellarg(print_r($pid, true)) . '"');
-
- // Check immediately if PID exists
- if (!empty($pid) && is_array($pid)) {
- $pid_to_check = $pid[0];
- exec('logger -t file_manager "Checking if PID ' . escapeshellarg($pid_to_check) . ' exists: ' . (file_exists("/proc/$pid_to_check") ? 'yes' : 'no') . '"');
- if (file_exists("/proc/$pid_to_check")) {
- exec('logger -t file_manager "Process ' . escapeshellarg($pid_to_check) . ' cmdline: ' . escapeshellarg(file_get_contents("/proc/$pid_to_check/cmdline")) . '"');
- }
- }
}
break;
case 17: // extract
@@ -996,7 +1015,6 @@ while (true) {
continue 2;
}
$pid = pid_exists($pid??0);
- exec('logger -t file_manager "After pid_exists: action=' . escapeshellarg($action) . ', pid=' . escapeshellarg($pid ?: 'false') . ', pid_file_will_be_created=' . ($pid !== false ? 'yes' : 'no') . '"');
// Store PID to survive file_manager restarts
if ($pid !== false) {
@@ -1004,7 +1022,6 @@ while (true) {
}
if ($pid === false) {
- exec('logger -t file_manager "PID is false - entering cleanup: delete_empty_dirs=' . escapeshellarg($delete_empty_dirs ?? 'null') . ', action=' . escapeshellarg($action) . '"');
if (!empty($delete_empty_dirs)) {
exec("find ".quoted($source)." -type d -empty -print -delete 1>$status 2>$null & echo \$!", $pid);
$delete_empty_dirs = false;
@@ -1032,7 +1049,6 @@ while (true) {
}
}
if (file_exists($error)) $reply['error'] = trim(file_get_contents($error));
- exec('logger -t file_manager "Job complete - deleting active file for action=' . escapeshellarg($action) . '"');
delete_file($active, $pid_file, $status, $error);
unset($pid);
$delete_empty_dirs = null;
From 06de83f05d6f73901e5f4a4e369f9e31708f76e5 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 14:56:10 +0100
Subject: [PATCH 015/173] Add debug logging and remove obsolete zip_wrapper
script
---
emhttp/plugins/dynamix/nchan/file_manager | 15 ++++++
emhttp/plugins/dynamix/scripts/zip_wrapper | 56 ----------------------
2 files changed, 15 insertions(+), 56 deletions(-)
delete mode 100755 emhttp/plugins/dynamix/scripts/zip_wrapper
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 53d47d5589..f28010ad05 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -199,12 +199,15 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
// Check if status file exists
if (!file_exists($status)) {
+ exec('logger -t file_manager "parse_zip_progress: status file does not exist: ' . escapeshellarg($status) . '"');
return $text;
}
// Get total line count to know if there are new lines
$total_lines = intval(exec("wc -l < " . escapeshellarg($status) . " 2>/dev/null || echo 0"));
+ exec('logger -t file_manager "parse_zip_progress: total_lines=' . $total_lines . ', last_line_number=' . $last_line_number . '"');
if ($total_lines == 0) {
+ exec('logger -t file_manager "parse_zip_progress: status file is empty"');
return $text;
}
@@ -213,6 +216,7 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
$start_line = $last_line_number + 1;
$new_lines = [];
exec("tail -n +" . escapeshellarg($start_line) . " " . escapeshellarg($status) . " 2>/dev/null", $new_lines);
+ exec('logger -t file_manager "parse_zip_progress: read ' . count($new_lines) . ' new lines from line ' . $start_line . '"');
// Process new lines
foreach ($new_lines as $line) {
@@ -264,6 +268,8 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
$text[0] .= mb_strimhalf($current_file_name, 70, '...');
}
+ exec('logger -t file_manager "parse_zip_progress: total_bytes=' . $total_bytes . ', processed_bytes=' . $processed_bytes . ', current_file_dots=' . $current_file_dots . '"');
+
// Calculate progress if we have total size
if ($total_bytes && $total_bytes > 0) {
// Current progress = completed files + dots of current file
@@ -845,13 +851,17 @@ while (true) {
// return status of running action
if (!empty($pid)) {
+ exec('logger -t file_manager "Case 16 status check: pid=' . escapeshellarg($pid) . '"');
$format = $format ?? 'zip';
$archive_name = $archive_name ?? 'archive.zip';
$archive_path = rtrim($target, '/').'/'.$archive_name;
+ exec('logger -t file_manager "Status check: format=' . escapeshellarg($format) . ', status_file=' . escapeshellarg($status) . ', exists=' . (file_exists($status) ? 'yes' : 'no') . '"');
+
// For zip format, use structured progress parser
if ($format === 'zip') {
$text = parse_zip_progress($status, _('Compressing'), 100);
+ exec('logger -t file_manager "parse_zip_progress returned: ' . escapeshellarg(json_encode($text)) . '"');
} else {
// For tar formats, use simple status display for now
$file_line = trim(exec("tail -1 $status 2>$null"));
@@ -878,11 +888,14 @@ while (true) {
// start action
} else {
+ exec('logger -t file_manager "Case 16 start: pid is empty, starting new job"');
parse_zip_progress(null, null, 100, true); // Reset static variables
$format = $format ?? 'zip';
$archive_name = $archive_name ?? 'archive.zip';
$archive_path = rtrim($target, '/').'/'.$archive_name;
+ exec('logger -t file_manager "Start compress: format=' . escapeshellarg($format) . ', archive=' . escapeshellarg($archive_path) . '"');
+
// For multiple sources, find common parent directory
// For single source, use its parent directory
if (count($source) > 1) {
@@ -958,7 +971,9 @@ while (true) {
$cmd = "cd ".quoted($source_parent)." && zip -r ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status & echo \$!";
}
+ exec('logger -t file_manager "Executing command (first 200 chars): ' . escapeshellarg(substr($cmd, 0, 200)) . '"');
exec($cmd, $pid);
+ exec('logger -t file_manager "After exec: pid=' . escapeshellarg(json_encode($pid)) . '"');
}
break;
case 17: // extract
diff --git a/emhttp/plugins/dynamix/scripts/zip_wrapper b/emhttp/plugins/dynamix/scripts/zip_wrapper
deleted file mode 100755
index 42d06cbf9a..0000000000
--- a/emhttp/plugins/dynamix/scripts/zip_wrapper
+++ /dev/null
@@ -1,56 +0,0 @@
-#!/bin/bash
-# Wrapper for zip to parse progress dots into structured log format
-# Usage: zip_wrapper
-
-archive="$1"
-shift
-source_files=("$@")
-
-status_file="/var/tmp/zip.progress"
-: > "$status_file"
-
-# Debug: log wrapper PID and process tree
-logger -t zip_wrapper "Started with PID=$$, PPID=$PPID, archive=$archive"
-logger -t zip_wrapper "Process tree: $(pstree -pals $$ 2>&1 || echo 'pstree failed')"
-
-# Run zip with dot progress and parse output character-by-character
-zip --recurse-paths --display-usize --display-dots --dot-size 100m "$archive" -- "${source_files[@]}" 2>&1 | while IFS= read -r -n1 char || [[ -n $char ]]; do
-
- # Line mode - accumulate characters until we detect dot mode or line end
- if [[ ! $dot_mode ]]; then
- line_buffer+="$char"
-
- # Detect start of dot progress: "adding: path/file (SIZE) "
- # Pattern matches when line ends with file size like "(1.2G) " or "(200M) "
- if [[ $line_buffer =~ adding:\ (.*)\ \(([0-9.]+[KMGT]?)\)\ $ ]]; then
- src_file="${BASH_REMATCH[1]}"
- src_size="${BASH_REMATCH[2]}"
- # Format: FILE|size|filename (filename at end to handle pipes in filenames)
- printf 'FILE|%s|%s\n' "$src_size" "$src_file" >> "$status_file"
- line_buffer=""
- dot_mode=1
-
- # Line completed without entering dot mode - write as info line
- elif [[ ! $char ]]; then
- [[ -n $line_buffer ]] && printf 'INFO|%s\n' "$line_buffer" >> "$status_file"
- line_buffer=""
- fi
-
- # Dot mode - log each dot with timestamp, exit on non-dot character
- elif [[ $dot_mode ]]; then
- if [[ $char == '.' ]]; then
- printf 'DOT|%s\n' "$(date +%s)" >> "$status_file"
- else
- # Back to line mode
- line_buffer="$char"
- dot_mode=
- fi
- fi
-
-done
-
-# Write completion marker
-printf 'DONE\n' >> "$status_file"
-
-# Debug: log wrapper completion
-logger -t zip_wrapper "Completed, PID=$$"
From 1f035983d9ca4253fe20f077ff68a5fe7a8e0d2b Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 15:08:29 +0100
Subject: [PATCH 016/173] Add debug logging to pid_exists and after switch
statement
---
emhttp/plugins/dynamix/nchan/file_manager | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index f28010ad05..c7ecb5d820 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -47,8 +47,11 @@ function fm_pools_filter(&$disks) {
}
function pid_exists($pid) {
+ $original_pid = $pid;
$pid = is_array($pid) ? $pid[0] : $pid;
- return $pid && file_exists("/proc/$pid") ? $pid : false;
+ $exists = file_exists("/proc/$pid");
+ exec('logger -t file_manager "pid_exists: original=' . escapeshellarg(json_encode($original_pid)) . ', extracted=' . escapeshellarg($pid) . ', /proc exists=' . escapeshellarg($exists ? 'YES' : 'NO') . '"');
+ return $pid && $exists ? $pid : false;
}
function isdir($name) {
@@ -1029,7 +1032,9 @@ while (true) {
default:
continue 2;
}
+ exec('logger -t file_manager "After switch: action=' . $action . ', pid before pid_exists=' . escapeshellarg(json_encode($pid??null)) . '"');
$pid = pid_exists($pid??0);
+ exec('logger -t file_manager "After pid_exists: pid=' . escapeshellarg(json_encode($pid)) . '"');
// Store PID to survive file_manager restarts
if ($pid !== false) {
From 1f614d79cf023e29351516b011780758e93bc22f Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 15:23:34 +0100
Subject: [PATCH 017/173] Fix PID tracking: redirect heredoc block to status
file to keep shell alive
---
emhttp/plugins/dynamix/nchan/file_manager | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index c7ecb5d820..5466ac7fce 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -921,13 +921,16 @@ while (true) {
switch ($format) {
case 'zip':
// Inline zip wrapper with structured progress output
+ // CRITICAL: The '>$status' redirect at the end keeps the shell group process alive.
+ // Without it, the shell group exits immediately after backgrounding, making the PID invalid.
+ // The redirect forces the shell to wait for the entire pipeline to complete.
$cmd = <</dev/null | awk '{sum+=\$1} END {print sum}')
- printf 'TOTAL|%s\\n' "\$total_bytes" > $status
+ printf 'TOTAL|%s\\n' "\$total_bytes"
zip --recurse-paths --display-usize --display-dots --dot-size 100m {$archive_path} -- {$escaped_basenames} 2>&1 | while IFS= read -r -n1 char || [[ -n \$char ]]; do
if [[ ! \$dot_mode ]]; then
@@ -936,23 +939,23 @@ while (true) {
if [[ \$line_buffer =~ adding:\\ (.*)\\ \\(([0-9.]+[KMGT]?)\\)\\ $ ]]; then
src_file="\${BASH_REMATCH[1]}"
src_size="\${BASH_REMATCH[2]}"
- printf 'FILE|%s|%s\\n' "\$src_size" "\$src_file" >> $status
+ printf 'FILE|%s|%s\\n' "\$src_size" "\$src_file"
line_buffer=""
dot_mode=1
elif [[ ! \$char ]]; then
- [[ -n \$line_buffer ]] && printf 'INFO|%s\\n' "\$line_buffer" >> $status
+ [[ -n \$line_buffer ]] && printf 'INFO|%s\\n' "\$line_buffer"
line_buffer=""
fi
elif [[ \$dot_mode ]]; then
if [[ \$char == '.' ]]; then
- printf 'DOT|%s\\n' "\$(date +%s)" >> $status
+ printf 'DOT|%s\\n' "\$(date +%s)"
else
line_buffer="\$char"
dot_mode=
fi
fi
done
- } 2>$error & echo \$!
+ } >$status 2>$error & echo \$!
BASH;
break;
case 'tar':
From e8d219c9231fd00e424dac6154f2022c31133a37 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 15:30:43 +0100
Subject: [PATCH 018/173] Copy status file to /tmp before deletion for
debugging compress progress
---
emhttp/plugins/dynamix/nchan/file_manager | 38 +++++++++++++++++------
1 file changed, 28 insertions(+), 10 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 5466ac7fce..39fbfd2ef0 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -933,26 +933,36 @@ while (true) {
printf 'TOTAL|%s\\n' "\$total_bytes"
zip --recurse-paths --display-usize --display-dots --dot-size 100m {$archive_path} -- {$escaped_basenames} 2>&1 | while IFS= read -r -n1 char || [[ -n \$char ]]; do
- if [[ ! \$dot_mode ]]; then
- line_buffer+="\$char"
-
- if [[ \$line_buffer =~ adding:\\ (.*)\\ \\(([0-9.]+[KMGT]?)\\)\\ $ ]]; then
- src_file="\${BASH_REMATCH[1]}"
- src_size="\${BASH_REMATCH[2]}"
- printf 'FILE|%s|%s\\n' "\$src_size" "\$src_file"
+ # Check for newline or EOF to process complete line
+ if [[ \$char == \$'\\n' || ! \$char ]]; then
+ if [[ ! \$dot_mode ]]; then
+ # Check if line matches "adding: filename (size)" pattern
+ if [[ \$line_buffer =~ adding:\\ (.*)\\ \\(([0-9.]+[KMGT]?)\\) ]]; then
+ src_file="\${BASH_REMATCH[1]}"
+ src_size="\${BASH_REMATCH[2]}"
+ printf 'FILE|%s|%s\\n' "\$src_size" "\$src_file"
+ dot_mode=1
+ elif [[ -n \$line_buffer ]]; then
+ printf 'INFO|%s\\n' "\$line_buffer"
+ fi
line_buffer=""
- dot_mode=1
- elif [[ ! \$char ]]; then
- [[ -n \$line_buffer ]] && printf 'INFO|%s\\n' "\$line_buffer"
+ else
+ # In dot mode, newline resets to line mode
+ dot_mode=
line_buffer=""
fi
elif [[ \$dot_mode ]]; then
+ # In dot mode, capture dots
if [[ \$char == '.' ]]; then
printf 'DOT|%s\\n' "\$(date +%s)"
else
+ # Not a dot, switch back to line mode and start new buffer
line_buffer="\$char"
dot_mode=
fi
+ else
+ # Accumulate characters into line buffer
+ line_buffer+="\$char"
fi
done
} >$status 2>$error & echo \$!
@@ -1072,6 +1082,14 @@ while (true) {
}
}
if (file_exists($error)) $reply['error'] = trim(file_get_contents($error));
+
+ // Debug: Copy status file before deletion for action 16 (compress)
+ if ($action == 16 && file_exists($status)) {
+ $debug_copy = '/tmp/status-debug-' . time() . '.txt';
+ copy($status, $debug_copy);
+ exec('logger -t file_manager "Status file copied to ' . $debug_copy . ' before deletion"');
+ }
+
delete_file($active, $pid_file, $status, $error);
unset($pid);
$delete_empty_dirs = null;
From 789f0c7d051c8ce2798f98417a8b8dd7a9374e17 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 15:32:57 +0100
Subject: [PATCH 019/173] Auto-increment archive filename if target file
already exists (TEST.zip -> TEST-2.zip)
---
emhttp/plugins/dynamix/Browse.page | 75 +++++++++++++++++++++++++-----
1 file changed, 64 insertions(+), 11 deletions(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index 9f68967d23..398ab99b13 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -304,7 +304,57 @@ function updateArchiveName() {
// Remove existing extension from base name
baseName = baseName.replace(/\.(zip|tar|tar\.gz|tar\.bz2|tar\.xz|tar\.zst|tgz|tbz2|txz)$/i, '');
var format = $('#dfm_format').val();
- $('#dfm_archive_name').val(baseName + '.' + format);
+ var proposedName = baseName + '.' + format;
+
+ // Check if file exists and find unique name
+ var targetDir = dfm.window.find('#dfm_target').val() || $('#dfm_prefix').text();
+ findUniqueArchiveName(targetDir, baseName, format, function(uniqueName) {
+ $('#dfm_archive_name').val(uniqueName);
+ });
+}
+
+function findUniqueArchiveName(dir, baseName, format, callback) {
+ var testName = baseName + '.' + format;
+ var fullPath = dir.replace(/\/$/, '') + '/' + testName;
+
+ // Check if file exists via fileTree data
+ $.post('/plugins/dynamix/include/FileTree.php', {path: dir, root: $('#dfm_prefix').text()}, function(data) {
+ var exists = false;
+ if (data && data.length) {
+ exists = data.some(function(item) {
+ return item.data === fullPath;
+ });
+ }
+
+ if (!exists) {
+ callback(testName);
+ return;
+ }
+
+ // File exists, find next available number
+ var counter = 2;
+ var findNext = function() {
+ var numberedName = baseName + '-' + counter + '.' + format;
+ var numberedPath = dir.replace(/\/$/, '') + '/' + numberedName;
+
+ var numberExists = data.some(function(item) {
+ return item.data === numberedPath;
+ });
+
+ if (!numberExists) {
+ callback(numberedName);
+ } else {
+ counter++;
+ if (counter > 100) {
+ // Safety limit
+ callback(baseName + '-' + Date.now() + '.' + format);
+ } else {
+ findNext();
+ }
+ }
+ };
+ findNext();
+ });
}
function fileEdit(id) {
@@ -826,17 +876,18 @@ function doAction(action, title, id) {
// Detect if source is in appdata/system path for default format selection
var isAppdata = source.match(/\/mnt\/[^\/]+\/(appdata|system)\//);
dfm.window.find('#dfm_format').val(isAppdata ? 'tar.zst' : 'zip');
- // Set default archive name based on source name
- var archiveName = name + '.' + dfm.window.find('#dfm_format').val();
- dfm.window.find('#dfm_archive_name').val(archiveName);
- // Bind format change to update archive name
- dfm.window.find('#dfm_format').on('change', updateArchiveName);
// Set target to current directory
dfm.window.find('#dfm_target').val(dir).attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).attr('data-pickfilter','SHOW_POPULAR').fileTreeAttach(null,null,function(path){
dfm.window.find('#dfm_target').val(path).change();
+ // Update archive name when target changes
+ updateArchiveName();
});
dfm.window.find('#dfm_target').addClass('dfm-target-with-tree');
- console.log('Compress dialog opened', {source: source, dir: dir, archiveName: archiveName});
+ // Bind format change to update archive name
+ dfm.window.find('#dfm_format').on('change', updateArchiveName);
+ // Set initial archive name with uniqueness check
+ updateArchiveName();
+ console.log('Compress dialog opened', {source: source, dir: dir});
break;
case 17: // extract
dfm.window.html($('#dfm_templateExtract').html());
@@ -1160,14 +1211,16 @@ function doActions(action, title) {
// Detect if source is in appdata/system path
var isAppdata = firstSource.match(/\/mnt\/[^\/]+(appdata|system)\//);
dfm.window.find('#dfm_format').val(isAppdata ? 'tar.zst' : 'zip');
- var archiveName = (bulk ? 'archive' : firstName) + '.' + dfm.window.find('#dfm_format').val();
- dfm.window.find('#dfm_archive_name').val(archiveName);
- dfm.window.find('#dfm_format').on('change', updateArchiveName);
dfm.window.find('#dfm_target').val(dir).attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).attr('data-pickfilter','SHOW_POPULAR').fileTreeAttach(null,null,function(path){
dfm.window.find('#dfm_target').val(path).change();
+ // Update archive name when target changes
+ updateArchiveName();
});
dfm.window.find('#dfm_target').addClass('dfm-target-with-tree');
- console.log('Bulk compress dialog opened', {sources: source, bulk: bulk, archiveName: archiveName});
+ dfm.window.find('#dfm_format').on('change', updateArchiveName);
+ // Set initial archive name with uniqueness check
+ updateArchiveName();
+ console.log('Bulk compress dialog opened', {sources: source, bulk: bulk});
break;
}
dfm.window.find('#dfm_source').attr('size',Math.min(dfm.tsize[action],source.length));
From 451545acf0959124620f1dbc1287ff58764e5842 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 15:36:02 +0100
Subject: [PATCH 020/173] Simplify archive name auto-increment: check visible
files instead of AJAX
---
emhttp/plugins/dynamix/Browse.page | 68 ++++++++++--------------------
1 file changed, 22 insertions(+), 46 deletions(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index 398ab99b13..cafbd34abf 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -306,55 +306,31 @@ function updateArchiveName() {
var format = $('#dfm_format').val();
var proposedName = baseName + '.' + format;
- // Check if file exists and find unique name
- var targetDir = dfm.window.find('#dfm_target').val() || $('#dfm_prefix').text();
- findUniqueArchiveName(targetDir, baseName, format, function(uniqueName) {
- $('#dfm_archive_name').val(uniqueName);
- });
-}
-
-function findUniqueArchiveName(dir, baseName, format, callback) {
- var testName = baseName + '.' + format;
- var fullPath = dir.replace(/\/$/, '') + '/' + testName;
+ // Try to find unique name by checking current directory listing
+ var targetDir = dfm.window.find('#dfm_target').val();
+ if (!targetDir) targetDir = $('#dfm_prefix').text();
- // Check if file exists via fileTree data
- $.post('/plugins/dynamix/include/FileTree.php', {path: dir, root: $('#dfm_prefix').text()}, function(data) {
- var exists = false;
- if (data && data.length) {
- exists = data.some(function(item) {
- return item.data === fullPath;
- });
- }
-
- if (!exists) {
- callback(testName);
- return;
+ // Get current files from displayed file tree if available
+ var existingFiles = [];
+ $('#dfm_filelist tbody tr').each(function() {
+ var fileData = $(this).find('td:first').attr('data');
+ if (fileData) {
+ var fileName = fileData.split('/').pop();
+ existingFiles.push(fileName);
}
-
- // File exists, find next available number
- var counter = 2;
- var findNext = function() {
- var numberedName = baseName + '-' + counter + '.' + format;
- var numberedPath = dir.replace(/\/$/, '') + '/' + numberedName;
-
- var numberExists = data.some(function(item) {
- return item.data === numberedPath;
- });
-
- if (!numberExists) {
- callback(numberedName);
- } else {
- counter++;
- if (counter > 100) {
- // Safety limit
- callback(baseName + '-' + Date.now() + '.' + format);
- } else {
- findNext();
- }
- }
- };
- findNext();
});
+
+ // Check if proposed name exists and find alternative
+ var finalName = proposedName;
+ if (existingFiles.indexOf(proposedName) !== -1) {
+ var counter = 2;
+ while (existingFiles.indexOf(baseName + '-' + counter + '.' + format) !== -1 && counter < 100) {
+ counter++;
+ }
+ finalName = baseName + '-' + counter + '.' + format;
+ }
+
+ $('#dfm_archive_name').val(finalName);
}
function fileEdit(id) {
From 3cc6fb8a3628c4e97b088991aaadf5b2edeca023 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 15:42:34 +0100
Subject: [PATCH 021/173] Fix zip progress: rewrite to line-by-line parsing
with dot counting, add debug logging to archive name selection
---
emhttp/plugins/dynamix/Browse.page | 8 ++++
emhttp/plugins/dynamix/nchan/file_manager | 45 ++++++++---------------
2 files changed, 24 insertions(+), 29 deletions(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index cafbd34abf..ea71b63fa0 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -297,14 +297,19 @@ function fileExtension(file) {
function updateArchiveName() {
var sourceName = $('#dfm_source').text().trim();
+ console.log('updateArchiveName: sourceName from #dfm_source =', sourceName);
+
// Remove trailing slash if present (for folders)
sourceName = sourceName.replace(/\/$/, '');
// Get just the filename/foldername without path
var baseName = fileName(sourceName);
+ console.log('updateArchiveName: baseName after fileName() =', baseName);
+
// Remove existing extension from base name
baseName = baseName.replace(/\.(zip|tar|tar\.gz|tar\.bz2|tar\.xz|tar\.zst|tgz|tbz2|txz)$/i, '');
var format = $('#dfm_format').val();
var proposedName = baseName + '.' + format;
+ console.log('updateArchiveName: proposedName =', proposedName);
// Try to find unique name by checking current directory listing
var targetDir = dfm.window.find('#dfm_target').val();
@@ -319,16 +324,19 @@ function updateArchiveName() {
existingFiles.push(fileName);
}
});
+ console.log('updateArchiveName: existingFiles =', existingFiles);
// Check if proposed name exists and find alternative
var finalName = proposedName;
if (existingFiles.indexOf(proposedName) !== -1) {
+ console.log('updateArchiveName: file exists, finding alternative');
var counter = 2;
while (existingFiles.indexOf(baseName + '-' + counter + '.' + format) !== -1 && counter < 100) {
counter++;
}
finalName = baseName + '-' + counter + '.' + format;
}
+ console.log('updateArchiveName: finalName =', finalName);
$('#dfm_archive_name').val(finalName);
}
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 39fbfd2ef0..a24d439e24 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -932,37 +932,24 @@ while (true) {
total_bytes=\$(du -sb {$escaped_basenames} 2>/dev/null | awk '{sum+=\$1} END {print sum}')
printf 'TOTAL|%s\\n' "\$total_bytes"
- zip --recurse-paths --display-usize --display-dots --dot-size 100m {$archive_path} -- {$escaped_basenames} 2>&1 | while IFS= read -r -n1 char || [[ -n \$char ]]; do
- # Check for newline or EOF to process complete line
- if [[ \$char == \$'\\n' || ! \$char ]]; then
- if [[ ! \$dot_mode ]]; then
- # Check if line matches "adding: filename (size)" pattern
- if [[ \$line_buffer =~ adding:\\ (.*)\\ \\(([0-9.]+[KMGT]?)\\) ]]; then
- src_file="\${BASH_REMATCH[1]}"
- src_size="\${BASH_REMATCH[2]}"
- printf 'FILE|%s|%s\\n' "\$src_size" "\$src_file"
- dot_mode=1
- elif [[ -n \$line_buffer ]]; then
- printf 'INFO|%s\\n' "\$line_buffer"
- fi
- line_buffer=""
- else
- # In dot mode, newline resets to line mode
- dot_mode=
- line_buffer=""
- fi
- elif [[ \$dot_mode ]]; then
- # In dot mode, capture dots
- if [[ \$char == '.' ]]; then
- printf 'DOT|%s\\n' "\$(date +%s)"
- else
- # Not a dot, switch back to line mode and start new buffer
- line_buffer="\$char"
- dot_mode=
+ zip --recurse-paths --display-usize --display-dots --dot-size 100m {$archive_path} -- {$escaped_basenames} 2>&1 | while IFS= read -r line; do
+ # Check if line matches "adding:" or "updating:" pattern with size
+ if [[ \$line =~ (adding|updating):\\ (.*)\\ \\(([0-9.]+[KMGT]?)\\) ]]; then
+ src_file="\${BASH_REMATCH[2]}"
+ src_size="\${BASH_REMATCH[3]}"
+ printf 'FILE|%s|%s\\n' "\$src_size" "\$src_file"
+
+ # Count dots in the line and emit DOT markers
+ dot_count=\$(echo "\$line" | tr -cd '.' | wc -c)
+ if [[ \$dot_count -gt 0 ]]; then
+ timestamp=\$(date +%s)
+ for ((i=0; i$status 2>$error & echo \$!
From 1eb17cb23f645070cdb446ec6f88766303eb4a7d Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 15:46:23 +0100
Subject: [PATCH 022/173] Revert to character-by-character parsing with
individual DOT timestamps for speed calculation, fix regex to match end of
line
---
emhttp/plugins/dynamix/nchan/file_manager | 46 +++++++++++++++--------
1 file changed, 30 insertions(+), 16 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index a24d439e24..a8e465fc3c 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -932,24 +932,38 @@ while (true) {
total_bytes=\$(du -sb {$escaped_basenames} 2>/dev/null | awk '{sum+=\$1} END {print sum}')
printf 'TOTAL|%s\\n' "\$total_bytes"
- zip --recurse-paths --display-usize --display-dots --dot-size 100m {$archive_path} -- {$escaped_basenames} 2>&1 | while IFS= read -r line; do
- # Check if line matches "adding:" or "updating:" pattern with size
- if [[ \$line =~ (adding|updating):\\ (.*)\\ \\(([0-9.]+[KMGT]?)\\) ]]; then
- src_file="\${BASH_REMATCH[2]}"
- src_size="\${BASH_REMATCH[3]}"
- printf 'FILE|%s|%s\\n' "\$src_size" "\$src_file"
-
- # Count dots in the line and emit DOT markers
- dot_count=\$(echo "\$line" | tr -cd '.' | wc -c)
- if [[ \$dot_count -gt 0 ]]; then
- timestamp=\$(date +%s)
- for ((i=0; i&1 | while IFS= read -r -n1 char || [[ -n \$char ]]; do
+ # Check for newline or EOF to process complete line
+ if [[ \$char == \$'\\n' || ! \$char ]]; then
+ if [[ ! \$dot_mode ]]; then
+ # Check if line matches "adding:" or "updating:" with filename and size at end
+ # The \$ at the end is critical - it ensures we only match when the line ends with size
+ if [[ \$line_buffer =~ (adding|updating):\\ (.*)\\ \\(([0-9.]+[KMGT]?)\\)\$ ]]; then
+ src_file="\${BASH_REMATCH[2]}"
+ src_size="\${BASH_REMATCH[3]}"
+ printf 'FILE|%s|%s\\n' "\$src_size" "\$src_file"
+ dot_mode=1
+ elif [[ -n \$line_buffer ]]; then
+ printf 'INFO|%s\\n' "\$line_buffer"
+ fi
+ line_buffer=""
+ else
+ # In dot mode, newline resets to line mode
+ dot_mode=
+ line_buffer=""
+ fi
+ elif [[ \$dot_mode ]]; then
+ # In dot mode, capture dots with individual timestamps for speed calculation
+ if [[ \$char == '.' ]]; then
+ printf 'DOT|%s\\n' "\$(date +%s)"
+ else
+ # Not a dot, switch back to line mode and start new buffer
+ line_buffer="\$char"
+ dot_mode=
fi
else
- # Other lines as INFO
- [[ -n \$line ]] && printf 'INFO|%s\\n' "\$line"
+ # Accumulate characters into line buffer
+ line_buffer+="\$char"
fi
done
} >$status 2>$error & echo \$!
From cee92d60d92c5108a90a7f523b20871f2a3a2a11 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 15:49:46 +0100
Subject: [PATCH 023/173] Fix archive name: extract first source from
multi-line text
---
emhttp/plugins/dynamix/Browse.page | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index ea71b63fa0..b6766a73ba 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -296,8 +296,12 @@ function fileExtension(file) {
}
function updateArchiveName() {
- var sourceName = $('#dfm_source').text().trim();
- console.log('updateArchiveName: sourceName from #dfm_source =', sourceName);
+ var sourceText = $('#dfm_source').text().trim();
+ console.log('updateArchiveName: sourceText from #dfm_source =', sourceText);
+
+ // Get first source only (sources are on separate lines)
+ var sourceName = sourceText.split(/[\r\n]+/)[0];
+ console.log('updateArchiveName: first source =', sourceName);
// Remove trailing slash if present (for folders)
sourceName = sourceName.replace(/\/$/, '');
From 8301e6065838a54743742659385270792d344fc5 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 15:52:55 +0100
Subject: [PATCH 024/173] Use \r as source separator (consistent with backend)
---
emhttp/plugins/dynamix/Browse.page | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index b6766a73ba..1a71b8b1b2 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -299,8 +299,8 @@ function updateArchiveName() {
var sourceText = $('#dfm_source').text().trim();
console.log('updateArchiveName: sourceText from #dfm_source =', sourceText);
- // Get first source only (sources are on separate lines)
- var sourceName = sourceText.split(/[\r\n]+/)[0];
+ // Get first source only (sources are separated by \r, same as backend)
+ var sourceName = sourceText.split('\r')[0];
console.log('updateArchiveName: first source =', sourceName);
// Remove trailing slash if present (for folders)
From bd2d8a14626bd43c79c3e843d64fb20bb29cf603 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 15:55:36 +0100
Subject: [PATCH 025/173] Use static debug filename
/tmp/status-debug-compress.txt for easier access
---
emhttp/plugins/dynamix/nchan/file_manager | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index a8e465fc3c..bc545e82d5 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -892,6 +892,7 @@ while (true) {
// start action
} else {
exec('logger -t file_manager "Case 16 start: pid is empty, starting new job"');
+ @unlink('/tmp/status-debug-compress.txt'); // Clear old debug file
parse_zip_progress(null, null, 100, true); // Reset static variables
$format = $format ?? 'zip';
$archive_name = $archive_name ?? 'archive.zip';
@@ -1086,7 +1087,7 @@ while (true) {
// Debug: Copy status file before deletion for action 16 (compress)
if ($action == 16 && file_exists($status)) {
- $debug_copy = '/tmp/status-debug-' . time() . '.txt';
+ $debug_copy = '/tmp/status-debug-compress.txt';
copy($status, $debug_copy);
exec('logger -t file_manager "Status file copied to ' . $debug_copy . ' before deletion"');
}
From 698e456ddd9f8f6d044fb4ebc119a35c511f61b5 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 16:00:10 +0100
Subject: [PATCH 026/173] Fix zip progress: use stdbuf -o0 to disable output
buffering for character-by-character dot streaming
---
emhttp/plugins/dynamix/nchan/file_manager | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index bc545e82d5..2dacb80b1c 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -933,7 +933,8 @@ while (true) {
total_bytes=\$(du -sb {$escaped_basenames} 2>/dev/null | awk '{sum+=\$1} END {print sum}')
printf 'TOTAL|%s\\n' "\$total_bytes"
- zip --recurse-paths --display-usize --display-dots --dot-size 100m {$archive_path} -- {$escaped_basenames} 2>&1 | while IFS= read -r -n1 char || [[ -n \$char ]]; do
+ # Use stdbuf to disable output buffering so dots appear character-by-character
+ stdbuf -o0 zip --recurse-paths --display-usize --display-dots --dot-size 100m {$archive_path} -- {$escaped_basenames} 2>&1 | while IFS= read -r -n1 char || [[ -n \$char ]]; do
# Check for newline or EOF to process complete line
if [[ \$char == \$'\\n' || ! \$char ]]; then
if [[ ! \$dot_mode ]]; then
From 6e84bc50fe1c8b0ee90257709b9027ff197ae595 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 16:17:01 +0100
Subject: [PATCH 027/173] Test: use >>status direct writes to check if
buffering is the issue (may break PID tracking temporarily)
---
emhttp/plugins/dynamix/Browse.page | 27 ++++++++++++-----------
emhttp/plugins/dynamix/nchan/file_manager | 11 ++++-----
2 files changed, 20 insertions(+), 18 deletions(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index 1a71b8b1b2..7fd3c4a1b9 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -296,18 +296,17 @@ function fileExtension(file) {
}
function updateArchiveName() {
- var sourceText = $('#dfm_source').text().trim();
- console.log('updateArchiveName: sourceText from #dfm_source =', sourceText);
+ // Get first source name from data attribute (set when dialog opened)
+ var baseName = $('#dfm_source').data('firstName') || '';
+ console.log('updateArchiveName: baseName from data =', baseName);
- // Get first source only (sources are separated by \r, same as backend)
- var sourceName = sourceText.split('\r')[0];
- console.log('updateArchiveName: first source =', sourceName);
-
- // Remove trailing slash if present (for folders)
- sourceName = sourceName.replace(/\/$/, '');
- // Get just the filename/foldername without path
- var baseName = fileName(sourceName);
- console.log('updateArchiveName: baseName after fileName() =', baseName);
+ if (!baseName) {
+ console.log('updateArchiveName: no firstName data, using fallback');
+ var sourceText = $('#dfm_source').text().trim();
+ var sourceName = sourceText.split('\r')[0] || sourceText;
+ sourceName = sourceName.replace(/\/$/, '');
+ baseName = fileName(sourceName);
+ }
// Remove existing extension from base name
baseName = baseName.replace(/\.(zip|tar|tar\.gz|tar\.bz2|tar\.xz|tar\.zst|tgz|tbz2|txz)$/i, '');
@@ -1193,11 +1192,13 @@ function doActions(action, title) {
dfm.window.html($('#dfm_templateCompress').html());
dfm_createSource(source);
// Use first source name for archive name suggestion
- var firstSource = bulk ? source[0] : source[0];
+ var firstSource = source[0];
var firstPath = firstSource.substr(1).split('/');
var firstName = firstPath.pop() || firstPath.pop();
+ // Store first source name for updateArchiveName function
+ dfm.window.find('#dfm_source').data('firstName', firstName);
// Detect if source is in appdata/system path
- var isAppdata = firstSource.match(/\/mnt\/[^\/]+(appdata|system)\//);
+ var isAppdata = firstSource.match(/\/mnt\/[^\/]+(appdata|system)\/);
dfm.window.find('#dfm_format').val(isAppdata ? 'tar.zst' : 'zip');
dfm.window.find('#dfm_target').val(dir).attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).attr('data-pickfilter','SHOW_POPULAR').fileTreeAttach(null,null,function(path){
dfm.window.find('#dfm_target').val(path).change();
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 2dacb80b1c..a1b3e6c16b 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -925,13 +925,14 @@ while (true) {
// CRITICAL: The '>$status' redirect at the end keeps the shell group process alive.
// Without it, the shell group exits immediately after backgrounding, making the PID invalid.
// The redirect forces the shell to wait for the entire pipeline to complete.
+ // TODO: Currently testing direct writes with >>$status to avoid buffering.
$cmd = <</dev/null | awk '{sum+=\$1} END {print sum}')
- printf 'TOTAL|%s\\n' "\$total_bytes"
+ printf 'TOTAL|%s\\n' "\$total_bytes" >>$status
# Use stdbuf to disable output buffering so dots appear character-by-character
stdbuf -o0 zip --recurse-paths --display-usize --display-dots --dot-size 100m {$archive_path} -- {$escaped_basenames} 2>&1 | while IFS= read -r -n1 char || [[ -n \$char ]]; do
@@ -943,10 +944,10 @@ while (true) {
if [[ \$line_buffer =~ (adding|updating):\\ (.*)\\ \\(([0-9.]+[KMGT]?)\\)\$ ]]; then
src_file="\${BASH_REMATCH[2]}"
src_size="\${BASH_REMATCH[3]}"
- printf 'FILE|%s|%s\\n' "\$src_size" "\$src_file"
+ printf 'FILE|%s|%s\\n' "\$src_size" "\$src_file" >>$status
dot_mode=1
elif [[ -n \$line_buffer ]]; then
- printf 'INFO|%s\\n' "\$line_buffer"
+ printf 'INFO|%s\\n' "\$line_buffer" >>$status
fi
line_buffer=""
else
@@ -957,7 +958,7 @@ while (true) {
elif [[ \$dot_mode ]]; then
# In dot mode, capture dots with individual timestamps for speed calculation
if [[ \$char == '.' ]]; then
- printf 'DOT|%s\\n' "\$(date +%s)"
+ printf 'DOT|%(%s)T\\n' -1 >>$status
else
# Not a dot, switch back to line mode and start new buffer
line_buffer="\$char"
@@ -968,7 +969,7 @@ while (true) {
line_buffer+="\$char"
fi
done
- } >$status 2>$error & echo \$!
+ } 2>$error & echo \$!
BASH;
break;
case 'tar':
From 8c113579c77ccef544bab025c12de526d6391694 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 16:21:31 +0100
Subject: [PATCH 028/173] Fix regex syntax: remove unnecessary backslash in
character class [^/]
---
emhttp/plugins/dynamix/Browse.page | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index 7fd3c4a1b9..7ace55a8ab 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -1198,7 +1198,7 @@ function doActions(action, title) {
// Store first source name for updateArchiveName function
dfm.window.find('#dfm_source').data('firstName', firstName);
// Detect if source is in appdata/system path
- var isAppdata = firstSource.match(/\/mnt\/[^\/]+(appdata|system)\/);
+ var isAppdata = firstSource.match(/\/mnt\/[^/]+(appdata|system)\//);
dfm.window.find('#dfm_format').val(isAppdata ? 'tar.zst' : 'zip');
dfm.window.find('#dfm_target').val(dir).attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).attr('data-pickfilter','SHOW_POPULAR').fileTreeAttach(null,null,function(path){
dfm.window.find('#dfm_target').val(path).change();
From c69a4964c102de40e394905dde3497d3eac67166 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 16:31:22 +0100
Subject: [PATCH 029/173] Fix zip regex for leading spaces, remove broken
auto-increment (TODO: needs AJAX or server-side implementation)
---
emhttp/plugins/dynamix/Browse.page | 31 +++--------------------
emhttp/plugins/dynamix/nchan/file_manager | 4 +--
2 files changed, 6 insertions(+), 29 deletions(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index 7ace55a8ab..2185e9a169 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -314,34 +314,11 @@ function updateArchiveName() {
var proposedName = baseName + '.' + format;
console.log('updateArchiveName: proposedName =', proposedName);
- // Try to find unique name by checking current directory listing
- var targetDir = dfm.window.find('#dfm_target').val();
- if (!targetDir) targetDir = $('#dfm_prefix').text();
+ // TODO: Auto-increment (-2, -3) if file exists
+ // Currently not implemented because fileTree only shows directories, not files
+ // Would require AJAX call to check target directory contents
- // Get current files from displayed file tree if available
- var existingFiles = [];
- $('#dfm_filelist tbody tr').each(function() {
- var fileData = $(this).find('td:first').attr('data');
- if (fileData) {
- var fileName = fileData.split('/').pop();
- existingFiles.push(fileName);
- }
- });
- console.log('updateArchiveName: existingFiles =', existingFiles);
-
- // Check if proposed name exists and find alternative
- var finalName = proposedName;
- if (existingFiles.indexOf(proposedName) !== -1) {
- console.log('updateArchiveName: file exists, finding alternative');
- var counter = 2;
- while (existingFiles.indexOf(baseName + '-' + counter + '.' + format) !== -1 && counter < 100) {
- counter++;
- }
- finalName = baseName + '-' + counter + '.' + format;
- }
- console.log('updateArchiveName: finalName =', finalName);
-
- $('#dfm_archive_name').val(finalName);
+ $('#dfm_archive_name').val(proposedName);
}
function fileEdit(id) {
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index a1b3e6c16b..1a74f1c744 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -940,8 +940,8 @@ while (true) {
if [[ \$char == \$'\\n' || ! \$char ]]; then
if [[ ! \$dot_mode ]]; then
# Check if line matches "adding:" or "updating:" with filename and size at end
- # The \$ at the end is critical - it ensures we only match when the line ends with size
- if [[ \$line_buffer =~ (adding|updating):\\ (.*)\\ \\(([0-9.]+[KMGT]?)\\)\$ ]]; then
+ # Pattern: " adding: filename (size) " (leading spaces, trailing space before $)
+ if [[ \$line_buffer =~ (adding|updating):[[:space:]]+(.*)[[:space:]]+\\(([0-9.]+[KMGT]?)\\)[[:space:]]+\$ ]]; then
src_file="\${BASH_REMATCH[2]}"
src_size="\${BASH_REMATCH[3]}"
printf 'FILE|%s|%s\\n' "\$src_size" "\$src_file" >>$status
From f003c47e3d9da57abc51c51f53cfc87195ef0926 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 16:33:46 +0100
Subject: [PATCH 030/173] Fix zip regex: remove $ anchor to match (size)
immediately, not wait for line end (dots come on same line)
---
emhttp/plugins/dynamix/nchan/file_manager | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 1a74f1c744..de13967093 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -939,9 +939,10 @@ while (true) {
# Check for newline or EOF to process complete line
if [[ \$char == \$'\\n' || ! \$char ]]; then
if [[ ! \$dot_mode ]]; then
- # Check if line matches "adding:" or "updating:" with filename and size at end
- # Pattern: " adding: filename (size) " (leading spaces, trailing space before $)
- if [[ \$line_buffer =~ (adding|updating):[[:space:]]+(.*)[[:space:]]+\\(([0-9.]+[KMGT]?)\\)[[:space:]]+\$ ]]; then
+ # Check if line matches "adding:" or "updating:" with filename and size
+ # Pattern from ziptest.sh: "adding: filename (size) " (trailing space after size)
+ # Match when we see (size) followed by space, don't wait for line end
+ if [[ \$line_buffer =~ (adding|updating):[[:space:]]+(.*)[[:space:]]+\\(([0-9.]+[KMGT]?)\\)[[:space:]]+ ]]; then
src_file="\${BASH_REMATCH[2]}"
src_size="\${BASH_REMATCH[3]}"
printf 'FILE|%s|%s\\n' "\$src_size" "\$src_file" >>$status
From 09eba73a0aa0a6a733f317c4461774e89d672da5 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 16:58:17 +0100
Subject: [PATCH 031/173] Fix zip progress: check pattern after every char
instead of only at newline
- Pattern must match BEFORE dots arrive, not after they're in line_buffer
- Use bracket redirect (no need for append on every printf)
- Remove stdbuf (not needed for character-by-character reading)
- Tested with 4GB file: 40 dots logged correctly
---
emhttp/plugins/dynamix/nchan/file_manager | 59 ++++++++++++-----------
1 file changed, 31 insertions(+), 28 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index de13967093..bb41a4f874 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -925,52 +925,55 @@ while (true) {
// CRITICAL: The '>$status' redirect at the end keeps the shell group process alive.
// Without it, the shell group exits immediately after backgrounding, making the PID invalid.
// The redirect forces the shell to wait for the entire pipeline to complete.
- // TODO: Currently testing direct writes with >>$status to avoid buffering.
+ // CRITICAL FIX: Pattern must be checked after EVERY character, not just at newline.
+ // This prevents dots from being buffered into line_buffer before dot_mode activates.
$cmd = <</dev/null | awk '{sum+=\$1} END {print sum}')
- printf 'TOTAL|%s\\n' "\$total_bytes" >>$status
+ printf 'TOTAL|%s\\n' "\$total_bytes"
- # Use stdbuf to disable output buffering so dots appear character-by-character
- stdbuf -o0 zip --recurse-paths --display-usize --display-dots --dot-size 100m {$archive_path} -- {$escaped_basenames} 2>&1 | while IFS= read -r -n1 char || [[ -n \$char ]]; do
- # Check for newline or EOF to process complete line
- if [[ \$char == \$'\\n' || ! \$char ]]; then
- if [[ ! \$dot_mode ]]; then
- # Check if line matches "adding:" or "updating:" with filename and size
- # Pattern from ziptest.sh: "adding: filename (size) " (trailing space after size)
- # Match when we see (size) followed by space, don't wait for line end
- if [[ \$line_buffer =~ (adding|updating):[[:space:]]+(.*)[[:space:]]+\\(([0-9.]+[KMGT]?)\\)[[:space:]]+ ]]; then
- src_file="\${BASH_REMATCH[2]}"
- src_size="\${BASH_REMATCH[3]}"
- printf 'FILE|%s|%s\\n' "\$src_size" "\$src_file" >>$status
- dot_mode=1
- elif [[ -n \$line_buffer ]]; then
- printf 'INFO|%s\\n' "\$line_buffer" >>$status
+ zip --recurse-paths --display-usize --display-dots --dot-size 100m {$archive_path} -- {$escaped_basenames} 2>&1 | while IFS= read -r -n1 char || [[ -n \$char ]]; do
+
+ # Line mode: accumulate characters and check for pattern match after each char
+ if [[ ! \$dot_mode ]]; then
+ line_buffer+="\$char"
+
+ # Check after every char if we have the pattern (size) followed by space
+ # This ensures we switch to dot_mode BEFORE dots arrive
+ if [[ \$line_buffer =~ (adding|updating):\\ (.*)\\ \\(([0-9.]+[KMGT]?)\\)\ ]]; then
+ src_file="\${BASH_REMATCH[2]}"
+ src_size="\${BASH_REMATCH[3]}"
+ printf 'FILE|%s|%s\\n' "\$src_size" "\$src_file"
+ dot_mode=1
+ fi
+
+ # On newline, output non-matching lines as INFO
+ if [[ \$char == \$'\\n' ]]; then
+ if [[ ! \$dot_mode && -n \$line_buffer ]]; then
+ printf 'INFO|%s\\n' "\$line_buffer"
fi
line_buffer=""
- else
- # In dot mode, newline resets to line mode
- dot_mode=
- line_buffer=""
fi
+
+ # Dot mode: capture dots individually
elif [[ \$dot_mode ]]; then
- # In dot mode, capture dots with individual timestamps for speed calculation
if [[ \$char == '.' ]]; then
- printf 'DOT|%(%s)T\\n' -1 >>$status
+ printf 'DOT|%(%s)T\\n' -1
+ elif [[ \$char == \$'\\n' ]]; then
+ # Newline ends dot mode
+ dot_mode=
+ line_buffer=""
else
- # Not a dot, switch back to line mode and start new buffer
+ # Non-dot, non-newline: back to line mode
line_buffer="\$char"
dot_mode=
fi
- else
- # Accumulate characters into line buffer
- line_buffer+="\$char"
fi
done
- } 2>$error & echo \$!
+ } >$status 2>$error & echo \$!
BASH;
break;
case 'tar':
From ec00239d6dbcc0539f64ee41e4ddc3105e29c4dc Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 17:23:52 +0100
Subject: [PATCH 032/173] Add INFO messages display to zip progress
- Collect last 3 INFO messages from structured log
- Append with linebreak to text[0] after filename
- Truncate to 70 chars for readability
- Clear on reset for new jobs
---
emhttp/plugins/dynamix/nchan/file_manager | 95 ++++++++++++++++++++---
1 file changed, 84 insertions(+), 11 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index bb41a4f874..de80c33149 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -181,7 +181,11 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
static $current_file_dots = 0;
static $first_dot_time = null;
static $last_dot_time = null;
+ static $first_file_time = null;
+ static $last_file_time = null;
+ static $file_count = 0;
static $last_eta_seconds = null;
+ static $info_messages = [];
// Reset static variables when starting a new archive
if ($reset) {
@@ -193,7 +197,11 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
$current_file_dots = 0;
$first_dot_time = null;
$last_dot_time = null;
+ $first_file_time = null;
+ $last_file_time = null;
+ $file_count = 0;
$last_eta_seconds = null;
+ $info_messages = [];
return [];
}
@@ -223,7 +231,7 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
// Process new lines
foreach ($new_lines as $line) {
- $parts = explode('|', $line, 3);
+ $parts = explode('|', $line, 4);
if (count($parts) < 2) continue;
$type = $parts[0];
@@ -240,10 +248,18 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
$processed_bytes += $current_file_size;
}
- // Start tracking new file
- $current_file_size = size_to_bytes($parts[1]);
- $current_file_name = $parts[2] ?? '';
+ // Parse FILE|timestamp|size|filename
+ $timestamp = intval($parts[1]);
+ $current_file_size = size_to_bytes($parts[2]);
+ $current_file_name = $parts[3] ?? '';
$current_file_dots = 0;
+ $file_count++;
+
+ // Track FILE timestamps for speed calculation (used when no DOTs available)
+ if ($first_file_time === null) {
+ $first_file_time = $timestamp;
+ }
+ $last_file_time = $timestamp;
break;
case 'DOT':
@@ -258,7 +274,11 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
break;
case 'INFO':
- // Info message (currently unused)
+ // Collect info messages (keep last 3)
+ $info_messages[] = $parts[1];
+ if (count($info_messages) > 3) {
+ array_shift($info_messages);
+ }
break;
}
}
@@ -271,24 +291,51 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
$text[0] .= mb_strimhalf($current_file_name, 70, '...');
}
- exec('logger -t file_manager "parse_zip_progress: total_bytes=' . $total_bytes . ', processed_bytes=' . $processed_bytes . ', current_file_dots=' . $current_file_dots . '"');
+ // Add info messages if any
+ if (!empty($info_messages)) {
+ foreach ($info_messages as $msg) {
+ $text[0] .= "\n" . mb_strimhalf($msg, 70, '...');
+ }
+ }
+
+ exec('logger -t file_manager "parse_zip_progress: total_bytes=' . $total_bytes . ', processed_bytes=' . $processed_bytes . ', current_file_dots=' . $current_file_dots . ', file_count=' . $file_count . '"');
// Calculate progress if we have total size
if ($total_bytes && $total_bytes > 0) {
- // Current progress = completed files + dots of current file
+ // Current progress = completed files + progress of current file
$dots_bytes = $current_file_dots * $dot_size_mb * 1024 * 1024;
- $current_progress_bytes = $processed_bytes + $dots_bytes;
+ $current_file_progress_bytes = $dots_bytes; // Start with DOTs if available
+
+ // For small files without DOTs, estimate progress based on elapsed time
+ if ($dots_bytes == 0 && $current_file_size > 0 && $last_file_time !== null && $file_count > 1) {
+ // Calculate average time per file from completed files
+ $completed_files = $file_count - 1; // Exclude current file
+ $avg_time_per_file = ($last_file_time - $first_file_time) / $completed_files;
+
+ // Time elapsed since current file started
+ $current_time = time();
+ $time_elapsed = $current_time - $last_file_time;
+
+ // Estimate progress of current file (cap at 100%)
+ if ($avg_time_per_file > 0) {
+ $progress_ratio = min(1.0, $time_elapsed / $avg_time_per_file);
+ $current_file_progress_bytes = $current_file_size * $progress_ratio;
+ }
+ }
+
+ $current_progress_bytes = $processed_bytes + $current_file_progress_bytes;
// Calculate percentage
$percent = min(100, intval(($current_progress_bytes / $total_bytes) * 100));
- // Calculate speed and ETA from dot timestamps
+ // Calculate speed and ETA
$speed = '';
$eta = 'N/A';
+ // Prefer DOT timestamps for speed (more accurate for large files)
if ($first_dot_time !== null && $last_dot_time !== null && $last_dot_time > $first_dot_time) {
$time_span = $last_dot_time - $first_dot_time;
- $dots_processed = $current_file_dots; // approximate total dots across all files
+ $dots_processed = $current_file_dots;
if ($time_span > 0 && $dots_processed > 1) {
// Calculate speed: dots * dot_size / time
@@ -310,6 +357,32 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
}
}
}
+ // Fallback: use FILE timestamps for small files without DOTs
+ // FILE timestamp = start of file processing, so difference between FILEs = duration of previous file
+ elseif ($first_file_time !== null && $last_file_time !== null && $last_file_time > $first_file_time && $file_count > 1) {
+ $time_span = $last_file_time - $first_file_time;
+
+ if ($time_span > 0) {
+ // Calculate speed: processed bytes / time
+ // processed_bytes = all completed files (current file not included until next FILE arrives)
+ $speed_bytes = $processed_bytes / $time_span;
+ $speed = bytes_to_size($speed_bytes) . '/s';
+
+ // Calculate ETA
+ $remaining_bytes = $total_bytes - $current_progress_bytes;
+ if ($speed_bytes > 0) {
+ $eta_seconds = $remaining_bytes / $speed_bytes;
+
+ // Apply hysteresis
+ if ($last_eta_seconds !== null) {
+ $eta_seconds = ($last_eta_seconds * 0.7) + ($eta_seconds * 0.3);
+ }
+ $last_eta_seconds = $eta_seconds;
+
+ $eta = seconds_to_time(intval($eta_seconds));
+ }
+ }
+ }
// Build progress text
$progress_parts = [];
@@ -946,7 +1019,7 @@ while (true) {
if [[ \$line_buffer =~ (adding|updating):\\ (.*)\\ \\(([0-9.]+[KMGT]?)\\)\ ]]; then
src_file="\${BASH_REMATCH[2]}"
src_size="\${BASH_REMATCH[3]}"
- printf 'FILE|%s|%s\\n' "\$src_size" "\$src_file"
+ printf 'FILE|%(%s)T|%s|%s\\n' -1 "\$src_size" "\$src_file"
dot_mode=1
fi
From 28d1281052ddd8ade494c80bd5612175f589efd9 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 17:29:04 +0100
Subject: [PATCH 033/173] Refactor: unify FILE/DOT timestamps into
progress_events array
- Replace separate first_dot_time/last_dot_time/first_file_time/last_file_time
- Use progress_events = [[timestamp, bytes_processed], ...] array
- Single unified speed calculation: (last_bytes - first_bytes) / (last_time - first_time)
- Removes ~40 lines of duplicate speed calculation code
- Keep first_file_time/last_file_time for current file progress estimation
- DOTs automatically dominate speed (more events) when present
- FILE events used for small files without DOTs
---
emhttp/plugins/dynamix/nchan/file_manager | 66 ++++++++---------------
1 file changed, 21 insertions(+), 45 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index de80c33149..a92e21c22b 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -179,10 +179,9 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
static $current_file_name = '';
static $current_file_size = 0;
static $current_file_dots = 0;
- static $first_dot_time = null;
- static $last_dot_time = null;
- static $first_file_time = null;
- static $last_file_time = null;
+ static $progress_events = []; // Array of [timestamp, bytes_processed] for unified speed calculation
+ static $first_file_time = null; // Keep for current file progress estimation
+ static $last_file_time = null; // Keep for current file progress estimation
static $file_count = 0;
static $last_eta_seconds = null;
static $info_messages = [];
@@ -195,8 +194,7 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
$current_file_name = '';
$current_file_size = 0;
$current_file_dots = 0;
- $first_dot_time = null;
- $last_dot_time = null;
+ $progress_events = [];
$first_file_time = null;
$last_file_time = null;
$file_count = 0;
@@ -255,11 +253,14 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
$current_file_dots = 0;
$file_count++;
- // Track FILE timestamps for speed calculation (used when no DOTs available)
+ // Track FILE timestamp for current file progress estimation
if ($first_file_time === null) {
$first_file_time = $timestamp;
}
$last_file_time = $timestamp;
+
+ // Record progress event: new file starts, no progress on this file yet
+ $progress_events[] = [$timestamp, $processed_bytes];
break;
case 'DOT':
@@ -267,10 +268,9 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
$timestamp = intval($parts[1]);
$current_file_dots++;
- if ($first_dot_time === null) {
- $first_dot_time = $timestamp;
- }
- $last_dot_time = $timestamp;
+ // Record progress event: processed_bytes (completed files) + current file progress
+ $current_file_progress = $current_file_dots * $dot_size_mb * 1024 * 1024;
+ $progress_events[] = [$timestamp, $processed_bytes + $current_file_progress];
break;
case 'INFO':
@@ -328,44 +328,20 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
// Calculate percentage
$percent = min(100, intval(($current_progress_bytes / $total_bytes) * 100));
- // Calculate speed and ETA
+ // Calculate speed and ETA using unified progress events
$speed = '';
$eta = 'N/A';
- // Prefer DOT timestamps for speed (more accurate for large files)
- if ($first_dot_time !== null && $last_dot_time !== null && $last_dot_time > $first_dot_time) {
- $time_span = $last_dot_time - $first_dot_time;
- $dots_processed = $current_file_dots;
+ if (count($progress_events) >= 2) {
+ $first_event = $progress_events[0];
+ $last_event = $progress_events[count($progress_events) - 1];
- if ($time_span > 0 && $dots_processed > 1) {
- // Calculate speed: dots * dot_size / time
- $speed_bytes = ($dots_processed * $dot_size_mb * 1024 * 1024) / $time_span;
- $speed = bytes_to_size($speed_bytes) . '/s';
-
- // Calculate ETA
- $remaining_bytes = $total_bytes - $current_progress_bytes;
- if ($speed_bytes > 0) {
- $eta_seconds = $remaining_bytes / $speed_bytes;
-
- // Apply hysteresis to prevent ETA from jumping
- if ($last_eta_seconds !== null) {
- $eta_seconds = ($last_eta_seconds * 0.7) + ($eta_seconds * 0.3);
- }
- $last_eta_seconds = $eta_seconds;
-
- $eta = seconds_to_time(intval($eta_seconds));
- }
- }
- }
- // Fallback: use FILE timestamps for small files without DOTs
- // FILE timestamp = start of file processing, so difference between FILEs = duration of previous file
- elseif ($first_file_time !== null && $last_file_time !== null && $last_file_time > $first_file_time && $file_count > 1) {
- $time_span = $last_file_time - $first_file_time;
+ $time_span = $last_event[0] - $first_event[0];
+ $bytes_span = $last_event[1] - $first_event[1];
- if ($time_span > 0) {
- // Calculate speed: processed bytes / time
- // processed_bytes = all completed files (current file not included until next FILE arrives)
- $speed_bytes = $processed_bytes / $time_span;
+ if ($time_span > 0 && $bytes_span > 0) {
+ // Calculate speed from progress events
+ $speed_bytes = $bytes_span / $time_span;
$speed = bytes_to_size($speed_bytes) . '/s';
// Calculate ETA
@@ -373,7 +349,7 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
if ($speed_bytes > 0) {
$eta_seconds = $remaining_bytes / $speed_bytes;
- // Apply hysteresis
+ // Apply hysteresis to prevent ETA from jumping
if ($last_eta_seconds !== null) {
$eta_seconds = ($last_eta_seconds * 0.7) + ($eta_seconds * 0.3);
}
From a774da020bc20175b43e702ef737313200529306 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 17:37:47 +0100
Subject: [PATCH 034/173] Fix: Show compress/extract progress in footer status
bar
Action 16 (Compress) and 17 (Extract) were missing from the footer
progress display whitelist. This caused their status to only appear
in the dialog, but not in the bottom status bar.
Users can now minimize the dialog and see compress/extract progress
in the status bar, just like copy/move operations.
---
emhttp/plugins/dynamix/BrowseButton.page | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/emhttp/plugins/dynamix/BrowseButton.page b/emhttp/plugins/dynamix/BrowseButton.page
index 8ba348fdcc..b41887adf0 100644
--- a/emhttp/plugins/dynamix/BrowseButton.page
+++ b/emhttp/plugins/dynamix/BrowseButton.page
@@ -237,9 +237,9 @@ function dfm_showProgress(data) {
}
// Build footer text (progress info only)
- // Show footer for actions with actual progress: delete (1,6), copy (3,8), move (4,9), search (15)
+ // Show footer for actions with actual progress: delete (1,6), copy (3,8), move (4,9), search (15), compress (16), extract (17)
// No footer for quick operations: create (0), rename (2,7), chown (11), chmod (12)
- let showFooter = [1, 3, 4, 6, 8, 9, 15].includes(parsed.action);
+ let showFooter = [1, 3, 4, 6, 8, 9, 15, 16, 17].includes(parsed.action);
if (showFooter) {
let footerText = parsed.text[1] || parsed.text[0] || '';
dfm_footer('write', footerText, $icon);
From f9d178eca45094c70f2c39ce11d3ce30e76dca45 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 18:15:34 +0100
Subject: [PATCH 035/173] Fail if archive/rename target already exists; add
TODO for extract overwrite option
---
emhttp/plugins/dynamix/nchan/file_manager | 163 ++++++++++++++--------
1 file changed, 103 insertions(+), 60 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index a92e21c22b..000fe9015e 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -226,7 +226,7 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
$new_lines = [];
exec("tail -n +" . escapeshellarg($start_line) . " " . escapeshellarg($status) . " 2>/dev/null", $new_lines);
exec('logger -t file_manager "parse_zip_progress: read ' . count($new_lines) . ' new lines from line ' . $start_line . '"');
-
+
// Process new lines
foreach ($new_lines as $line) {
$parts = explode('|', $line, 4);
@@ -238,41 +238,41 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
// First line: total size of all source files
$total_bytes = floatval($parts[1]);
break;
-
+
case 'FILE':
// New file started - previous file is complete
if ($current_file_size > 0) {
// Add completed file to processed bytes
$processed_bytes += $current_file_size;
}
-
+
// Parse FILE|timestamp|size|filename
$timestamp = intval($parts[1]);
$current_file_size = size_to_bytes($parts[2]);
$current_file_name = $parts[3] ?? '';
$current_file_dots = 0;
$file_count++;
-
+
// Track FILE timestamp for current file progress estimation
if ($first_file_time === null) {
$first_file_time = $timestamp;
}
$last_file_time = $timestamp;
-
+
// Record progress event: new file starts, no progress on this file yet
$progress_events[] = [$timestamp, $processed_bytes];
break;
-
+
case 'DOT':
// Progress dot for current file
$timestamp = intval($parts[1]);
$current_file_dots++;
-
+
// Record progress event: processed_bytes (completed files) + current file progress
$current_file_progress = $current_file_dots * $dot_size_mb * 1024 * 1024;
$progress_events[] = [$timestamp, $processed_bytes + $current_file_progress];
break;
-
+
case 'INFO':
// Collect info messages (keep last 3)
$info_messages[] = $parts[1];
@@ -282,7 +282,7 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
break;
}
}
-
+
$last_line_number = $total_lines;
}
@@ -305,61 +305,61 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
// Current progress = completed files + progress of current file
$dots_bytes = $current_file_dots * $dot_size_mb * 1024 * 1024;
$current_file_progress_bytes = $dots_bytes; // Start with DOTs if available
-
+
// For small files without DOTs, estimate progress based on elapsed time
if ($dots_bytes == 0 && $current_file_size > 0 && $last_file_time !== null && $file_count > 1) {
// Calculate average time per file from completed files
$completed_files = $file_count - 1; // Exclude current file
$avg_time_per_file = ($last_file_time - $first_file_time) / $completed_files;
-
+
// Time elapsed since current file started
$current_time = time();
$time_elapsed = $current_time - $last_file_time;
-
+
// Estimate progress of current file (cap at 100%)
if ($avg_time_per_file > 0) {
$progress_ratio = min(1.0, $time_elapsed / $avg_time_per_file);
$current_file_progress_bytes = $current_file_size * $progress_ratio;
}
}
-
+
$current_progress_bytes = $processed_bytes + $current_file_progress_bytes;
-
+
// Calculate percentage
$percent = min(100, intval(($current_progress_bytes / $total_bytes) * 100));
-
+
// Calculate speed and ETA using unified progress events
$speed = '';
$eta = 'N/A';
-
+
if (count($progress_events) >= 2) {
$first_event = $progress_events[0];
$last_event = $progress_events[count($progress_events) - 1];
-
+
$time_span = $last_event[0] - $first_event[0];
$bytes_span = $last_event[1] - $first_event[1];
-
+
if ($time_span > 0 && $bytes_span > 0) {
// Calculate speed from progress events
$speed_bytes = $bytes_span / $time_span;
$speed = bytes_to_size($speed_bytes) . '/s';
-
+
// Calculate ETA
$remaining_bytes = $total_bytes - $current_progress_bytes;
if ($speed_bytes > 0) {
$eta_seconds = $remaining_bytes / $speed_bytes;
-
+
// Apply hysteresis to prevent ETA from jumping
if ($last_eta_seconds !== null) {
$eta_seconds = ($last_eta_seconds * 0.7) + ($eta_seconds * 0.3);
}
$last_eta_seconds = $eta_seconds;
-
+
$eta = seconds_to_time(intval($eta_seconds));
}
}
}
-
+
// Build progress text
$progress_parts = [];
$progress_parts[] = _('Completed') . ": " . $percent . '%';
@@ -368,7 +368,7 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
$progress_parts[] = _('Speed') . ": " . $speed;
}
$progress_parts[] = _('ETA') . ": " . $eta;
-
+
$text[1] = implode(", ", $progress_parts);
}
@@ -426,21 +426,21 @@ function parse_rsync_progress($status, $action_label, $reset = false) {
// This causes ~2% error per percent point. We average multiple measurements at different percents.
if ($total_size === null || count($total_calculations) < RSYNC_TOTAL_SIZE_SAMPLES) {
$percent_val = intval(str_replace('%', '', $percent));
-
+
// Track if this is a "running transfer" line (no xfr# info)
$is_running_line = !isset($parts[4]);
-
+
// Calculate total when we have minimum progress on a running transfer line
// and the percent changed since last calculation
if ($is_running_line && $percent_val >= RSYNC_MIN_PROGRESS_PERCENT && $last_calc_percent !== $percent_val) {
// Convert transferred size to bytes
$transferred_bytes = size_to_bytes($transferred);
-
+
// Calculate total from transferred and percent (rsync truncates, so add 0.5% for better accuracy)
$calculated_total = $transferred_bytes * 100 / ($percent_val + 0.5);
$total_calculations[] = $calculated_total;
$last_calc_percent = $percent_val;
-
+
// Once we have enough measurements, average them and lock the value
if (count($total_calculations) >= RSYNC_TOTAL_SIZE_SAMPLES) {
$total_size = array_sum($total_calculations) / count($total_calculations);
@@ -463,14 +463,14 @@ function parse_rsync_progress($status, $action_label, $reset = false) {
$progress_parts[] = _('Completed') . ": " . $percent;
$progress_parts[] = _('Speed') . ": " . $speed;
$progress_parts[] = _('ETA') . ": " . $time;
-
+
// Always show Total (either calculated or N/A)
if ($total_size !== null) {
$progress_parts[] = _('Total') . ": ~" . bytes_to_size($total_size);
} else {
$progress_parts[] = _('Total') . ": N/A";
}
-
+
$text[1] = implode(", ", $progress_parts);
}
}
@@ -667,7 +667,14 @@ while (true) {
// start action
} else {
$path = dirname($source[0]);
- exec("mv -f ".quoted($source)." ".quoted("$path/$target")." 1>$null 2>$error & echo \$!", $pid);
+ $target_path = "$path/$target";
+
+ // Check if target already exists
+ if (file_exists($target_path)) {
+ $reply['error'] = _('Target already exists');
+ } else {
+ exec("mv ".quoted($source)." ".quoted($target_path)." 1>$null 2>$error & echo \$!", $pid);
+ }
}
break;
case 3: // copy folder
@@ -907,9 +914,10 @@ while (true) {
$format = $format ?? 'zip';
$archive_name = $archive_name ?? 'archive.zip';
$archive_path = rtrim($target, '/').'/'.$archive_name;
-
+ $archive_tmp = $archive_path.'.tmp';
+
exec('logger -t file_manager "Status check: format=' . escapeshellarg($format) . ', status_file=' . escapeshellarg($status) . ', exists=' . (file_exists($status) ? 'yes' : 'no') . '"');
-
+
// For zip format, use structured progress parser
if ($format === 'zip') {
$text = parse_zip_progress($status, _('Compressing'), 100);
@@ -917,13 +925,15 @@ while (true) {
} else {
// For tar formats, use simple status display for now
$file_line = trim(exec("tail -1 $status 2>$null"));
-
+
$archive_size = '';
- if (file_exists($archive_path)) {
- $size_bytes = filesize($archive_path);
+ // Check tmp file during compression
+ $size_path = file_exists($archive_tmp) ? $archive_tmp : $archive_path;
+ if (file_exists($size_path)) {
+ $size_bytes = filesize($size_path);
$archive_size = ' ['.bytes_to_size($size_bytes).']';
}
-
+
if (!empty($file_line) && strlen($file_line) > 2) {
$clean_line = preg_replace('/^(adding:|deflated|stored|a\s+)/i', '', $file_line);
$clean_line = trim($clean_line);
@@ -932,7 +942,7 @@ while (true) {
$text = [_('Compressing').'...'.$archive_size];
}
}
-
+
$reply['status'] = json_encode([
'action' => $action,
'text' => $text
@@ -946,9 +956,19 @@ while (true) {
$format = $format ?? 'zip';
$archive_name = $archive_name ?? 'archive.zip';
$archive_path = rtrim($target, '/').'/'.$archive_name;
+ $archive_tmp = $archive_path.'.tmp';
+ // Clean up any leftover tmp file from previous failed run
+ @unlink($archive_tmp);
+
exec('logger -t file_manager "Start compress: format=' . escapeshellarg($format) . ', archive=' . escapeshellarg($archive_path) . '"');
-
+
+ // Check if target archive already exists
+ if (file_exists($archive_path)) {
+ $reply['error'] = _('Archive already exists');
+ break;
+ }
+
// For multiple sources, find common parent directory
// For single source, use its parent directory
if (count($source) > 1) {
@@ -964,32 +984,29 @@ while (true) {
$source_parent = dirname($source_path);
$source_basenames = [basename($source_path)];
}
-
+
// Build compression command based on format
$escaped_basenames = implode(' ', array_map('escapeshellarg', $source_basenames));
-
+
switch ($format) {
case 'zip':
- // Inline zip wrapper with structured progress output
- // CRITICAL: The '>$status' redirect at the end keeps the shell group process alive.
- // Without it, the shell group exits immediately after backgrounding, making the PID invalid.
- // The redirect forces the shell to wait for the entire pipeline to complete.
- // CRITICAL FIX: Pattern must be checked after EVERY character, not just at newline.
- // This prevents dots from being buffered into line_buffer before dot_mode activates.
+ // Inline zip wrapper: parse zip output and produce structured log (TOTAL|bytes, FILE|ts|size|name, DOT|ts)
$cmd = <</dev/null | awk '{sum+=\$1} END {print sum}')
+ total_bytes=\$(du -sb $escaped_basenames 2>/dev/null | awk '{sum+=\$1} END {print sum}')
printf 'TOTAL|%s\\n' "\$total_bytes"
-
- zip --recurse-paths --display-usize --display-dots --dot-size 100m {$archive_path} -- {$escaped_basenames} 2>&1 | while IFS= read -r -n1 char || [[ -n \$char ]]; do
-
+
+ # Write to .tmp first for atomic creation
+ # Read char-by-char (-n1) to check regex after each character - prevents dots from being buffered before pattern match
+ zip --recurse-paths --display-usize --display-dots --dot-size 100m "$archive_tmp" -- $escaped_basenames 2>&1 | while IFS= read -r -n1 char || [[ -n \$char ]]; do
+
# Line mode: accumulate characters and check for pattern match after each char
if [[ ! \$dot_mode ]]; then
line_buffer+="\$char"
-
+
# Check after every char if we have the pattern (size) followed by space
# This ensures we switch to dot_mode BEFORE dots arrive
if [[ \$line_buffer =~ (adding|updating):\\ (.*)\\ \\(([0-9.]+[KMGT]?)\\)\ ]]; then
@@ -998,7 +1015,7 @@ while (true) {
printf 'FILE|%(%s)T|%s|%s\\n' -1 "\$src_size" "\$src_file"
dot_mode=1
fi
-
+
# On newline, output non-matching lines as INFO
if [[ \$char == \$'\\n' ]]; then
if [[ ! \$dot_mode && -n \$line_buffer ]]; then
@@ -1006,7 +1023,7 @@ while (true) {
fi
line_buffer=""
fi
-
+
# Dot mode: capture dots individually
elif [[ \$dot_mode ]]; then
if [[ \$char == '.' ]]; then
@@ -1022,6 +1039,22 @@ while (true) {
fi
fi
done
+
+ # Capture zip exit code and write any errors
+ zip_exit=\${PIPESTATUS[0]}
+
+ # On success: atomic rename, on failure: cleanup tmp
+ if [[ \$zip_exit -eq 0 ]]; then
+ mv "$archive_tmp" "$archive_path"
+ else
+ rm -f "$archive_tmp"
+ fi
+
+ # CRITICAL: The '>$status 2>$error' redirect MUST be outside the braces at the group level.
+ # Without it, the shell group process exits immediately after backgrounding (&), making the
+ # returned PID invalid - pid_exists() then finds no /proc/$pid and returns empty $pid.
+ # This redirect forces bash to keep the group process alive until the entire pipeline completes.
+ # Alternative forms like 'printf ... >$status' inside the group do NOT prevent early exit.
} >$status 2>$error & echo \$!
BASH;
break;
@@ -1043,7 +1076,7 @@ while (true) {
default:
$cmd = "cd ".quoted($source_parent)." && zip -r ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status & echo \$!";
}
-
+
exec('logger -t file_manager "Executing command (first 200 chars): ' . escapeshellarg(substr($cmd, 0, 200)) . '"');
exec($cmd, $pid);
exec('logger -t file_manager "After exec: pid=' . escapeshellarg(json_encode($pid)) . '"');
@@ -1061,12 +1094,16 @@ while (true) {
// start action
} else {
+ // TODO: Add "Overwrite existing files" option for extract
+ // Check tar/unzip/7z/unrar options: tar has --skip-existing, unzip has -n (never overwrite), etc.
+ // Let user decide whether to overwrite or skip existing files
+
$archive = $source[0];
$dest = rtrim($target, '/');
-
+
// Create destination directory if it doesn't exist
exec("mkdir -p ".quoted($dest));
-
+
// Detect archive type and extract accordingly
$cmd = "cd ".quoted($dest)." && ";
if (preg_match('/\.(tar\.gz|tgz)$/i', $archive)) {
@@ -1138,15 +1175,21 @@ while (true) {
foreach ($datasets as $dataset) if (exec("ls --indicator-style=none /mnt/$dataset|wc -l")==0) exec("zfs destroy $dataset 2>/dev/null");
}
}
+
+ // For compress action: clean up tmp file if still exists (shouldn't, but safety)
+ if ($action == 16 && isset($archive_tmp)) {
+ @unlink($archive_tmp);
+ }
+
if (file_exists($error)) $reply['error'] = trim(file_get_contents($error));
-
+
// Debug: Copy status file before deletion for action 16 (compress)
if ($action == 16 && file_exists($status)) {
$debug_copy = '/tmp/status-debug-compress.txt';
copy($status, $debug_copy);
exec('logger -t file_manager "Status file copied to ' . $debug_copy . ' before deletion"');
}
-
+
delete_file($active, $pid_file, $status, $error);
unset($pid);
$delete_empty_dirs = null;
From b2616c5eff3f0b98d319c4d2d535ae6523e83b21 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 18:26:46 +0100
Subject: [PATCH 036/173] Fix negative ETA when dot count exceeds du-reported
size
zip --dot-size 100m counts in 1024-based MiB steps while du -sb reports
exact bytes. For files whose size is not an exact multiple of 100 MiB,
the last dot can push current_progress_bytes past total_bytes, causing
remaining_bytes < 0 and a negative ETA (e.g. 'ETA: 0:00:-4').
- Clamp eta_seconds to >= 0 before displaying
- Don't store negative values in last_eta_seconds (prevents hysteresis
from amplifying the error across subsequent polls)
- Add targeted logger call when overcounting is detected to aid diagnosis
---
emhttp/plugins/dynamix/nchan/file_manager | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 000fe9015e..fb26835f9e 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -325,6 +325,11 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
$current_progress_bytes = $processed_bytes + $current_file_progress_bytes;
+ // Log when dot count exceeds du-reported size (helps diagnose "ETA: 0:00:-N" if it recurs)
+ if ($current_progress_bytes > $total_bytes) {
+ exec('logger -t file_manager "parse_zip_progress: overcounting: total=' . $total_bytes . ', current_progress=' . intval($current_progress_bytes) . ', processed_bytes=' . $processed_bytes . ', file_dots=' . $current_file_dots . ', file_size=' . intval($current_file_size) . '"');
+ }
+
// Calculate percentage
$percent = min(100, intval(($current_progress_bytes / $total_bytes) * 100));
@@ -353,9 +358,9 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
if ($last_eta_seconds !== null) {
$eta_seconds = ($last_eta_seconds * 0.7) + ($eta_seconds * 0.3);
}
- $last_eta_seconds = $eta_seconds;
+ $last_eta_seconds = max(0.0, $eta_seconds); // Don't accumulate negative values into hysteresis
- $eta = seconds_to_time(intval($eta_seconds));
+ $eta = seconds_to_time(max(0, intval($eta_seconds)));
}
}
}
From 24c461a092fea535bb2673ca1dee1135c6a47c18 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 19:28:39 +0100
Subject: [PATCH 037/173] Refactor zip progress: replace avg-time heuristic
with speed-based interpolation
Remove first_file_time, last_file_time, file_count (3 static vars).
Replace heuristic (avg time per completed file) with: when no DOTs yet,
interpolate current file progress as speed * elapsed_since_file_start.
This produces smooth progress for small files without artificial estimates,
and unifies the speed calculation path for both DOT and small-file cases.
---
emhttp/plugins/dynamix/nchan/file_manager | 97 +++++++++--------------
1 file changed, 36 insertions(+), 61 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index fb26835f9e..01c0a1639a 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -162,8 +162,8 @@ function calculate_eta($transferred, $percent, $speed, $last_rsync_eta_seconds =
*
* Reads structured log file incrementally with format:
* TOTAL|bytes (first line with total source size)
- * FILE|size|filename (filename at end to handle pipes in filename)
- * DOT|timestamp (each 100MB compressed)
+ * FILE|timestamp|size|filename (filename at end to handle pipes in filename)
+ * DOT|timestamp (each 100MB of source file already compressed)
* INFO|message (optional)
*
* @param string $status Path to structured log file
@@ -179,10 +179,8 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
static $current_file_name = '';
static $current_file_size = 0;
static $current_file_dots = 0;
- static $progress_events = []; // Array of [timestamp, bytes_processed] for unified speed calculation
- static $first_file_time = null; // Keep for current file progress estimation
- static $last_file_time = null; // Keep for current file progress estimation
- static $file_count = 0;
+ static $progress_events = []; // Array of [timestamp, bytes_processed] for speed calculation
+ static $current_file_start_time = null; // Unix timestamp when current file started (for small-file interpolation)
static $last_eta_seconds = null;
static $info_messages = [];
@@ -195,9 +193,7 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
$current_file_size = 0;
$current_file_dots = 0;
$progress_events = [];
- $first_file_time = null;
- $last_file_time = null;
- $file_count = 0;
+ $current_file_start_time = null;
$last_eta_seconds = null;
$info_messages = [];
return [];
@@ -251,13 +247,7 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
$current_file_size = size_to_bytes($parts[2]);
$current_file_name = $parts[3] ?? '';
$current_file_dots = 0;
- $file_count++;
-
- // Track FILE timestamp for current file progress estimation
- if ($first_file_time === null) {
- $first_file_time = $timestamp;
- }
- $last_file_time = $timestamp;
+ $current_file_start_time = $timestamp;
// Record progress event: new file starts, no progress on this file yet
$progress_events[] = [$timestamp, $processed_bytes];
@@ -298,31 +288,32 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
}
}
- exec('logger -t file_manager "parse_zip_progress: total_bytes=' . $total_bytes . ', processed_bytes=' . $processed_bytes . ', current_file_dots=' . $current_file_dots . ', file_count=' . $file_count . '"');
+ exec('logger -t file_manager "parse_zip_progress: total_bytes=' . $total_bytes . ', processed_bytes=' . $processed_bytes . ', current_file_dots=' . $current_file_dots . '"');
// Calculate progress if we have total size
if ($total_bytes && $total_bytes > 0) {
- // Current progress = completed files + progress of current file
- $dots_bytes = $current_file_dots * $dot_size_mb * 1024 * 1024;
- $current_file_progress_bytes = $dots_bytes; // Start with DOTs if available
-
- // For small files without DOTs, estimate progress based on elapsed time
- if ($dots_bytes == 0 && $current_file_size > 0 && $last_file_time !== null && $file_count > 1) {
- // Calculate average time per file from completed files
- $completed_files = $file_count - 1; // Exclude current file
- $avg_time_per_file = ($last_file_time - $first_file_time) / $completed_files;
-
- // Time elapsed since current file started
- $current_time = time();
- $time_elapsed = $current_time - $last_file_time;
-
- // Estimate progress of current file (cap at 100%)
- if ($avg_time_per_file > 0) {
- $progress_ratio = min(1.0, $time_elapsed / $avg_time_per_file);
- $current_file_progress_bytes = $current_file_size * $progress_ratio;
+ // Calculate speed from progress events (reused for interpolation and display)
+ $speed_bytes = 0;
+ if (count($progress_events) >= 2) {
+ $first_event = $progress_events[0];
+ $last_event = $progress_events[count($progress_events) - 1];
+ $time_span = $last_event[0] - $first_event[0];
+ $bytes_span = $last_event[1] - $first_event[1];
+ if ($time_span > 0 && $bytes_span > 0) {
+ $speed_bytes = $bytes_span / $time_span;
}
}
+ // Current progress = completed files + progress of current file via DOTs
+ $dots_bytes = $current_file_dots * $dot_size_mb * 1024 * 1024;
+ $current_file_progress_bytes = $dots_bytes;
+
+ // No DOTs yet: interpolate using current speed and elapsed time since file started
+ if ($dots_bytes == 0 && $current_file_size > 0 && $current_file_start_time !== null && $speed_bytes > 0) {
+ $time_elapsed = time() - $current_file_start_time;
+ $current_file_progress_bytes = min($current_file_size, $speed_bytes * $time_elapsed);
+ }
+
$current_progress_bytes = $processed_bytes + $current_file_progress_bytes;
// Log when dot count exceeds du-reported size (helps diagnose "ETA: 0:00:-N" if it recurs)
@@ -330,39 +321,23 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
exec('logger -t file_manager "parse_zip_progress: overcounting: total=' . $total_bytes . ', current_progress=' . intval($current_progress_bytes) . ', processed_bytes=' . $processed_bytes . ', file_dots=' . $current_file_dots . ', file_size=' . intval($current_file_size) . '"');
}
- // Calculate percentage
$percent = min(100, intval(($current_progress_bytes / $total_bytes) * 100));
- // Calculate speed and ETA using unified progress events
$speed = '';
$eta = 'N/A';
+ if ($speed_bytes > 0) {
+ $speed = bytes_to_size($speed_bytes) . '/s';
- if (count($progress_events) >= 2) {
- $first_event = $progress_events[0];
- $last_event = $progress_events[count($progress_events) - 1];
-
- $time_span = $last_event[0] - $first_event[0];
- $bytes_span = $last_event[1] - $first_event[1];
-
- if ($time_span > 0 && $bytes_span > 0) {
- // Calculate speed from progress events
- $speed_bytes = $bytes_span / $time_span;
- $speed = bytes_to_size($speed_bytes) . '/s';
+ $remaining_bytes = $total_bytes - $current_progress_bytes;
+ $eta_seconds = $remaining_bytes / $speed_bytes;
- // Calculate ETA
- $remaining_bytes = $total_bytes - $current_progress_bytes;
- if ($speed_bytes > 0) {
- $eta_seconds = $remaining_bytes / $speed_bytes;
-
- // Apply hysteresis to prevent ETA from jumping
- if ($last_eta_seconds !== null) {
- $eta_seconds = ($last_eta_seconds * 0.7) + ($eta_seconds * 0.3);
- }
- $last_eta_seconds = max(0.0, $eta_seconds); // Don't accumulate negative values into hysteresis
-
- $eta = seconds_to_time(max(0, intval($eta_seconds)));
- }
+ // Apply hysteresis to prevent ETA from jumping
+ if ($last_eta_seconds !== null) {
+ $eta_seconds = ($last_eta_seconds * 0.7) + ($eta_seconds * 0.3);
}
+ $last_eta_seconds = max(0.0, $eta_seconds); // Don't accumulate negative values into hysteresis
+
+ $eta = seconds_to_time(max(0, intval($eta_seconds)));
}
// Build progress text
From 2aaffd1f9829e29b4fc58780fde5ba1443c83508 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 19:55:49 +0100
Subject: [PATCH 038/173] Optimize nchan traffic: skip publish when status
unchanged
- parse_zip_progress returns [] when no new log lines (early return)
- Remove small-file speed interpolation and $current_file_start_time
- Case 16: only set $reply[status] when parse_zip_progress returns data
- Global dedup: track $last_published_status and skip publish when
status JSON is identical to last poll (applies to rsync, zip, tar)
---
emhttp/plugins/dynamix/nchan/file_manager | 156 +++++++++++-----------
1 file changed, 77 insertions(+), 79 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 01c0a1639a..5c8e2b9fb0 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -180,7 +180,6 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
static $current_file_size = 0;
static $current_file_dots = 0;
static $progress_events = []; // Array of [timestamp, bytes_processed] for speed calculation
- static $current_file_start_time = null; // Unix timestamp when current file started (for small-file interpolation)
static $last_eta_seconds = null;
static $info_messages = [];
@@ -193,7 +192,6 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
$current_file_size = 0;
$current_file_dots = 0;
$progress_events = [];
- $current_file_start_time = null;
$last_eta_seconds = null;
$info_messages = [];
return [];
@@ -202,80 +200,77 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
// Initialize text array with action label
$text[0] = $action_label . "... ";
- // Check if status file exists
- if (!file_exists($status)) {
- exec('logger -t file_manager "parse_zip_progress: status file does not exist: ' . escapeshellarg($status) . '"');
- return $text;
- }
-
- // Get total line count to know if there are new lines
- $total_lines = intval(exec("wc -l < " . escapeshellarg($status) . " 2>/dev/null || echo 0"));
- exec('logger -t file_manager "parse_zip_progress: total_lines=' . $total_lines . ', last_line_number=' . $last_line_number . '"');
- if ($total_lines == 0) {
- exec('logger -t file_manager "parse_zip_progress: status file is empty"');
- return $text;
- }
-
// Read only new lines since last call (incremental)
- if ($last_line_number < $total_lines) {
- $start_line = $last_line_number + 1;
- $new_lines = [];
- exec("tail -n +" . escapeshellarg($start_line) . " " . escapeshellarg($status) . " 2>/dev/null", $new_lines);
- exec('logger -t file_manager "parse_zip_progress: read ' . count($new_lines) . ' new lines from line ' . $start_line . '"');
-
- // Process new lines
- foreach ($new_lines as $line) {
- $parts = explode('|', $line, 4);
- if (count($parts) < 2) continue;
-
- $type = $parts[0];
- switch ($type) {
- case 'TOTAL':
- // First line: total size of all source files
- $total_bytes = floatval($parts[1]);
- break;
-
- case 'FILE':
- // New file started - previous file is complete
- if ($current_file_size > 0) {
- // Add completed file to processed bytes
- $processed_bytes += $current_file_size;
- }
-
- // Parse FILE|timestamp|size|filename
- $timestamp = intval($parts[1]);
- $current_file_size = size_to_bytes($parts[2]);
- $current_file_name = $parts[3] ?? '';
- $current_file_dots = 0;
- $current_file_start_time = $timestamp;
+ $start_line = $last_line_number + 1;
+ $new_lines = [];
+ exec("tail -n +" . escapeshellarg($start_line) . " " . escapeshellarg($status) . " 2>/dev/null", $new_lines);
+ exec('logger -t file_manager "parse_zip_progress: read ' . count($new_lines) . ' new lines from line ' . $start_line . '"');
- // Record progress event: new file starts, no progress on this file yet
- $progress_events[] = [$timestamp, $processed_bytes];
- break;
-
- case 'DOT':
- // Progress dot for current file
- $timestamp = intval($parts[1]);
- $current_file_dots++;
+ if (empty($new_lines)) {
+ return [];
+ }
- // Record progress event: processed_bytes (completed files) + current file progress
- $current_file_progress = $current_file_dots * $dot_size_mb * 1024 * 1024;
- $progress_events[] = [$timestamp, $processed_bytes + $current_file_progress];
- break;
+ // Process new lines
+ // Example lines:
+ // TOTAL|7178980548
+ // FILE|1772990889|0|small_files_test/
+ // FILE|1772990889|50M|small_files_test/file_1.bin
+ // FILE|1772990890|50M|small_files_test/file_2.bin
+ // FILE|1772990912|0|special_test/
+ // FILE|1772990912|200M|special_test/normal.bin
+ // DOT|1772990914
+ // DOT|1772990916
+ // FILE|1772990916|250M|special_test/with spaces.bin
+ foreach ($new_lines as $line) {
+ $parts = explode('|', $line, 4);
+ if (count($parts) < 2) continue;
+
+ $type = $parts[0];
+ switch ($type) {
+ case 'TOTAL':
+ // First line: total size of all source files
+ $total_bytes = floatval($parts[1]);
+ break;
+
+ case 'FILE':
+ // New file started - previous file is complete
+ if ($current_file_size > 0) {
+ // Add completed file to processed bytes
+ $processed_bytes += $current_file_size;
+ }
- case 'INFO':
- // Collect info messages (keep last 3)
- $info_messages[] = $parts[1];
- if (count($info_messages) > 3) {
- array_shift($info_messages);
- }
- break;
- }
+ // Parse FILE|timestamp|size|filename
+ $timestamp = intval($parts[1]);
+ $current_file_size = size_to_bytes($parts[2]);
+ $current_file_name = $parts[3] ?? '';
+ $current_file_dots = 0;
+
+ // Record progress event: new file starts, no progress on this file yet
+ $progress_events[] = [$timestamp, $processed_bytes];
+ break;
+
+ case 'DOT':
+ // Progress dot for current file
+ $timestamp = intval($parts[1]);
+ $current_file_dots++;
+
+ // Record progress event: processed_bytes (completed files) + current file progress
+ $current_file_progress = $current_file_dots * $dot_size_mb * 1024 * 1024;
+ $progress_events[] = [$timestamp, $processed_bytes + $current_file_progress];
+ break;
+
+ case 'INFO':
+ // Collect info messages (keep last 3)
+ $info_messages[] = $parts[1];
+ if (count($info_messages) > 3) {
+ array_shift($info_messages);
+ }
+ break;
}
-
- $last_line_number = $total_lines;
}
+ $last_line_number += count($new_lines);
+
// Display current filename
if ($current_file_name) {
$text[0] .= mb_strimhalf($current_file_name, 70, '...');
@@ -308,12 +303,6 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
$dots_bytes = $current_file_dots * $dot_size_mb * 1024 * 1024;
$current_file_progress_bytes = $dots_bytes;
- // No DOTs yet: interpolate using current speed and elapsed time since file started
- if ($dots_bytes == 0 && $current_file_size > 0 && $current_file_start_time !== null && $speed_bytes > 0) {
- $time_elapsed = time() - $current_file_start_time;
- $current_file_progress_bytes = min($current_file_size, $speed_bytes * $time_elapsed);
- }
-
$current_progress_bytes = $processed_bytes + $current_file_progress_bytes;
// Log when dot count exceeds du-reported size (helps diagnose "ETA: 0:00:-N" if it recurs)
@@ -563,6 +552,7 @@ if (!file_exists($empty_dir)) {
// initialize $delete_empty_dirs state: null = not a move operation (yet), true = rsync copy-delete phase, false = cleanup phase (done)
$delete_empty_dirs = null;
+$last_published_status = null;
// infinite loop to monitor and execute file operations
// Note: exec() uses /bin/sh which is symlinked to bash in unraid and a requirement for process substitution syntax >(...)
@@ -923,10 +913,12 @@ while (true) {
}
}
- $reply['status'] = json_encode([
- 'action' => $action,
- 'text' => $text
- ]);
+ if (!empty($text)) {
+ $reply['status'] = json_encode([
+ 'action' => $action,
+ 'text' => $text
+ ]);
+ }
// start action
} else {
@@ -1181,6 +1173,12 @@ while (true) {
publish('filemonitor', file_exists($active) ? 1 : 0);
$timer = time();
}
+ // Don't publish identical status (avoids redundant nchan traffic for rsync/zip)
+ if (isset($reply['status']) && !isset($reply['done']) && $reply['status'] === $last_published_status) {
+ unset($reply['status']);
+ } else {
+ $last_published_status = $reply['status'] ?? null;
+ }
publish('filemanager', json_encode($reply));
usleep(250000);
}
From b27cb83c69ac6fc2d1f956c2d9cd5ddcf6f27471 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 21:54:58 +0100
Subject: [PATCH 039/173] Add progress for tar formats using checkpoint+awk
structured log
All tar formats (tar, tar.gz, tar.bz2, tar.xz, tar.zst) now produce the
same structured log as zip: TOTAL|bytes, FILE|ts|0|name, DOT|ts.
Implementation (Test 13 approach):
- du -sb calculates total_bytes, writes TOTAL line first
- checkpoint = total_bytes / 10240 / 50 => always ~50 dots regardless of size
- tar -cv stdout piped through awk => FILE|ts|0|name lines
- --checkpoint-action=exec=printf DOT|ts => appends to same status file
- atomic create: write to archive.tmp, mv on success
- parse_zip_progress renamed to parse_compress_progress with null dot_size_mb
support (auto mode: total_bytes/50 per dot) for tar progress
---
emhttp/plugins/dynamix/nchan/file_manager | 73 ++++++++++++-----------
1 file changed, 38 insertions(+), 35 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 5c8e2b9fb0..de61e551b4 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -158,21 +158,21 @@ function calculate_eta($transferred, $percent, $speed, $last_rsync_eta_seconds =
}
/**
- * Parse zip progress output from structured log format.
+ * Parse compress progress output from structured log format (zip and tar formats).
*
* Reads structured log file incrementally with format:
* TOTAL|bytes (first line with total source size)
* FILE|timestamp|size|filename (filename at end to handle pipes in filename)
- * DOT|timestamp (each 100MB of source file already compressed)
+ * DOT|timestamp (each dot = dot_size_mb of source data; null = auto: total_bytes/50)
* INFO|message (optional)
*
* @param string $status Path to structured log file
* @param string $action_label Label to display for the action (e.g. "Compressing")
- * @param int $dot_size_mb Size of each progress dot in MB (default 100)
+ * @param int|null $dot_size_mb Size of each progress dot in MB; null = auto (total_bytes/50)
* @param bool $reset If true, resets static state variables
* @return array Text array with progress information
*/
-function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset = false) {
+function parse_compress_progress($status, $action_label, $dot_size_mb = 100, $reset = false) {
static $last_line_number = 0;
static $total_bytes = null;
static $processed_bytes = 0;
@@ -197,6 +197,8 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
return [];
}
+ $auto_dot_size = ($dot_size_mb === null);
+
// Initialize text array with action label
$text[0] = $action_label . "... ";
@@ -204,7 +206,7 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
$start_line = $last_line_number + 1;
$new_lines = [];
exec("tail -n +" . escapeshellarg($start_line) . " " . escapeshellarg($status) . " 2>/dev/null", $new_lines);
- exec('logger -t file_manager "parse_zip_progress: read ' . count($new_lines) . ' new lines from line ' . $start_line . '"');
+ exec('logger -t file_manager "parse_compress_progress: read ' . count($new_lines) . ' new lines from line ' . $start_line . '"');
if (empty($new_lines)) {
return [];
@@ -255,7 +257,8 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
$current_file_dots++;
// Record progress event: processed_bytes (completed files) + current file progress
- $current_file_progress = $current_file_dots * $dot_size_mb * 1024 * 1024;
+ $effective_dot_mb = $auto_dot_size ? ($total_bytes > 0 ? $total_bytes / 50.0 / (1024 * 1024) : 100) : $dot_size_mb;
+ $current_file_progress = $current_file_dots * $effective_dot_mb * 1024 * 1024;
$progress_events[] = [$timestamp, $processed_bytes + $current_file_progress];
break;
@@ -283,7 +286,7 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
}
}
- exec('logger -t file_manager "parse_zip_progress: total_bytes=' . $total_bytes . ', processed_bytes=' . $processed_bytes . ', current_file_dots=' . $current_file_dots . '"');
+ exec('logger -t file_manager "parse_compress_progress: total_bytes=' . $total_bytes . ', processed_bytes=' . $processed_bytes . ', current_file_dots=' . $current_file_dots . '"');
// Calculate progress if we have total size
if ($total_bytes && $total_bytes > 0) {
@@ -300,14 +303,15 @@ function parse_zip_progress($status, $action_label, $dot_size_mb = 100, $reset =
}
// Current progress = completed files + progress of current file via DOTs
- $dots_bytes = $current_file_dots * $dot_size_mb * 1024 * 1024;
+ $effective_dot_mb = $auto_dot_size ? ($total_bytes / 50.0 / (1024 * 1024)) : $dot_size_mb;
+ $dots_bytes = $current_file_dots * $effective_dot_mb * 1024 * 1024;
$current_file_progress_bytes = $dots_bytes;
$current_progress_bytes = $processed_bytes + $current_file_progress_bytes;
// Log when dot count exceeds du-reported size (helps diagnose "ETA: 0:00:-N" if it recurs)
if ($current_progress_bytes > $total_bytes) {
- exec('logger -t file_manager "parse_zip_progress: overcounting: total=' . $total_bytes . ', current_progress=' . intval($current_progress_bytes) . ', processed_bytes=' . $processed_bytes . ', file_dots=' . $current_file_dots . ', file_size=' . intval($current_file_size) . '"');
+ exec('logger -t file_manager "parse_compress_progress: overcounting: total=' . $total_bytes . ', current_progress=' . intval($current_progress_bytes) . ', processed_bytes=' . $processed_bytes . ', file_dots=' . $current_file_dots . ', file_size=' . intval($current_file_size) . '"');
}
$percent = min(100, intval(($current_progress_bytes / $total_bytes) * 100));
@@ -890,27 +894,10 @@ while (true) {
// For zip format, use structured progress parser
if ($format === 'zip') {
- $text = parse_zip_progress($status, _('Compressing'), 100);
- exec('logger -t file_manager "parse_zip_progress returned: ' . escapeshellarg(json_encode($text)) . '"');
+ $text = parse_compress_progress($status, _('Compressing'), 100);
+ exec('logger -t file_manager "parse_compress_progress returned: ' . escapeshellarg(json_encode($text)) . '"');
} else {
- // For tar formats, use simple status display for now
- $file_line = trim(exec("tail -1 $status 2>$null"));
-
- $archive_size = '';
- // Check tmp file during compression
- $size_path = file_exists($archive_tmp) ? $archive_tmp : $archive_path;
- if (file_exists($size_path)) {
- $size_bytes = filesize($size_path);
- $archive_size = ' ['.bytes_to_size($size_bytes).']';
- }
-
- if (!empty($file_line) && strlen($file_line) > 2) {
- $clean_line = preg_replace('/^(adding:|deflated|stored|a\s+)/i', '', $file_line);
- $clean_line = trim($clean_line);
- $text = [_('Compressing').': '.mb_strimhalf($clean_line, 50, '...').$archive_size];
- } else {
- $text = [_('Compressing').'...'.$archive_size];
- }
+ $text = parse_compress_progress($status, _('Compressing'), null);
}
if (!empty($text)) {
@@ -924,7 +911,7 @@ while (true) {
} else {
exec('logger -t file_manager "Case 16 start: pid is empty, starting new job"');
@unlink('/tmp/status-debug-compress.txt'); // Clear old debug file
- parse_zip_progress(null, null, 100, true); // Reset static variables
+ parse_compress_progress(null, null, 100, true); // Reset static variables
$format = $format ?? 'zip';
$archive_name = $archive_name ?? 'archive.zip';
$archive_path = rtrim($target, '/').'/'.$archive_name;
@@ -1031,24 +1018,40 @@ while (true) {
BASH;
break;
case 'tar':
- $cmd = "cd ".quoted($source_parent)." && tar -cvf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status & echo \$!";
+ $tar_flags = '-cvf';
break;
case 'tar.gz':
- $cmd = "cd ".quoted($source_parent)." && tar -czvf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status & echo \$!";
+ $tar_flags = '-czvf';
break;
case 'tar.zst':
- $cmd = "cd ".quoted($source_parent)." && tar --use-compress-program=zstd -cvf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status & echo \$!";
+ $tar_flags = '--use-compress-program=zstd -cvf';
break;
case 'tar.bz2':
- $cmd = "cd ".quoted($source_parent)." && tar -cvjf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status & echo \$!";
+ $tar_flags = '-cvjf';
break;
case 'tar.xz':
- $cmd = "cd ".quoted($source_parent)." && tar -cvJf ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status & echo \$!";
+ $tar_flags = '-cvJf';
break;
default:
$cmd = "cd ".quoted($source_parent)." && zip -r ".quoted($archive_path)." ".$escaped_basenames." 2>&1 | tee $status & echo \$!";
}
+ if (isset($tar_flags)) {
+ $cmd = <</dev/null | awk '{sum+=\$1} END {print sum}')
+ echo "TOTAL|\$total_bytes"
+ record_size=10240
+ checkpoint=\$((total_bytes / record_size / 50))
+ [[ \$checkpoint -lt 1 ]] && checkpoint=1
+ tar $tar_flags "$archive_tmp" --record-size=\$record_size --checkpoint=\$checkpoint "--checkpoint-action=exec=printf 'DOT|%s\\n' \\\$(date +%s) >> $status" -- $escaped_basenames | awk '{printf "FILE|%d|0|%s\\n", systime(), \$0; fflush()}'
+ tar_exit=\${PIPESTATUS[0]}
+ [[ \$tar_exit -eq 0 ]] && mv "$archive_tmp" "$archive_path" || rm -f "$archive_tmp"
+ } >$status 2>$error & echo \$!
+ BASH;
+ }
+
exec('logger -t file_manager "Executing command (first 200 chars): ' . escapeshellarg(substr($cmd, 0, 200)) . '"');
exec($cmd, $pid);
exec('logger -t file_manager "After exec: pid=' . escapeshellarg(json_encode($pid)) . '"');
From 73477ac98463e95fd1815d15f3f2bd337d5578ce Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 22:04:21 +0100
Subject: [PATCH 040/173] Fix tar progress regression and archive name dropdown
- parse_compress_progress: carry over DOT-estimated progress into
processed_bytes when FILE size is 0 (tar case), prevents % from
resetting to 0 on each new file
- updateArchiveName: use dfm.window.find() instead of global $() to
avoid reading from the hidden template element (which always has
value 'zip'), so format change now correctly updates file extension
---
emhttp/plugins/dynamix/Browse.page | 22 ++++++++--------------
emhttp/plugins/dynamix/nchan/file_manager | 6 +++++-
2 files changed, 13 insertions(+), 15 deletions(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index 2185e9a169..01ac1aae2b 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -296,29 +296,23 @@ function fileExtension(file) {
}
function updateArchiveName() {
+ var $dialog = dfm.window;
// Get first source name from data attribute (set when dialog opened)
- var baseName = $('#dfm_source').data('firstName') || '';
- console.log('updateArchiveName: baseName from data =', baseName);
-
+ var baseName = $dialog.find('#dfm_source').data('firstName') || '';
+
if (!baseName) {
- console.log('updateArchiveName: no firstName data, using fallback');
- var sourceText = $('#dfm_source').text().trim();
+ var sourceText = $dialog.find('#dfm_source').text().trim();
var sourceName = sourceText.split('\r')[0] || sourceText;
sourceName = sourceName.replace(/\/$/, '');
baseName = fileName(sourceName);
}
-
+
// Remove existing extension from base name
baseName = baseName.replace(/\.(zip|tar|tar\.gz|tar\.bz2|tar\.xz|tar\.zst|tgz|tbz2|txz)$/i, '');
- var format = $('#dfm_format').val();
+ var format = $dialog.find('#dfm_format').val();
var proposedName = baseName + '.' + format;
- console.log('updateArchiveName: proposedName =', proposedName);
-
- // TODO: Auto-increment (-2, -3) if file exists
- // Currently not implemented because fileTree only shows directories, not files
- // Would require AJAX call to check target directory contents
-
- $('#dfm_archive_name').val(proposedName);
+
+ $dialog.find('#dfm_archive_name').val(proposedName);
}
function fileEdit(id) {
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index de61e551b4..76bd641b33 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -237,8 +237,12 @@ function parse_compress_progress($status, $action_label, $dot_size_mb = 100, $re
case 'FILE':
// New file started - previous file is complete
if ($current_file_size > 0) {
- // Add completed file to processed bytes
+ // zip: exact size known, add to processed bytes
$processed_bytes += $current_file_size;
+ } elseif ($current_file_dots > 0) {
+ // tar: no file size (size=0), carry over DOT-estimated progress to prevent reset
+ $effective_dot_mb = $auto_dot_size ? ($total_bytes > 0 ? $total_bytes / 50.0 / (1024 * 1024) : 100) : $dot_size_mb;
+ $processed_bytes += $current_file_dots * $effective_dot_mb * 1024 * 1024;
}
// Parse FILE|timestamp|size|filename
From 55a4c8fe2b24504bf1145d4a4909dd7688752763 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 8 Mar 2026 23:25:03 +0100
Subject: [PATCH 041/173] Unify compress progress: fixed 100MB/dot for both zip
and tar
- Add COMPRESS_DOT_SIZE_MB=100 constant (zip: --dot-size, tar: checkpoint)
- Remove auto_dot_size mode: tar now uses same fixed 100MB/dot as zip
- archive_tmp: use uniqid() instead of .tmp suffix to avoid overwriting
existing files (e.g. archive.zip.tmp) during parallel operations
- Fix bash violations: remove -n flag in [[ ]] tests, refactor long tar
command into tar_args array
---
emhttp/plugins/dynamix/nchan/file_manager | 53 +++++++++++------------
1 file changed, 26 insertions(+), 27 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 76bd641b33..6646ea47ef 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -25,6 +25,9 @@ $timer = time();
define('RSYNC_MIN_PROGRESS_PERCENT', 3); // Minimum progress percentage before calculating total size
define('RSYNC_TOTAL_SIZE_SAMPLES', 5); // Number of measurements to average for total size calculation
+// Compress progress dot size: 1 dot = this many MB of source data (zip: --dot-size, tar: checkpoint interval)
+define('COMPRESS_DOT_SIZE_MB', 100);
+
require_once "$docroot/webGui/include/Helpers.php";
require_once "$docroot/webGui/include/Wrappers.php";
require_once "$docroot/webGui/include/publish.php";
@@ -163,12 +166,12 @@ function calculate_eta($transferred, $percent, $speed, $last_rsync_eta_seconds =
* Reads structured log file incrementally with format:
* TOTAL|bytes (first line with total source size)
* FILE|timestamp|size|filename (filename at end to handle pipes in filename)
- * DOT|timestamp (each dot = dot_size_mb of source data; null = auto: total_bytes/50)
+ * DOT|timestamp (each dot = dot_size_mb MB of source data processed)
* INFO|message (optional)
*
* @param string $status Path to structured log file
* @param string $action_label Label to display for the action (e.g. "Compressing")
- * @param int|null $dot_size_mb Size of each progress dot in MB; null = auto (total_bytes/50)
+ * @param int $dot_size_mb Size of each progress dot in MB (e.g. 100 = same as zip --dot-size 100m)
* @param bool $reset If true, resets static state variables
* @return array Text array with progress information
*/
@@ -197,8 +200,6 @@ function parse_compress_progress($status, $action_label, $dot_size_mb = 100, $re
return [];
}
- $auto_dot_size = ($dot_size_mb === null);
-
// Initialize text array with action label
$text[0] = $action_label . "... ";
@@ -241,8 +242,7 @@ function parse_compress_progress($status, $action_label, $dot_size_mb = 100, $re
$processed_bytes += $current_file_size;
} elseif ($current_file_dots > 0) {
// tar: no file size (size=0), carry over DOT-estimated progress to prevent reset
- $effective_dot_mb = $auto_dot_size ? ($total_bytes > 0 ? $total_bytes / 50.0 / (1024 * 1024) : 100) : $dot_size_mb;
- $processed_bytes += $current_file_dots * $effective_dot_mb * 1024 * 1024;
+ $processed_bytes += $current_file_dots * $dot_size_mb * 1024 * 1024;
}
// Parse FILE|timestamp|size|filename
@@ -261,8 +261,7 @@ function parse_compress_progress($status, $action_label, $dot_size_mb = 100, $re
$current_file_dots++;
// Record progress event: processed_bytes (completed files) + current file progress
- $effective_dot_mb = $auto_dot_size ? ($total_bytes > 0 ? $total_bytes / 50.0 / (1024 * 1024) : 100) : $dot_size_mb;
- $current_file_progress = $current_file_dots * $effective_dot_mb * 1024 * 1024;
+ $current_file_progress = $current_file_dots * $dot_size_mb * 1024 * 1024;
$progress_events[] = [$timestamp, $processed_bytes + $current_file_progress];
break;
@@ -307,8 +306,7 @@ function parse_compress_progress($status, $action_label, $dot_size_mb = 100, $re
}
// Current progress = completed files + progress of current file via DOTs
- $effective_dot_mb = $auto_dot_size ? ($total_bytes / 50.0 / (1024 * 1024)) : $dot_size_mb;
- $dots_bytes = $current_file_dots * $effective_dot_mb * 1024 * 1024;
+ $dots_bytes = $current_file_dots * $dot_size_mb * 1024 * 1024;
$current_file_progress_bytes = $dots_bytes;
$current_progress_bytes = $processed_bytes + $current_file_progress_bytes;
@@ -896,13 +894,7 @@ while (true) {
exec('logger -t file_manager "Status check: format=' . escapeshellarg($format) . ', status_file=' . escapeshellarg($status) . ', exists=' . (file_exists($status) ? 'yes' : 'no') . '"');
- // For zip format, use structured progress parser
- if ($format === 'zip') {
- $text = parse_compress_progress($status, _('Compressing'), 100);
- exec('logger -t file_manager "parse_compress_progress returned: ' . escapeshellarg(json_encode($text)) . '"');
- } else {
- $text = parse_compress_progress($status, _('Compressing'), null);
- }
+ $text = parse_compress_progress($status, _('Compressing'), COMPRESS_DOT_SIZE_MB);
if (!empty($text)) {
$reply['status'] = json_encode([
@@ -915,14 +907,13 @@ while (true) {
} else {
exec('logger -t file_manager "Case 16 start: pid is empty, starting new job"');
@unlink('/tmp/status-debug-compress.txt'); // Clear old debug file
- parse_compress_progress(null, null, 100, true); // Reset static variables
+ parse_compress_progress(null, null, COMPRESS_DOT_SIZE_MB, true); // Reset static variables
+ $dot_mb = COMPRESS_DOT_SIZE_MB;
$format = $format ?? 'zip';
$archive_name = $archive_name ?? 'archive.zip';
$archive_path = rtrim($target, '/').'/'.$archive_name;
- $archive_tmp = $archive_path.'.tmp';
-
- // Clean up any leftover tmp file from previous failed run
- @unlink($archive_tmp);
+ // unique tmp path in same dir (required for atomic rename across same filesystem)
+ $archive_tmp = rtrim($target, '/').'/.'.$archive_name.'_'.uniqid('', true).'.tmp';
exec('logger -t file_manager "Start compress: format=' . escapeshellarg($format) . ', archive=' . escapeshellarg($archive_path) . '"');
@@ -964,7 +955,7 @@ while (true) {
# Write to .tmp first for atomic creation
# Read char-by-char (-n1) to check regex after each character - prevents dots from being buffered before pattern match
- zip --recurse-paths --display-usize --display-dots --dot-size 100m "$archive_tmp" -- $escaped_basenames 2>&1 | while IFS= read -r -n1 char || [[ -n \$char ]]; do
+ zip --recurse-paths --display-usize --display-dots --dot-size {$dot_mb}m "$archive_tmp" -- $escaped_basenames 2>&1 | while IFS= read -r -n1 char || [[ \$char ]]; do
# Line mode: accumulate characters and check for pattern match after each char
if [[ ! \$dot_mode ]]; then
@@ -981,7 +972,7 @@ while (true) {
# On newline, output non-matching lines as INFO
if [[ \$char == \$'\\n' ]]; then
- if [[ ! \$dot_mode && -n \$line_buffer ]]; then
+ if [[ ! \$dot_mode && \$line_buffer ]]; then
printf 'INFO|%s\\n' "\$line_buffer"
fi
line_buffer=""
@@ -1047,9 +1038,17 @@ while (true) {
total_bytes=\$(du -sb $escaped_basenames 2>/dev/null | awk '{sum+=\$1} END {print sum}')
echo "TOTAL|\$total_bytes"
record_size=10240
- checkpoint=\$((total_bytes / record_size / 50))
- [[ \$checkpoint -lt 1 ]] && checkpoint=1
- tar $tar_flags "$archive_tmp" --record-size=\$record_size --checkpoint=\$checkpoint "--checkpoint-action=exec=printf 'DOT|%s\\n' \\\$(date +%s) >> $status" -- $escaped_basenames | awk '{printf "FILE|%d|0|%s\\n", systime(), \$0; fflush()}'
+ dot_size=\$(($dot_mb * 1024 * 1024)) # COMPRESS_DOT_SIZE_MB MB per dot
+ checkpoint=\$((dot_size / record_size))
+ tar_args=(
+ $tar_flags
+ "$archive_tmp"
+ --record-size=\$record_size
+ --checkpoint=\$checkpoint
+ "--checkpoint-action=exec=printf 'DOT|%s\\n' \\\$(date +%s) >> $status"
+ -- $escaped_basenames
+ )
+ tar "\${tar_args[@]}" | awk '{printf "FILE|%d|0|%s\\n", systime(), \$0; fflush()}'
tar_exit=\${PIPESTATUS[0]}
[[ \$tar_exit -eq 0 ]] && mv "$archive_tmp" "$archive_path" || rm -f "$archive_tmp"
} >$status 2>$error & echo \$!
From e59548bc0d01e84fe04a0dd9e1b1f232f88906ca Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 22 Mar 2026 17:32:55 +0100
Subject: [PATCH 042/173] Add tar.bz2 and tar.xz to compress format dropdown;
move $pid init before loop
---
emhttp/plugins/dynamix/include/Templates.php | 2 ++
emhttp/plugins/dynamix/nchan/file_manager | 2 +-
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/emhttp/plugins/dynamix/include/Templates.php b/emhttp/plugins/dynamix/include/Templates.php
index 118b3bdc87..b1ff06c9f5 100644
--- a/emhttp/plugins/dynamix/include/Templates.php
+++ b/emhttp/plugins/dynamix/include/Templates.php
@@ -373,6 +373,8 @@ function getMode(file){
+
+
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 6646ea47ef..0ad4e91336 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -558,13 +558,13 @@ if (!file_exists($empty_dir)) {
// initialize $delete_empty_dirs state: null = not a move operation (yet), true = rsync copy-delete phase, false = cleanup phase (done)
$delete_empty_dirs = null;
+$pid = false;
$last_published_status = null;
// infinite loop to monitor and execute file operations
// Note: exec() uses /bin/sh which is symlinked to bash in unraid and a requirement for process substitution syntax >(...)
while (true) {
unset($action, $source, $target, $H, $sparse, $exist, $zfs, $format, $archive_name);
- if (!isset($pid)) $pid = false;
// read job parameters from JSON file: $action, $title, $source, $target, $H, $sparse, $exist, $zfs (set by emhttp/plugins/dynamix/include/Control.php)
if (file_exists($active)) {
From 6334a0da7a6c6c765cc9770bdbc26e168a6e0476 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 22 Mar 2026 19:22:39 +0100
Subject: [PATCH 043/173] Debug: add logging to extract case 17
---
emhttp/plugins/dynamix/nchan/file_manager | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 0ad4e91336..d8ebc1327c 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -1075,10 +1075,13 @@ while (true) {
// TODO: Add "Overwrite existing files" option for extract
// Check tar/unzip/7z/unrar options: tar has --skip-existing, unzip has -n (never overwrite), etc.
// Let user decide whether to overwrite or skip existing files
-
+
$archive = $source[0];
$dest = rtrim($target, '/');
+ exec('logger -t file_manager "extract start: archive=' . escapeshellarg($archive) . ', dest=' . escapeshellarg($dest) . '"');
+ exec('logger -t file_manager "extract start: quoted_archive=' . escapeshellarg(quoted($archive)) . ', quoted_dest=' . escapeshellarg(quoted($dest)) . '"');
+
// Create destination directory if it doesn't exist
exec("mkdir -p ".quoted($dest));
@@ -1105,7 +1108,9 @@ while (true) {
$cmd .= "unzip ".quoted($archive)." 2>&1 | tee $status";
}
$cmd .= " & echo \$!";
+ exec('logger -t file_manager "extract cmd=' . escapeshellarg($cmd) . '"');
exec($cmd, $pid);
+ exec('logger -t file_manager "extract pid=' . escapeshellarg(json_encode($pid)) . '"');
}
break;
case 99: // kill running background process
From b1c2edf5f5cd9af21051a8b18faa5ac2a1dd1773 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 22 Mar 2026 19:22:48 +0100
Subject: [PATCH 044/173] Hide tar.bz2 in compress dropdown (too unpopular)
---
emhttp/plugins/dynamix/include/Templates.php | 2 ++
1 file changed, 2 insertions(+)
diff --git a/emhttp/plugins/dynamix/include/Templates.php b/emhttp/plugins/dynamix/include/Templates.php
index b1ff06c9f5..3d7a30ade4 100644
--- a/emhttp/plugins/dynamix/include/Templates.php
+++ b/emhttp/plugins/dynamix/include/Templates.php
@@ -373,7 +373,9 @@ function getMode(file){
+
From 9bcb37bfaf7d76b1279ac0a524ed9010484e28f1 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 22 Mar 2026 19:27:25 +0100
Subject: [PATCH 045/173] Fix extract: use >status redirect instead of tee to
prevent exec() blocking
---
emhttp/plugins/dynamix/nchan/file_manager | 20 +++++++++++---------
1 file changed, 11 insertions(+), 9 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index d8ebc1327c..659c68e86e 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -1086,26 +1086,28 @@ while (true) {
exec("mkdir -p ".quoted($dest));
// Detect archive type and extract accordingly
+ // note: redirect to >$status 2>&1 (not tee) so PHP exec() only captures echo $! (the PID)
+ // using tee would pipe output back to PHP which blocks exec() until the process finishes
$cmd = "cd ".quoted($dest)." && ";
if (preg_match('/\.(tar\.gz|tgz)$/i', $archive)) {
- $cmd .= "tar -xzvf ".quoted($archive)." 2>&1 | tee $status";
+ $cmd .= "tar -xzvf ".quoted($archive)." >$status 2>&1";
} elseif (preg_match('/\.(tar\.bz2|tbz2)$/i', $archive)) {
- $cmd .= "tar -xjvf ".quoted($archive)." 2>&1 | tee $status";
+ $cmd .= "tar -xjvf ".quoted($archive)." >$status 2>&1";
} elseif (preg_match('/\.(tar\.xz|txz)$/i', $archive)) {
- $cmd .= "tar -xJvf ".quoted($archive)." 2>&1 | tee $status";
+ $cmd .= "tar -xJvf ".quoted($archive)." >$status 2>&1";
} elseif (preg_match('/\.(tar\.zst)$/i', $archive)) {
- $cmd .= "tar -I zstd -xvf ".quoted($archive)." 2>&1 | tee $status";
+ $cmd .= "tar -I zstd -xvf ".quoted($archive)." >$status 2>&1";
} elseif (preg_match('/\.tar$/i', $archive)) {
- $cmd .= "tar -xvf ".quoted($archive)." 2>&1 | tee $status";
+ $cmd .= "tar -xvf ".quoted($archive)." >$status 2>&1";
} elseif (preg_match('/\.zip$/i', $archive)) {
- $cmd .= "unzip ".quoted($archive)." 2>&1 | tee $status";
+ $cmd .= "unzip ".quoted($archive)." >$status 2>&1";
} elseif (preg_match('/\.7z$/i', $archive)) {
- $cmd .= "7z x ".quoted($archive)." 2>&1 | tee $status";
+ $cmd .= "7z x ".quoted($archive)." >$status 2>&1";
} elseif (preg_match('/\.rar$/i', $archive)) {
- $cmd .= "unrar x ".quoted($archive)." 2>&1 | tee $status";
+ $cmd .= "unrar x ".quoted($archive)." >$status 2>&1";
} else {
// Try to detect by file command as fallback
- $cmd .= "unzip ".quoted($archive)." 2>&1 | tee $status";
+ $cmd .= "unzip ".quoted($archive)." >$status 2>&1";
}
$cmd .= " & echo \$!";
exec('logger -t file_manager "extract cmd=' . escapeshellarg($cmd) . '"');
From cfadc20f89ad012ab0d9e4435a16c80a136a2838 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 22 Mar 2026 19:50:46 +0100
Subject: [PATCH 046/173] Fix extract: redirect stderr to error file so
failures are surfaced to user
---
emhttp/plugins/dynamix/nchan/file_manager | 22 +++++++++++-----------
1 file changed, 11 insertions(+), 11 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 659c68e86e..964daf5679 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -1086,28 +1086,28 @@ while (true) {
exec("mkdir -p ".quoted($dest));
// Detect archive type and extract accordingly
- // note: redirect to >$status 2>&1 (not tee) so PHP exec() only captures echo $! (the PID)
- // using tee would pipe output back to PHP which blocks exec() until the process finishes
+ // note: redirect stdout to $status, stderr to $error so PHP exec() only captures echo $! (the PID)
+ // and so errors are surfaced by the end-of-job handler which reads $error
$cmd = "cd ".quoted($dest)." && ";
if (preg_match('/\.(tar\.gz|tgz)$/i', $archive)) {
- $cmd .= "tar -xzvf ".quoted($archive)." >$status 2>&1";
+ $cmd .= "tar -xzvf ".quoted($archive)." >$status 2>$error";
} elseif (preg_match('/\.(tar\.bz2|tbz2)$/i', $archive)) {
- $cmd .= "tar -xjvf ".quoted($archive)." >$status 2>&1";
+ $cmd .= "tar -xjvf ".quoted($archive)." >$status 2>$error";
} elseif (preg_match('/\.(tar\.xz|txz)$/i', $archive)) {
- $cmd .= "tar -xJvf ".quoted($archive)." >$status 2>&1";
+ $cmd .= "tar -xJvf ".quoted($archive)." >$status 2>$error";
} elseif (preg_match('/\.(tar\.zst)$/i', $archive)) {
- $cmd .= "tar -I zstd -xvf ".quoted($archive)." >$status 2>&1";
+ $cmd .= "tar -I zstd -xvf ".quoted($archive)." >$status 2>$error";
} elseif (preg_match('/\.tar$/i', $archive)) {
- $cmd .= "tar -xvf ".quoted($archive)." >$status 2>&1";
+ $cmd .= "tar -xvf ".quoted($archive)." >$status 2>$error";
} elseif (preg_match('/\.zip$/i', $archive)) {
- $cmd .= "unzip ".quoted($archive)." >$status 2>&1";
+ $cmd .= "unzip ".quoted($archive)." >$status 2>$error";
} elseif (preg_match('/\.7z$/i', $archive)) {
- $cmd .= "7z x ".quoted($archive)." >$status 2>&1";
+ $cmd .= "7z x ".quoted($archive)." >$status 2>$error";
} elseif (preg_match('/\.rar$/i', $archive)) {
- $cmd .= "unrar x ".quoted($archive)." >$status 2>&1";
+ $cmd .= "unrar x ".quoted($archive)." >$status 2>$error";
} else {
// Try to detect by file command as fallback
- $cmd .= "unzip ".quoted($archive)." >$status 2>&1";
+ $cmd .= "unzip ".quoted($archive)." >$status 2>$error";
}
$cmd .= " & echo \$!";
exec('logger -t file_manager "extract cmd=' . escapeshellarg($cmd) . '"');
From 94725de76ac01922b3a88b8c8c2323be6bd099e8 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 22 Mar 2026 20:05:24 +0100
Subject: [PATCH 047/173] Add extract progress: structured FILE/DOT/TOTAL log
format like compress
---
emhttp/plugins/dynamix/nchan/file_manager | 114 ++++++++++++++++------
1 file changed, 82 insertions(+), 32 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 964daf5679..ffd4b1b6a4 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -1064,53 +1064,103 @@ while (true) {
// return status of running action
if (!empty($pid)) {
- $file_line = exec("tail -1 $status");
- $reply['status'] = json_encode([
- 'action' => $action,
- 'text' => [_('Extracting').'... '.mb_strimhalf($file_line, 70, '...')]
- ]);
+ $text = parse_compress_progress($status, _('Extracting'), COMPRESS_DOT_SIZE_MB);
+ if (!empty($text)) {
+ $reply['status'] = json_encode([
+ 'action' => $action,
+ 'text' => $text
+ ]);
+ }
// start action
} else {
// TODO: Add "Overwrite existing files" option for extract
- // Check tar/unzip/7z/unrar options: tar has --skip-existing, unzip has -n (never overwrite), etc.
- // Let user decide whether to overwrite or skip existing files
$archive = $source[0];
$dest = rtrim($target, '/');
+ $dot_mb = COMPRESS_DOT_SIZE_MB;
exec('logger -t file_manager "extract start: archive=' . escapeshellarg($archive) . ', dest=' . escapeshellarg($dest) . '"');
exec('logger -t file_manager "extract start: quoted_archive=' . escapeshellarg(quoted($archive)) . ', quoted_dest=' . escapeshellarg(quoted($dest)) . '"');
- // Create destination directory if it doesn't exist
exec("mkdir -p ".quoted($dest));
- // Detect archive type and extract accordingly
- // note: redirect stdout to $status, stderr to $error so PHP exec() only captures echo $! (the PID)
- // and so errors are surfaced by the end-of-job handler which reads $error
- $cmd = "cd ".quoted($dest)." && ";
- if (preg_match('/\.(tar\.gz|tgz)$/i', $archive)) {
- $cmd .= "tar -xzvf ".quoted($archive)." >$status 2>$error";
- } elseif (preg_match('/\.(tar\.bz2|tbz2)$/i', $archive)) {
- $cmd .= "tar -xjvf ".quoted($archive)." >$status 2>$error";
- } elseif (preg_match('/\.(tar\.xz|txz)$/i', $archive)) {
- $cmd .= "tar -xJvf ".quoted($archive)." >$status 2>$error";
- } elseif (preg_match('/\.(tar\.zst)$/i', $archive)) {
- $cmd .= "tar -I zstd -xvf ".quoted($archive)." >$status 2>$error";
- } elseif (preg_match('/\.tar$/i', $archive)) {
- $cmd .= "tar -xvf ".quoted($archive)." >$status 2>$error";
- } elseif (preg_match('/\.zip$/i', $archive)) {
- $cmd .= "unzip ".quoted($archive)." >$status 2>$error";
- } elseif (preg_match('/\.7z$/i', $archive)) {
- $cmd .= "7z x ".quoted($archive)." >$status 2>$error";
- } elseif (preg_match('/\.rar$/i', $archive)) {
- $cmd .= "unrar x ".quoted($archive)." >$status 2>$error";
+ // Reset progress parser state for new extract operation (shares static vars with compress)
+ parse_compress_progress(null, null, COMPRESS_DOT_SIZE_MB, true);
+
+ if (preg_match('/\.zip$/i', $archive)) {
+ // ZIP: parse manifest for file sizes + total, then extract with structured FILE lines.
+ // Uses process substitution (< <(...)) so file_sizes associative array is visible in the while loop.
+ // Stderr of unzip -l suppressed to avoid duplicate error messages; extract errors go to $error.
+ $cmd = <</dev/null)
+ printf 'TOTAL|%s\\n' "\$total_bytes"
+
+ while IFS= read -r line; do
+ if [[ \$line =~ ^[[:space:]]*(inflating|extracting|creating|linking):[[:space:]]+(.+)\$ ]]; then
+ filename="\${BASH_REMATCH[2]}"
+ filename="\${filename%"\${filename##*[! ]}"}"
+ size="\${file_sizes[\$filename]:-0}"
+ printf 'FILE|%(%s)T|%s|%s\\n' -1 "\$size" "\$filename"
+ fi
+ done < <(unzip "$archive")
+ } >$status 2>$error & echo \$!
+ BASH;
+
} else {
- // Try to detect by file command as fallback
- $cmd .= "unzip ".quoted($archive)." >$status 2>$error";
+ // TAR variants: checkpoint-based DOT lines + awk-based FILE lines (no TOTAL: would require
+ // full decompression pass to sum uncompressed sizes, which doubles the work)
+ if (preg_match('/\.(tar\.gz|tgz)$/i', $archive)) {
+ $extract_tar_flags = '-xzvf';
+ } elseif (preg_match('/\.(tar\.bz2|tbz2)$/i', $archive)) {
+ $extract_tar_flags = '-xjvf';
+ } elseif (preg_match('/\.(tar\.xz|txz)$/i', $archive)) {
+ $extract_tar_flags = '-xJvf';
+ } elseif (preg_match('/\.(tar\.zst)$/i', $archive)) {
+ $extract_tar_flags = '--use-compress-program=zstd -xvf';
+ } elseif (preg_match('/\.tar$/i', $archive)) {
+ $extract_tar_flags = '-xvf';
+ }
+
+ if (isset($extract_tar_flags)) {
+ $cmd = <<> $status"
+ )
+ tar "\${tar_args[@]}" | awk '{printf "FILE|%d|0|%s\\n", systime(), \$0; fflush()}'
+ tar_exit=\${PIPESTATUS[0]}
+ exit \$tar_exit
+ } >$status 2>$error & echo \$!
+ BASH;
+ } elseif (preg_match('/\.7z$/i', $archive)) {
+ $cmd = "cd ".quoted($dest)." && 7z x ".quoted($archive)." >$status 2>$error & echo \$!";
+ } elseif (preg_match('/\.rar$/i', $archive)) {
+ $cmd = "cd ".quoted($dest)." && unrar x ".quoted($archive)." >$status 2>$error & echo \$!";
+ } else {
+ $cmd = "cd ".quoted($dest)." && unzip ".quoted($archive)." >$status 2>$error & echo \$!";
+ }
}
- $cmd .= " & echo \$!";
- exec('logger -t file_manager "extract cmd=' . escapeshellarg($cmd) . '"');
+
+ exec('logger -t file_manager "extract cmd=' . escapeshellarg(substr($cmd, 0, 200)) . '"');
exec($cmd, $pid);
exec('logger -t file_manager "extract pid=' . escapeshellarg(json_encode($pid)) . '"');
}
From dce115032c454b99992a06673d65cc7e537bb520 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 22 Mar 2026 20:14:09 +0100
Subject: [PATCH 048/173] Fix extract progress: unzip -n (no overwrite prompt),
tar TOTAL pre-scan, status debug copy
---
emhttp/plugins/dynamix/nchan/file_manager | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index ffd4b1b6a4..05bdcefdbd 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -1113,7 +1113,7 @@ while (true) {
size="\${file_sizes[\$filename]:-0}"
printf 'FILE|%(%s)T|%s|%s\\n' -1 "\$size" "\$filename"
fi
- done < <(unzip "$archive")
+ done < <(unzip -n "$archive")
} >$status 2>$error & echo \$!
BASH;
@@ -1136,6 +1136,8 @@ while (true) {
$cmd = <</dev/null | awk 'NF>=5 {sum+=\$3} END {print sum+0}')
+ printf 'TOTAL|%s\\n' "\$total_bytes"
record_size=10240
dot_size=\$(($dot_mb * 1024 * 1024))
checkpoint=\$((dot_size / record_size))
@@ -1218,9 +1220,9 @@ while (true) {
if (file_exists($error)) $reply['error'] = trim(file_get_contents($error));
- // Debug: Copy status file before deletion for action 16 (compress)
- if ($action == 16 && file_exists($status)) {
- $debug_copy = '/tmp/status-debug-compress.txt';
+ // Debug: Copy status file before deletion (compress and extract)
+ if (($action == 16 || $action == 17) && file_exists($status)) {
+ $debug_copy = $action == 16 ? '/tmp/status-debug-compress.txt' : '/tmp/status-debug-extract.txt';
copy($status, $debug_copy);
exec('logger -t file_manager "Status file copied to ' . $debug_copy . ' before deletion"');
}
From ad78031aa91ab25aff0348f28989372722d46f28 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 22 Mar 2026 20:28:58 +0100
Subject: [PATCH 049/173] Fix extract ZIP progress: correct date regex
MM-DD-YYYY for unzip -l output
---
emhttp/plugins/dynamix/nchan/file_manager | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 05bdcefdbd..b4c5539f06 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -1099,7 +1099,7 @@ while (true) {
declare -A file_sizes
total_bytes=0
while IFS= read -r line; do
- if [[ \$line =~ ^[[:space:]]+([0-9]+)[[:space:]]+[0-9]{4}-[0-9]{2}-[0-9]{2}[[:space:]]+[0-9]{2}:[0-9]{2}[[:space:]]+(.+)\$ ]]; then
+ if [[ \$line =~ ^[[:space:]]+([0-9]+)[[:space:]]+[0-9]{2}-[0-9]{2}-[0-9]{4}[[:space:]]+[0-9]{2}:[0-9]{2}[[:space:]]+(.+)\$ ]]; then
file_sizes["\${BASH_REMATCH[2]}"]=\"\${BASH_REMATCH[1]}\"
total_bytes=\$(( total_bytes + BASH_REMATCH[1] ))
fi
From d875b768f91040860fe528b88a4ef460df3b13aa Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 22 Mar 2026 20:38:07 +0100
Subject: [PATCH 050/173] Fix extract ZIP progress: remove escaped quotes in
assoc array assignment, use -o for overwrite
---
emhttp/plugins/dynamix/nchan/file_manager | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index b4c5539f06..acf1fcce33 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -1100,7 +1100,7 @@ while (true) {
total_bytes=0
while IFS= read -r line; do
if [[ \$line =~ ^[[:space:]]+([0-9]+)[[:space:]]+[0-9]{2}-[0-9]{2}-[0-9]{4}[[:space:]]+[0-9]{2}:[0-9]{2}[[:space:]]+(.+)\$ ]]; then
- file_sizes["\${BASH_REMATCH[2]}"]=\"\${BASH_REMATCH[1]}\"
+ file_sizes["\${BASH_REMATCH[2]}"]="\${BASH_REMATCH[1]}"
total_bytes=\$(( total_bytes + BASH_REMATCH[1] ))
fi
done < <(unzip -l "$archive" 2>/dev/null)
@@ -1113,7 +1113,7 @@ while (true) {
size="\${file_sizes[\$filename]:-0}"
printf 'FILE|%(%s)T|%s|%s\\n' -1 "\$size" "\$filename"
fi
- done < <(unzip -n "$archive")
+ done < <(unzip -o "$archive")
} >$status 2>$error & echo \$!
BASH;
From 8eca4522f2fe1d4298d2455f166dd75daec3bc2c Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 22 Mar 2026 20:45:22 +0100
Subject: [PATCH 051/173] Fix extract ZIP progress: count files without leading
spaces in TOTAL, add replacing keyword
---
emhttp/plugins/dynamix/nchan/file_manager | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index acf1fcce33..67dc2ae87b 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -1099,7 +1099,7 @@ while (true) {
declare -A file_sizes
total_bytes=0
while IFS= read -r line; do
- if [[ \$line =~ ^[[:space:]]+([0-9]+)[[:space:]]+[0-9]{2}-[0-9]{2}-[0-9]{4}[[:space:]]+[0-9]{2}:[0-9]{2}[[:space:]]+(.+)\$ ]]; then
+ if [[ \$line =~ ^[[:space:]]*([0-9]+)[[:space:]]+[0-9]{2}-[0-9]{2}-[0-9]{4}[[:space:]]+[0-9]{2}:[0-9]{2}[[:space:]]+(.+)\$ ]]; then
file_sizes["\${BASH_REMATCH[2]}"]="\${BASH_REMATCH[1]}"
total_bytes=\$(( total_bytes + BASH_REMATCH[1] ))
fi
@@ -1107,7 +1107,7 @@ while (true) {
printf 'TOTAL|%s\\n' "\$total_bytes"
while IFS= read -r line; do
- if [[ \$line =~ ^[[:space:]]*(inflating|extracting|creating|linking):[[:space:]]+(.+)\$ ]]; then
+ if [[ \$line =~ ^[[:space:]]*(inflating|extracting|creating|linking|replacing):[[:space:]]+(.+)\$ ]]; then
filename="\${BASH_REMATCH[2]}"
filename="\${filename%"\${filename##*[! ]}"}"
size="\${file_sizes[\$filename]:-0}"
From 9899b981eea670c717e343eba64a8edab99b9cbd Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 22 Mar 2026 20:47:19 +0100
Subject: [PATCH 052/173] Fix extract ZIP: remove unverified unzip keywords
(linking, replacing not produced by unzip -o on atum)
---
emhttp/plugins/dynamix/nchan/file_manager | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 67dc2ae87b..498e4c883b 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -1107,7 +1107,7 @@ while (true) {
printf 'TOTAL|%s\\n' "\$total_bytes"
while IFS= read -r line; do
- if [[ \$line =~ ^[[:space:]]*(inflating|extracting|creating|linking|replacing):[[:space:]]+(.+)\$ ]]; then
+ if [[ \$line =~ ^[[:space:]]*(inflating|extracting|creating):[[:space:]]+(.+)\$ ]]; then
filename="\${BASH_REMATCH[2]}"
filename="\${filename%"\${filename##*[! ]}"}"
size="\${file_sizes[\$filename]:-0}"
From 7016bd72ec13cc28c60496242aef88386fee2d51 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 22 Mar 2026 21:13:35 +0100
Subject: [PATCH 053/173] Fix ZIP extract progress: parallel manifest-based
poller instead of post-inflating stat
---
emhttp/plugins/dynamix/nchan/file_manager | 82 +++++++++++++++++++----
1 file changed, 69 insertions(+), 13 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 498e4c883b..9b1a74f86e 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -182,6 +182,7 @@ function parse_compress_progress($status, $action_label, $dot_size_mb = 100, $re
static $current_file_name = '';
static $current_file_size = 0;
static $current_file_dots = 0;
+ static $current_file_exact_bytes = null; // set by BYTES_DONE (ZIP extract polling)
static $progress_events = []; // Array of [timestamp, bytes_processed] for speed calculation
static $last_eta_seconds = null;
static $info_messages = [];
@@ -194,6 +195,7 @@ function parse_compress_progress($status, $action_label, $dot_size_mb = 100, $re
$current_file_name = '';
$current_file_size = 0;
$current_file_dots = 0;
+ $current_file_exact_bytes = null;
$progress_events = [];
$last_eta_seconds = null;
$info_messages = [];
@@ -250,11 +252,20 @@ function parse_compress_progress($status, $action_label, $dot_size_mb = 100, $re
$current_file_size = size_to_bytes($parts[2]);
$current_file_name = $parts[3] ?? '';
$current_file_dots = 0;
+ $current_file_exact_bytes = null;
// Record progress event: new file starts, no progress on this file yet
$progress_events[] = [$timestamp, $processed_bytes];
break;
+ case 'BYTES_DONE':
+ // Polled byte count for current file (ZIP extract: stat on growing output file)
+ $timestamp = intval($parts[1]);
+ $file_bytes = min(floatval($parts[2]), $current_file_size > 0 ? $current_file_size : PHP_FLOAT_MAX);
+ $current_file_exact_bytes = $file_bytes;
+ $progress_events[] = [$timestamp, $processed_bytes + $file_bytes];
+ break;
+
case 'DOT':
// Progress dot for current file
$timestamp = intval($parts[1]);
@@ -305,9 +316,13 @@ function parse_compress_progress($status, $action_label, $dot_size_mb = 100, $re
}
}
- // Current progress = completed files + progress of current file via DOTs
- $dots_bytes = $current_file_dots * $dot_size_mb * 1024 * 1024;
- $current_file_progress_bytes = $dots_bytes;
+ // Current progress = completed files + progress of current file
+ // BYTES_DONE (ZIP extract polling) takes priority over DOT-based estimation
+ if ($current_file_exact_bytes !== null) {
+ $current_file_progress_bytes = $current_file_exact_bytes;
+ } else {
+ $current_file_progress_bytes = $current_file_dots * $dot_size_mb * 1024 * 1024;
+ }
$current_progress_bytes = $processed_bytes + $current_file_progress_bytes;
@@ -1092,28 +1107,69 @@ while (true) {
// ZIP: parse manifest for file sizes + total, then extract with structured FILE lines.
// Uses process substitution (< <(...)) so file_sizes associative array is visible in the while loop.
// Stderr of unzip -l suppressed to avoid duplicate error messages; extract errors go to $error.
+ // Progress strategy: unzip has no --dot-size equivalent; it only prints "inflating: file" AFTER
+ // the file is fully written. So we use a parallel poller that watches the manifest order and
+ // polls the growing output file with stat every 0.5s to emit BYTES_DONE progress lines.
$cmd = <</dev/null)
printf 'TOTAL|%s\\n' "\$total_bytes"
- while IFS= read -r line; do
- if [[ \$line =~ ^[[:space:]]*(inflating|extracting|creating):[[:space:]]+(.+)\$ ]]; then
- filename="\${BASH_REMATCH[2]}"
- filename="\${filename%"\${filename##*[! ]}"}"
- size="\${file_sizes[\$filename]:-0}"
- printf 'FILE|%(%s)T|%s|%s\\n' -1 "\$size" "\$filename"
- fi
- done < <(unzip -o "$archive")
+ # Parallel poller: emit FILE + BYTES_DONE ahead of unzip output based on manifest order
+ (
+ idx=0
+ total_files=\${#file_order[@]}
+ while [[ \$idx -lt \$total_files ]]; do
+ fname="\${file_order[\$idx]}"
+ fsize="\${file_sizes[\$fname]:-0}"
+ fpath="$dest/\$fname"
+
+ # Wait until file appears (unzip started writing it)
+ while [[ ! -e "\$fpath" ]]; do
+ sleep 0.2
+ # Abort if status file has been deleted (operation cancelled)
+ [[ ! -e "$status" ]] && exit 0
+ done
+
+ # Emit FILE line once file appears
+ printf 'FILE|%(%s)T|%s|%s\\n' -1 "\$fsize" "\$fname"
+
+ # Poll size for files larger than 10MB
+ if [[ \$fsize -gt 10485760 ]]; then
+ prev_size=0
+ while true; do
+ sleep 0.5
+ [[ ! -e "$status" ]] && exit 0
+ cur_size=\$(stat -c%s "\$fpath" 2>/dev/null || echo 0)
+ # Stop polling when file stopped growing (complete)
+ [[ \$cur_size -le \$prev_size ]] && break
+ printf 'BYTES_DONE|%(%s)T|%s\\n' -1 "\$cur_size"
+ prev_size=\$cur_size
+ done
+ fi
+
+ idx=\$(( idx + 1 ))
+ done
+ ) &
+ poller_pid=\$!
+
+ unzip -o "$archive" >/dev/null
+ unzip_exit=\$?
+ wait \$poller_pid 2>/dev/null
+ exit \$unzip_exit
} >$status 2>$error & echo \$!
BASH;
From 5e789073640d273a7a16387f0ad6b9f95dca16db Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Sun, 3 May 2026 23:16:47 +0200
Subject: [PATCH 054/173] Fix ZIP extract: parallel arrays for manifest parser,
^J newline handling in filenames
---
emhttp/plugins/dynamix/nchan/file_manager | 41 ++++++++++++++---------
1 file changed, 26 insertions(+), 15 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 9b1a74f86e..2cd8194ec3 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -1114,47 +1114,57 @@ while (true) {
{
cd "$dest" || exit 1
- declare -A file_sizes
- declare -a file_order
+ # Parallel arrays: file_names[i] and file_sizes[i] hold name and uncompressed size
+ # for each entry in manifest order (same order unzip writes files to disk).
+ declare -a file_names
+ declare -a file_sizes
total_bytes=0
+
+ # Parse unzip -l manifest: columns are "size date time name"
+ # Date format is MM-DD-YYYY (American), so regex is [0-9]{2}-[0-9]{2}-[0-9]{4}.
+ # Large files fill the size column without leading spaces, so [[:space:]]* not +.
while IFS= read -r line; do
if [[ \$line =~ ^[[:space:]]*([0-9]+)[[:space:]]+[0-9]{2}-[0-9]{2}-[0-9]{4}[[:space:]]+[0-9]{2}:[0-9]{2}[[:space:]]+(.+)\$ ]]; then
- name="\${BASH_REMATCH[2]}"
- size="\${BASH_REMATCH[1]}"
- file_sizes["\$name"]="\$size"
- file_order+=("\$name")
- total_bytes=\$(( total_bytes + size ))
+ file_sizes+=("\${BASH_REMATCH[1]}")
+ # unzip -l replaces literal newlines in filenames with ^J; unzip drops them on extract
+ fname="\${BASH_REMATCH[2]//^J/}"
+ file_names+=("\$fname")
+ total_bytes=\$(( total_bytes + BASH_REMATCH[1] ))
fi
done < <(unzip -l "$archive" 2>/dev/null)
printf 'TOTAL|%s\\n' "\$total_bytes"
- # Parallel poller: emit FILE + BYTES_DONE ahead of unzip output based on manifest order
+ # Parallel poller subshell: unzip only emits "inflating: file" AFTER each file is
+ # fully written, so we cannot rely on its output for progress. Instead, we watch the
+ # destination directory in manifest order: once a file appears, emit FILE| and then
+ # poll its size with stat every 0.5s to emit BYTES_DONE| while it is growing.
(
idx=0
- total_files=\${#file_order[@]}
+ total_files=\${#file_names[@]}
while [[ \$idx -lt \$total_files ]]; do
- fname="\${file_order[\$idx]}"
- fsize="\${file_sizes[\$fname]:-0}"
+ fname="\${file_names[\$idx]}"
+ fsize="\${file_sizes[\$idx]:-0}"
fpath="$dest/\$fname"
# Wait until file appears (unzip started writing it)
while [[ ! -e "\$fpath" ]]; do
sleep 0.2
- # Abort if status file has been deleted (operation cancelled)
+ # Abort if status file gone (operation cancelled via case 99)
[[ ! -e "$status" ]] && exit 0
done
- # Emit FILE line once file appears
+ # Emit FILE line so the progress parser knows which file is active
printf 'FILE|%(%s)T|%s|%s\\n' -1 "\$fsize" "\$fname"
- # Poll size for files larger than 10MB
+ # For large files (>10MB): poll growing size to show % + Speed + ETA.
+ # Small files finish too fast for polling to be useful.
if [[ \$fsize -gt 10485760 ]]; then
prev_size=0
while true; do
sleep 0.5
[[ ! -e "$status" ]] && exit 0
cur_size=\$(stat -c%s "\$fpath" 2>/dev/null || echo 0)
- # Stop polling when file stopped growing (complete)
+ # File stopped growing: unzip finished writing it
[[ \$cur_size -le \$prev_size ]] && break
printf 'BYTES_DONE|%(%s)T|%s\\n' -1 "\$cur_size"
prev_size=\$cur_size
@@ -1166,6 +1176,7 @@ while (true) {
) &
poller_pid=\$!
+ # Run unzip; suppress stdout (progress comes from poller), errors go to $error
unzip -o "$archive" >/dev/null
unzip_exit=\$?
wait \$poller_pid 2>/dev/null
From 0637e182ff0389361b14e64332c600e9e01b856d Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Tue, 5 May 2026 10:41:40 +0200
Subject: [PATCH 055/173] Add Extract button to toolbar: enabled only when
exactly one archive file is selected
---
emhttp/plugins/dynamix/Browse.page | 29 +++++++++++++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index 01ac1aae2b..277a8d37ed 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -236,12 +236,14 @@ function selectAll() {
$('input.rename').prop('disabled',false);
+ $('input.extract').prop('disabled',true); // multiple selected: no archive detection possible
} else {
$('i[id^="check_"]:visible').removeClass('fa-check-square-o').addClass('fa-square-o');
$('input.extra').prop('disabled',true);
$('input.rename').prop('disabled',true);
+ $('input.extract').prop('disabled',true);
}
}
@@ -263,6 +265,18 @@ function selectOne(id, check=true) {
$('input.rename').prop('disabled',checked != 1);
+ // Extract: enabled only for exactly one selected archive file
+ var extractEnabled = false;
+ if (checked == 1) {
+ $('i[id^="check_"]').each(function(){
+ if ($(this).prop('id')!='check_0' && $(this).hasClass('fa-check-square-o')) {
+ var rowId = $(this).prop('id').dfm_fetch('row');
+ var name = $('#'+rowId).attr('data') || '';
+ if (isArchive(name)) extractEnabled = true;
+ }
+ });
+ }
+ $('input.extract').prop('disabled',!extractEnabled);
} else {
$('i[id^="queue_"]').each(function(){if ($(this).hasClass('fa-check-square-o')) checked++;});
$('.ui-dfm .ui-dialog-buttonset button:eq(1)').prop('disabled',checked == 0);
@@ -295,6 +309,12 @@ function fileExtension(file) {
return file.indexOf('.')>=0 ? file.split('.').pop() : '';
}
+function isArchive(name) {
+ var archiveExts = ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar', 'tgz', 'tbz2', 'txz', 'zst'];
+ var ext = fileExtension(name).toLowerCase();
+ return archiveExts.includes(ext) || /\.(tar\.(gz|bz2|xz|zst))$/i.test(name);
+}
+
function updateArchiveName() {
var $dialog = dfm.window;
// Get first source name from data attribute (set when dialog opened)
@@ -1182,6 +1202,14 @@ function doActions(action, title) {
updateArchiveName();
console.log('Bulk compress dialog opened', {sources: source, bulk: bulk});
break;
+ case 17: // extract (toolbar: exactly one archive file selected)
+ dfm.window.html($('#dfm_templateExtract').html());
+ dfm_createSource(source);
+ dfm.window.find('#dfm_target').val(dir).attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).attr('data-pickfilter','SHOW_POPULAR').fileTreeAttach(null,null,function(path){
+ dfm.window.find('#dfm_target').val(path).change();
+ });
+ dfm.window.find('#dfm_target').addClass('dfm-target-with-tree');
+ break;
}
dfm.window.find('#dfm_source').attr('size',Math.min(dfm.tsize[action],source.length));
dfm.window.dialog({
@@ -1632,6 +1660,7 @@ $(window).bind('resize',function(){
+
disabled}?>>
disabled}?>>
From 669a348bc217d4b14f85ed01d9703134acdf5c04 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Tue, 5 May 2026 10:48:54 +0200
Subject: [PATCH 056/173] Fix doActions: calculate root/match for actions >=15
(fileTreeAttach fix)
---
emhttp/plugins/dynamix/Browse.page | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index 277a8d37ed..10c1de5d1e 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -1102,6 +1102,16 @@ function doActions(action, title) {
}
if (u && d) {errorSource(); return;} // disallow mixing of disk and user shares
}
+ // For actions >= 15 with source: calculate root/match for fileTreeAttach
+ if (action >= 15 && source.length > 0) {
+ var path = source[0].substr(1).split('/');
+ var user = /^(user0?|rootshare)$/.test(path[1]);
+ var root = '/'+path[0]+(user ? '/'+path[1] : '');
+ var ud = ['disks','remotes'].includes(path[1]);
+ var match = ud || user ? '' : '^(?!\\/mnt\\/(user0?|rootshare)\\/).*$';
+ var bulk = source.length > 1;
+ var hdlink = "=$var['fuse_directio'] == 1 ? '1' : ''?>";
+ }
dfm.window = $("#dfm_dialogWindow");
switch (action) {
case 0: // create folder
From 69ae890a7fab02c468da8174ee39a19323730a21 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Tue, 5 May 2026 10:51:25 +0200
Subject: [PATCH 057/173] Fix Compress/Extract dialog: enable popular
suggestion + filetree navigation (setupTargetNavigation)
---
emhttp/plugins/dynamix/Browse.page | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index 10c1de5d1e..1e2a9f1931 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -1033,7 +1033,7 @@ function doAction(action, title, id) {
preventFileTreeClose();
// Setup file tree navigation for target input in copy/move dialogs (action 3=copy folder, 4=move folder, 8=copy file, 9=move file)
- if ([3,4,8,9].includes(action) && dfm.window.find('#dfm_target').length) {
+ if ([3,4,8,9,16,17].includes(action) && dfm.window.find('#dfm_target').length) {
setupTargetNavigation();
}
@@ -1388,7 +1388,7 @@ function doActions(action, title) {
preventFileTreeClose();
// Setup file tree navigation for target input in copy/move dialogs (action 3=copy, 4=move)
- if ([3,4].includes(action) && dfm.window.find('#dfm_target').length) {
+ if ([3,4,16,17].includes(action) && dfm.window.find('#dfm_target').length) {
setupTargetNavigation();
}
From 517fb21726cb72b592cd6397037960d116b59d91 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Tue, 5 May 2026 11:07:08 +0200
Subject: [PATCH 058/173] Add overwrite checkbox to Extract dialog; use exist
flag in all extract commands
---
emhttp/plugins/dynamix/include/Templates.php | 8 +++++++-
emhttp/plugins/dynamix/nchan/file_manager | 14 +++++++++-----
2 files changed, 16 insertions(+), 6 deletions(-)
diff --git a/emhttp/plugins/dynamix/include/Templates.php b/emhttp/plugins/dynamix/include/Templates.php
index 3d7a30ade4..32bf76a601 100644
--- a/emhttp/plugins/dynamix/include/Templates.php
+++ b/emhttp/plugins/dynamix/include/Templates.php
@@ -398,7 +398,13 @@ function getMode(file){
:
-:
+:
+
+
+
: _(extract to)_ ...
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 2cd8194ec3..82fcd24bc8 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -1089,7 +1089,8 @@ while (true) {
// start action
} else {
- // TODO: Add "Overwrite existing files" option for extract
+ // $exist is '--ignore-existing' when overwrite checkbox is unchecked (default), '' when checked
+ $no_overwrite = !empty($exist);
$archive = $source[0];
$dest = rtrim($target, '/');
@@ -1177,7 +1178,8 @@ while (true) {
poller_pid=\$!
# Run unzip; suppress stdout (progress comes from poller), errors go to $error
- unzip -o "$archive" >/dev/null
+ # -o = overwrite, -n = never overwrite (keep existing)
+ unzip = $no_overwrite ? '-n' : '-o' ?> "$archive" >/dev/null
unzip_exit=\$?
wait \$poller_pid 2>/dev/null
exit \$unzip_exit
@@ -1200,6 +1202,7 @@ while (true) {
}
if (isset($extract_tar_flags)) {
+ $keep_old_files = $no_overwrite ? '--keep-old-files' : '';
$cmd = <<> $status"
)
tar "\${tar_args[@]}" | awk '{printf "FILE|%d|0|%s\\n", systime(), \$0; fflush()}'
@@ -1221,11 +1225,11 @@ while (true) {
} >$status 2>$error & echo \$!
BASH;
} elseif (preg_match('/\.7z$/i', $archive)) {
- $cmd = "cd ".quoted($dest)." && 7z x ".quoted($archive)." >$status 2>$error & echo \$!";
+ $cmd = "cd ".quoted($dest)." && 7z x ".($no_overwrite ? '-aos ' : '').quoted($archive)." >$status 2>$error & echo \$!";
} elseif (preg_match('/\.rar$/i', $archive)) {
- $cmd = "cd ".quoted($dest)." && unrar x ".quoted($archive)." >$status 2>$error & echo \$!";
+ $cmd = "cd ".quoted($dest)." && unrar x ".($no_overwrite ? '-o- ' : '').quoted($archive)." >$status 2>$error & echo \$!";
} else {
- $cmd = "cd ".quoted($dest)." && unzip ".quoted($archive)." >$status 2>$error & echo \$!";
+ $cmd = "cd ".quoted($dest)." && unzip ".($no_overwrite ? '-n ' : '-o ').quoted($archive)." >$status 2>$error & echo \$!";
}
}
From 4601ff0ea7acee0d2b95e377fc928f72102c23e8 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Tue, 5 May 2026 11:08:57 +0200
Subject: [PATCH 059/173] Fix ZIP extract: use PHP var for overwrite flag
(HEREDOC does not eval PHP tags)
---
emhttp/plugins/dynamix/nchan/file_manager | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 82fcd24bc8..47334e619a 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -1105,6 +1105,7 @@ while (true) {
parse_compress_progress(null, null, COMPRESS_DOT_SIZE_MB, true);
if (preg_match('/\.zip$/i', $archive)) {
+ $unzip_overwrite_flag = $no_overwrite ? '-n' : '-o';
// ZIP: parse manifest for file sizes + total, then extract with structured FILE lines.
// Uses process substitution (< <(...)) so file_sizes associative array is visible in the while loop.
// Stderr of unzip -l suppressed to avoid duplicate error messages; extract errors go to $error.
@@ -1179,7 +1180,7 @@ while (true) {
# Run unzip; suppress stdout (progress comes from poller), errors go to $error
# -o = overwrite, -n = never overwrite (keep existing)
- unzip = $no_overwrite ? '-n' : '-o' ?> "$archive" >/dev/null
+ unzip $unzip_overwrite_flag "$archive" >/dev/null
unzip_exit=\$?
wait \$poller_pid 2>/dev/null
exit \$unzip_exit
From 21191e823d24a4b3ffaa0c84232757493e716ec6 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Tue, 5 May 2026 11:22:40 +0200
Subject: [PATCH 060/173] Fix TAR extract: use --skip-old-files instead of
--keep-old-files (no error on existing files)
---
emhttp/plugins/dynamix/nchan/file_manager | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 47334e619a..4f3130e557 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -1203,7 +1203,7 @@ while (true) {
}
if (isset($extract_tar_flags)) {
- $keep_old_files = $no_overwrite ? '--keep-old-files' : '';
+ $keep_old_files = $no_overwrite ? '--skip-old-files' : '';
$cmd = <<
Date: Tue, 5 May 2026 11:27:23 +0200
Subject: [PATCH 061/173] Fix TAR extract: suppress 'skipping existing file'
warning with --warning=no-existing-file
---
emhttp/plugins/dynamix/nchan/file_manager | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 4f3130e557..8ab47ca222 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -1203,7 +1203,7 @@ while (true) {
}
if (isset($extract_tar_flags)) {
- $keep_old_files = $no_overwrite ? '--skip-old-files' : '';
+ $keep_existing_files = $no_overwrite ? '--skip-old-files --warning=no-existing-file' : '';
$cmd = <<> $status"
)
tar "\${tar_args[@]}" | awk '{printf "FILE|%d|0|%s\\n", systime(), \$0; fflush()}'
From a65c7808c64d0fb5534b7dd2c1811f7876c502af Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Tue, 5 May 2026 11:36:13 +0200
Subject: [PATCH 062/173] Remove 7z extract support (7z binary not available on
Unraid)
---
emhttp/plugins/dynamix/Browse.page | 4 ++--
emhttp/plugins/dynamix/nchan/file_manager | 2 --
2 files changed, 2 insertions(+), 4 deletions(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index 1e2a9f1931..979dbc22de 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -157,7 +157,7 @@ function fileContextMenu(id, button) {
opts.push({text:"_(Compress)_", icon:"fa-file-archive-o", action:function(e){e.preventDefault();doAction(16,"_(Compress)_",id.dfm_proxy());}});
// Check if file is an archive
var fileName = $('#'+id).attr('data');
- var archiveExts = ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar', 'tgz', 'tbz2', 'txz', 'zst'];
+ var archiveExts = ['zip', 'tar', 'gz', 'bz2', 'xz', 'rar', 'tgz', 'tbz2', 'txz', 'zst'];
var ext = fileExtension(fileName).toLowerCase();
if (archiveExts.includes(ext) || fileName.match(/\.(tar\.(gz|bz2|xz|zst))$/i)) {
opts.push({text:"_(Extract)_", icon:"fa-archive", action:function(e){e.preventDefault();doAction(17,"_(Extract)_",id.dfm_proxy());}});
@@ -310,7 +310,7 @@ function fileExtension(file) {
}
function isArchive(name) {
- var archiveExts = ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar', 'tgz', 'tbz2', 'txz', 'zst'];
+var archiveExts = ['zip', 'tar', 'gz', 'bz2', 'xz', 'rar', 'tgz', 'tbz2', 'txz', 'zst'];
var ext = fileExtension(name).toLowerCase();
return archiveExts.includes(ext) || /\.(tar\.(gz|bz2|xz|zst))$/i.test(name);
}
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 8ab47ca222..d485dbbe6b 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -1225,8 +1225,6 @@ while (true) {
exit \$tar_exit
} >$status 2>$error & echo \$!
BASH;
- } elseif (preg_match('/\.7z$/i', $archive)) {
- $cmd = "cd ".quoted($dest)." && 7z x ".($no_overwrite ? '-aos ' : '').quoted($archive)." >$status 2>$error & echo \$!";
} elseif (preg_match('/\.rar$/i', $archive)) {
$cmd = "cd ".quoted($dest)." && unrar x ".($no_overwrite ? '-o- ' : '').quoted($archive)." >$status 2>$error & echo \$!";
} else {
From 54f7f7980bdcfa762d346dea06dab5b96cfd0654 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Tue, 5 May 2026 11:43:56 +0200
Subject: [PATCH 063/173] Remove rar extract support (unrar binary not
available on Unraid)
---
emhttp/plugins/dynamix/Browse.page | 4 ++--
emhttp/plugins/dynamix/nchan/file_manager | 2 --
2 files changed, 2 insertions(+), 4 deletions(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index 979dbc22de..0c2681e53b 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -157,7 +157,7 @@ function fileContextMenu(id, button) {
opts.push({text:"_(Compress)_", icon:"fa-file-archive-o", action:function(e){e.preventDefault();doAction(16,"_(Compress)_",id.dfm_proxy());}});
// Check if file is an archive
var fileName = $('#'+id).attr('data');
- var archiveExts = ['zip', 'tar', 'gz', 'bz2', 'xz', 'rar', 'tgz', 'tbz2', 'txz', 'zst'];
+ var archiveExts = ['zip', 'tar', 'gz', 'bz2', 'xz', 'tgz', 'tbz2', 'txz', 'zst'];
var ext = fileExtension(fileName).toLowerCase();
if (archiveExts.includes(ext) || fileName.match(/\.(tar\.(gz|bz2|xz|zst))$/i)) {
opts.push({text:"_(Extract)_", icon:"fa-archive", action:function(e){e.preventDefault();doAction(17,"_(Extract)_",id.dfm_proxy());}});
@@ -310,7 +310,7 @@ function fileExtension(file) {
}
function isArchive(name) {
-var archiveExts = ['zip', 'tar', 'gz', 'bz2', 'xz', 'rar', 'tgz', 'tbz2', 'txz', 'zst'];
+var archiveExts = ['zip', 'tar', 'gz', 'bz2', 'xz', 'tgz', 'tbz2', 'txz', 'zst'];
var ext = fileExtension(name).toLowerCase();
return archiveExts.includes(ext) || /\.(tar\.(gz|bz2|xz|zst))$/i.test(name);
}
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index d485dbbe6b..c7241ec31a 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -1225,8 +1225,6 @@ while (true) {
exit \$tar_exit
} >$status 2>$error & echo \$!
BASH;
- } elseif (preg_match('/\.rar$/i', $archive)) {
- $cmd = "cd ".quoted($dest)." && unrar x ".($no_overwrite ? '-o- ' : '').quoted($archive)." >$status 2>$error & echo \$!";
} else {
$cmd = "cd ".quoted($dest)." && unzip ".($no_overwrite ? '-n ' : '-o ').quoted($archive)." >$status 2>$error & echo \$!";
}
From 5f8230e1e7846dbc7a7225d12c3e535e3f308182 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Tue, 5 May 2026 13:00:11 +0200
Subject: [PATCH 064/173] Add tar.lz4 compress/extract support (lz4 available
on Unraid)
---
emhttp/plugins/dynamix/Browse.page | 8 ++++----
emhttp/plugins/dynamix/include/Templates.php | 1 +
emhttp/plugins/dynamix/nchan/file_manager | 5 +++++
3 files changed, 10 insertions(+), 4 deletions(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index 0c2681e53b..4a69b5fc63 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -157,9 +157,9 @@ function fileContextMenu(id, button) {
opts.push({text:"_(Compress)_", icon:"fa-file-archive-o", action:function(e){e.preventDefault();doAction(16,"_(Compress)_",id.dfm_proxy());}});
// Check if file is an archive
var fileName = $('#'+id).attr('data');
- var archiveExts = ['zip', 'tar', 'gz', 'bz2', 'xz', 'tgz', 'tbz2', 'txz', 'zst'];
+ var archiveExts = ['zip', 'tar', 'gz', 'bz2', 'xz', 'tgz', 'tbz2', 'txz', 'zst', 'lz4'];
var ext = fileExtension(fileName).toLowerCase();
- if (archiveExts.includes(ext) || fileName.match(/\.(tar\.(gz|bz2|xz|zst))$/i)) {
+ if (archiveExts.includes(ext) || fileName.match(/\.(tar\.(gz|bz2|xz|zst|lz4))$/i)) {
opts.push({text:"_(Extract)_", icon:"fa-archive", action:function(e){e.preventDefault();doAction(17,"_(Extract)_",id.dfm_proxy());}});
}
opts.push({divider:true});
@@ -310,9 +310,9 @@ function fileExtension(file) {
}
function isArchive(name) {
-var archiveExts = ['zip', 'tar', 'gz', 'bz2', 'xz', 'tgz', 'tbz2', 'txz', 'zst'];
+var archiveExts = ['zip', 'tar', 'gz', 'bz2', 'xz', 'tgz', 'tbz2', 'txz', 'zst', 'lz4'];
var ext = fileExtension(name).toLowerCase();
- return archiveExts.includes(ext) || /\.(tar\.(gz|bz2|xz|zst))$/i.test(name);
+ return archiveExts.includes(ext) || /\.(tar\.(gz|bz2|xz|zst|lz4))$/i.test(name);
}
function updateArchiveName() {
diff --git a/emhttp/plugins/dynamix/include/Templates.php b/emhttp/plugins/dynamix/include/Templates.php
index 32bf76a601..e2e545bf0a 100644
--- a/emhttp/plugins/dynamix/include/Templates.php
+++ b/emhttp/plugins/dynamix/include/Templates.php
@@ -378,6 +378,7 @@ function getMode(file){
-->
+
_(Archive name)_:
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index c7241ec31a..32a523bc74 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -1036,6 +1036,9 @@ while (true) {
case 'tar.zst':
$tar_flags = '--use-compress-program=zstd -cvf';
break;
+ case 'tar.lz4':
+ $tar_flags = '--use-compress-program=lz4 -cvf';
+ break;
case 'tar.bz2':
$tar_flags = '-cvjf';
break;
@@ -1198,6 +1201,8 @@ while (true) {
$extract_tar_flags = '-xJvf';
} elseif (preg_match('/\.(tar\.zst)$/i', $archive)) {
$extract_tar_flags = '--use-compress-program=zstd -xvf';
+ } elseif (preg_match('/\.(tar\.lz4)$/i', $archive)) {
+ $extract_tar_flags = '--use-compress-program=lz4 -xvf';
} elseif (preg_match('/\.tar$/i', $archive)) {
$extract_tar_flags = '-xvf';
}
From 941324f285eba04ed237baa14316259c6dc30342 Mon Sep 17 00:00:00 2001
From: mgutt <10757176+mgutt@users.noreply.github.com>
Date: Tue, 5 May 2026 13:23:59 +0200
Subject: [PATCH 065/173] feat: read docker paths from docker.cfg for compress
format auto-detection
- PHP reads DOCKER_APP_CONFIG_PATH and DOCKER_IMAGE_FILE from /boot/config/docker.cfg
- strips /mnt// prefix to match on any disk (user, cache, etc.)
- falls back to ['appdata', 'docker'] when docker.cfg is not configured
- injects subpaths as JS variable dfm_docker_subpaths
- both doAction (context menu) and doActions (toolbar) use the variable
- fixes broken regex in doActions that was missing a / (never matched)
---
emhttp/plugins/dynamix/Browse.page | 28 ++++++++++++++++++++++++----
1 file changed, 24 insertions(+), 4 deletions(-)
diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index 4a69b5fc63..45eb606416 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -38,6 +38,22 @@ $isshare = $root == 'mnt' && (!$main || !$next || ($main == 'rootshare' && !$res
$editor = '/boot/config/editor.cfg';
if (!file_exists($editor)) file_put_contents($editor, implode("\n",['','txt','js','php','page','plg','xml','old','bak','log','css']));
+
+// Read configured docker paths for compress format auto-detection.
+// Strip the /mnt// prefix so the subpath can match on any disk.
+$docker_cfg = @parse_ini_file('/boot/config/docker.cfg') ?: [];
+$dfm_docker_subpaths = [];
+foreach (['DOCKER_APP_CONFIG_PATH', 'DOCKER_IMAGE_FILE'] as $key) {
+ $val = rtrim(trim($docker_cfg[$key] ?? ''), '/');
+ if ($val && preg_match('#^/mnt/[^/]+/(.+)$#', $val, $m)) {
+ $dfm_docker_subpaths[] = $m[1];
+ }
+}
+// Fallback to defaults when docker is not configured
+if (empty($dfm_docker_subpaths)) {
+ $dfm_docker_subpaths = ['appdata', 'docker'];
+}
+$dfm_docker_subpaths_js = json_encode($dfm_docker_subpaths);
?>
">
">
@@ -47,6 +63,8 @@ if (!file_exists($editor)) file_put_contents($editor, implode("\n",['','txt','js