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;
+ });
}
});