diff --git a/modules/app_dash.py b/modules/app_dash.py index 62ac732..1ff4e9d 100644 --- a/modules/app_dash.py +++ b/modules/app_dash.py @@ -520,6 +520,9 @@ def copy_files(source_folder, destination_folder, relative_paths=None): try: shutil.copy2(abs_path, dest_path) copied += 1 + # Queue background conversion if enabled (import app_video lazily to avoid circular import) + from modules import app_video as _app_video + _app_video.queue_convert(rel_path) except OSError as e: errors.append(f"{rel_path}: {e}") diff --git a/modules/app_settings.py b/modules/app_settings.py index 0516b23..fd05272 100644 --- a/modules/app_settings.py +++ b/modules/app_settings.py @@ -38,6 +38,7 @@ def get_settings(): return { "dashcam_upload_enabled": app_upload.is_dashcam_upload_enabled(), "local_video_retention_days": app_video.retention_days(), + "video_convert_enabled": app_video.is_convert_enabled(), "cellular_enabled": _setting_bool( "cellular_enabled", "YARIS_CELLULAR_ENABLED" ), @@ -98,6 +99,7 @@ def update_settings(body): values = {} for field in ( "dashcam_upload_enabled", + "video_convert_enabled", "cellular_enabled", "ntfy_enabled", "clear_ntfy_url", @@ -182,9 +184,8 @@ def update_settings(body): app_upload.DASHCAM_UPLOAD_SETTING_KEY: ( "1" if values["dashcam_upload_enabled"] else "0" ), - app_video.RETENTION_SETTING: str( - values["local_video_retention_days"] - ), + app_video.RETENTION_SETTING: str(values["local_video_retention_days"]), + app_video.CONVERT_SETTING: "1" if values["video_convert_enabled"] else "0", "cellular_enabled": "1" if values["cellular_enabled"] else "0", "cellular_apn": apn, "alerts_enabled": "1" if values["ntfy_enabled"] else "0", diff --git a/modules/app_video.py b/modules/app_video.py index 9216cff..17f2d2f 100644 --- a/modules/app_video.py +++ b/modules/app_video.py @@ -12,10 +12,76 @@ from modules import app_dash, app_sql BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) THUMBNAIL_DIR = os.path.join(BASE_DIR, "dash", "thumbnails") RETENTION_SETTING = "local_video_retention_days" +CONVERT_SETTING = "video_convert_enabled" _thumbnail_lock = Semaphore() -# Tracks which relative paths are currently being generated (set of strings) _generating = set() _generating_lock = Semaphore() +_converting = set() +_converting_lock = Semaphore() + + +def is_convert_enabled(): + return app_sql.get_setting(CONVERT_SETTING, "0") == "1" + + +def queue_convert(relative_path): + """Spawn a background greenlet to re-encode relative_path to web-ready MP4. + No-op if conversion is disabled, already running, or file is already MP4.""" + if not is_convert_enabled(): + return + if relative_path.lower().endswith(".mp4"): + return + with _converting_lock: + if relative_path in _converting: + return + _converting.add(relative_path) + gevent.spawn(_convert_bg, relative_path) + + +def _convert_bg(relative_path): + try: + source_path = _local_path(relative_path) + mp4_relative = os.path.splitext(relative_path)[0] + ".mp4" + mp4_path = _local_path(mp4_relative) + + if not app_dash.begin_local_video_use(relative_path): + return + + try: + tmp_path = mp4_path + ".converting.mp4" + cmd = [ + "ffmpeg", "-nostdin", "-loglevel", "error", "-y", + "-i", source_path, + "-c:v", "h264_v4l2m2m", + "-c:a", "aac", + "-movflags", "faststart", + "-f", "mp4", + tmp_path, + ] + proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + while proc.poll() is None: + sleep(1) + if proc.returncode == 0 and os.path.isfile(tmp_path) and os.path.getsize(tmp_path) > 0: + os.replace(tmp_path, mp4_path) + # Delete original now that MP4 is ready + try: + os.remove(source_path) + except OSError: + pass + # Also remove old thumbnail so it gets regenerated from MP4 + _source, thumb = _thumbnail_path(relative_path) + if os.path.isfile(thumb): + try: + os.remove(thumb) + except OSError: + pass + elif os.path.exists(tmp_path): + os.remove(tmp_path) + finally: + app_dash.end_local_video_use(relative_path) + finally: + with _converting_lock: + _converting.discard(relative_path) def _local_path(relative_path): @@ -47,6 +113,8 @@ def list_local_videos(): except OSError as e: errors.append(f"{relative_path}: {e}") continue + with _converting_lock: + converting = relative_path in _converting files.append( { "path": relative_path, @@ -57,6 +125,7 @@ def list_local_videos(): stat.st_size, stat.st_mtime, ) in uploaded_stats, + "converting": converting, } ) total_size += stat.st_size diff --git a/static/js/local-videos.js b/static/js/local-videos.js index b419b6d..da10d9f 100644 --- a/static/js/local-videos.js +++ b/static/js/local-videos.js @@ -91,12 +91,13 @@ body.replaceChildren(); const files = result.files || []; let uploadedCount = 0; + let convertingCount = 0; files.forEach(function (file) { const row = document.createElement("tr"); row.dataset.path = file.path; - // Thumbnail cell + // Thumbnail cell — also contains filename for mobile view const preview = document.createElement("td"); preview.style.width = "9rem"; preview.style.padding = "0.25rem"; @@ -105,6 +106,12 @@ image.alt = ""; image.dataset.path = file.path; preview.appendChild(image); + // Filename shown only on mobile (hidden on desktop via CSS) + const mobileLabel = document.createElement("div"); + mobileLabel.className = "dash-thumb-name text-truncate small mt-1"; + mobileLabel.style.maxWidth = "5rem"; + mobileLabel.textContent = file.path.split("/").pop(); + preview.appendChild(mobileLabel); row.appendChild(preview); thumbnailObserver.observe(image); @@ -124,19 +131,25 @@ dateCell.className = "dash-col-date"; row.appendChild(dateCell); - // Upload badge + // Upload / convert badge const upload = document.createElement("td"); upload.className = "dash-col-upload"; const badge = document.createElement("span"); - badge.className = file.uploaded ? "badge bg-success" : "badge bg-warning text-dark"; - badge.textContent = file.uploaded ? "✓" : "…"; + if (file.converting) { + badge.className = "badge bg-info text-dark"; + badge.innerHTML = 'Converting'; + convertingCount++; + } else { + badge.className = file.uploaded ? "badge bg-success" : "badge bg-warning text-dark"; + badge.textContent = file.uploaded ? "✓" : "…"; + } upload.appendChild(badge); row.appendChild(upload); if (file.uploaded) uploadedCount++; // Actions — wrap in div so td stays a table cell (not flex) const actionsTd = document.createElement("td"); - actionsTd.style.whiteSpace = "nowrap"; + actionsTd.className = "dash-actions-td"; const actionsDiv = document.createElement("div"); actionsDiv.className = "data-list-actions"; @@ -160,12 +173,17 @@ }); summary.textContent = `${files.length} video(s), ${YarisUI.formatBytes(result.total_size || 0)} total, ${uploadedCount} uploaded`; + if (convertingCount > 0) summary.textContent += `; ${convertingCount} converting…`; if (result.retention_days > 0) { - summary.textContent += `; > ${result.retention_days}d uploaded auto-deleted`; + summary.textContent += `; > ${result.retention_days}d uploaded auto-deleted`; } if (result.errors && result.errors.length) { summary.textContent += ` (${result.errors.length} error(s))`; } + // Auto-refresh every 10s while conversions are running + if (convertingCount > 0) { + setTimeout(refreshFiles, 10000); + } } function refreshFiles() { diff --git a/static/js/settings.js b/static/js/settings.js index 7c59b08..4aee39b 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -15,6 +15,7 @@ let initialUplinkStatusLoaded = false; const fields = { dashcamUpload: document.getElementById('dashcam-upload-enabled'), retention: document.getElementById('retention-days'), + videoConvert: document.getElementById('video-convert-enabled'), cellularEnabled: document.getElementById('cellular-enabled'), cellularApn: document.getElementById('cellular-apn'), ntfyEnabled: document.getElementById('ntfy-enabled'), @@ -80,6 +81,7 @@ function populate(settings) { const thresholds = settings.alert_thresholds; fields.dashcamUpload.checked = settings.dashcam_upload_enabled; fields.retention.value = settings.local_video_retention_days; + fields.videoConvert.checked = settings.video_convert_enabled; fields.cellularEnabled.checked = settings.cellular_enabled; fields.cellularApn.value = settings.cellular_apn; fields.ntfyEnabled.checked = settings.ntfy_enabled; @@ -105,6 +107,7 @@ function payload() { return { dashcam_upload_enabled: fields.dashcamUpload.checked, local_video_retention_days: Number(fields.retention.value), + video_convert_enabled: fields.videoConvert.checked, cellular_enabled: fields.cellularEnabled.checked, cellular_apn: fields.cellularApn.value, ntfy_enabled: fields.ntfyEnabled.checked, diff --git a/static/yaris.css b/static/yaris.css index 52ababf..37b268a 100644 --- a/static/yaris.css +++ b/static/yaris.css @@ -269,18 +269,29 @@ body { /* ── Dashcam / video ─────────────────────────────────────────────────────── */ -/* Hide less-important columns on mobile to keep the table usable */ +/* On mobile: hide file/size/date/upload columns — only thumb+name and actions remain */ @media (max-width: 575.98px) { .dash-col-size, .dash-col-date, - .dash-col-upload { display: none; } -} -@media (max-width: 767.98px) { - .dash-col-file { max-width: 8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -} -/* Icon-only action buttons on mobile */ -@media (max-width: 575.98px) { + .dash-col-upload, + .dash-col-file { display: none; } + + /* Show filename inside the thumbnail cell instead */ + .dash-thumb-name { display: block !important; } + + /* Shrink thumbnail on mobile */ + .local-video-thumbnail { width: 5rem; } + + /* Icon-only action buttons */ .btn-icon-mobile .btn-label { display: none; } + + /* Keep actions cell from overflowing */ + .dash-actions-td { width: 1%; white-space: nowrap; } +} + +@media (min-width: 576px) { + .dash-thumb-name { display: none; } + .dash-col-file { max-width: 12rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } } diff --git a/templates/settings.html b/templates/settings.html index d3dd716..e77fea0 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -39,6 +39,13 @@ (0 = keep forever)
+