From c99559951241d5210ac27fa6dd4a93aed77573fa Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Mon, 10 Aug 2026 01:28:56 -0400 Subject: [PATCH] feat(videos): add manual Convert button per MOV file on Pi - New POST /api/local-video/convert route triggers queue_convert(force=True) - queue_convert() now returns True/False/None and accepts force param to bypass the auto-convert toggle (manual always works) - Convert button shown for non-MP4, non-converting files in video list - Clicking spins the button and refreshes list after 1s to show badge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app.py | 11 +++++++++++ modules/app_video.py | 17 +++++++++-------- static/js/local-videos.js | 26 ++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/app.py b/app.py index 3d274c4..b180cc4 100644 --- a/app.py +++ b/app.py @@ -176,6 +176,17 @@ def local_video_delete(): } return jsonify(summary), status_codes.get(summary["status"], 409) +@app.route('/api/local-video/convert', methods=['POST']) +def local_video_convert(): + body = request.get_json(silent=True) or {} + path = body.get("path", "") + if not path: + return jsonify({"status": "invalid_path", "message": "No path provided."}), 400 + queued = app_video.queue_convert(path, force=True) + if queued is False: + return jsonify({"status": "busy", "message": "Already converting."}), 409 + return jsonify({"status": "started"}) + @app.route('/api/upload/status') def upload_status(): return jsonify(app_upload.get_status()) diff --git a/modules/app_video.py b/modules/app_video.py index e6ba2c5..4ced921 100644 --- a/modules/app_video.py +++ b/modules/app_video.py @@ -25,20 +25,21 @@ 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 +def queue_convert(relative_path, force=False): + """Spawn a background thread to re-encode relative_path to web-ready MP4. + Returns True if queued, False if already converting, None if skipped (disabled/MP4). + Set force=True to bypass the is_convert_enabled() check (manual trigger).""" + if not force and not is_convert_enabled(): + return None if relative_path.lower().endswith(".mp4"): - return + return None with _converting_lock: if relative_path in _converting: - return + return False _converting.add(relative_path) - gevent.sleep(0) # yield before spawning thread t = threading.Thread(target=_convert_bg, args=(relative_path,), daemon=True) t.start() + return True def _convert_bg(relative_path): diff --git a/static/js/local-videos.js b/static/js/local-videos.js index 0c1198a..a1f9ad8 100644 --- a/static/js/local-videos.js +++ b/static/js/local-videos.js @@ -172,6 +172,16 @@ playButton.innerHTML = ' Play'; actionsDiv.appendChild(playButton); + // Convert button — only for non-MP4 files not already converting + if (!file.converting && !file.path.toLowerCase().endsWith(".mp4")) { + const convertButton = document.createElement("button"); + convertButton.type = "button"; + convertButton.className = "btn btn-sm btn-outline-secondary btn-icon-mobile"; + convertButton.dataset.convertPath = file.path; + convertButton.innerHTML = ' Convert'; + actionsDiv.appendChild(convertButton); + } + const deleteButton = document.createElement("button"); deleteButton.type = "button"; deleteButton.className = "btn btn-sm btn-outline-danger btn-icon-mobile"; @@ -238,6 +248,22 @@ deleteVideo(deleteButton.dataset.deleteLocalPath).finally(function () { deleteButton.disabled = false; }); + return; + } + const convertButton = event.target.closest("[data-convert-path]"); + if (convertButton) { + convertButton.disabled = true; + convertButton.innerHTML = ''; + fetch("/api/local-video/convert", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: convertButton.dataset.convertPath }), + }).then(function () { + setTimeout(refreshFiles, 1000); + }).catch(function (err) { + summary.textContent = `Convert failed: ${err.message}`; + convertButton.disabled = false; + }); } });