Newer
Older
medialib-manager / static / js / components / global-transcoding-tasks.js
const globalTasks = {};
const copyTasks = {};   // keyed by file path during copy phase
const mediaInfoCenter = {};

// Title management
const taskPercents = {}; // 'copy:<path>' | 'tx:<taskId>' -> {percent, phase, file}
const pageBaseTitle = (typeof mediaInfo !== "undefined" && mediaInfo.name)
    ? mediaInfo.name
    : "mctl";

// Scan animation
let _scanAnimFrame = null;
let _scanAnimInterval = null;
const _scanFrames = ["*", "^", "#", "!", "$", "&", "?"];

function startScanTitleAnimation() {
    if (_scanAnimInterval) return;
    let i = 0;
    _scanAnimInterval = setInterval(() => {
        document.title = `${_scanFrames[i % _scanFrames.length]} scanning... ${pageBaseTitle}`;
        i++;
    }, 150);
}

function stopScanTitleAnimation() {
    if (_scanAnimInterval) {
        clearInterval(_scanAnimInterval);
        _scanAnimInterval = null;
    }
    updatePageTitle();
}

function updatePageTitle() {
    if (_scanAnimInterval) return; // scan animation takes priority
    const entries = Object.values(taskPercents);
    if (!entries.length) {
        document.title = pageBaseTitle;
        return;
    }

    const isFilePage = typeof mediaInfo !== "undefined";
    if (isFilePage) {
        const thisFile = entries.find(e => e.file === mediaInfo.path);
        if (thisFile) {
            document.title = `[${thisFile.percent}% ${thisFile.phase}] ${pageBaseTitle}`;
            return;
        }
    }

    const avg = Math.round(entries.reduce((s, e) => s + e.percent, 0) / entries.length);
    document.title = `[avg ${avg}%] ${pageBaseTitle}`;
}

function createCopyTaskView(filePath) {
	const filename = filePath.split("/").at(-1);
	const href = `/single?path=${encodeURIComponent(filePath)}`;
	const li = document.createElement('li');
	li.classList.add("list-group-item", "task");
	li.dataset.copyFile = filePath;
	li.innerHTML = `
		<div class="file">
			<div class="circle-progress-bar" data-value="0" data-bar-color="#e0af68" data-bar-stroke="4" data-bar-size="30"></div>
			<div>
				<a href="${href}" class="link-primary">${filename}</a>
				<div class="copy-phase-label" style="font-size:0.75rem; color: var(--tn-muted)">copying backup...</div>
			</div>
			<span class="progress-percent" style="opacity:.7">0%</span>
		</div>
		<div class="control">
			<button class="btn btn-outline-secondary btn-sm" disabled title="copying in progress">
				<i class="bi bi-stop-fill"></i>
			</button>
		</div>
	`;
	initSingleCircleProgressBar(li.querySelector(".circle-progress-bar"));
	return li;
}

function createEmptyTaskView(taskId) {
	const li = document.createElement('li');
	li.classList.add("list-group-item");
	li.classList.add("task");
	li.dataset.taskId = taskId;

	let html = `
		<div class="file">
      <div class="circle-progress-bar" data-value="0" data-bar-color="#198754" data-bar-stroke="4" data-bar-size="30"></div>
      <a href="" class="link-primary"></a>
      <span class="progress-percent" style="opacity: .7">0%</span>
    </div>
    <div class="control">
      <button class="btn btn-danger cancel" data-task-id="${ taskId }"><i class="bi bi-stop-fill"></i> Stop</button>
      <div class="spinner-border text-secondary" role="status" style="display: none">
			  <span class="visually-hidden">Loading...</span>
			</div>
    </div>
	`;

	li.innerHTML = html;
	initSingleCircleProgressBar(li.querySelector(".circle-progress-bar"));
	return initTaskView(li);
}

function getTotalActiveTasks() {
	return Object.keys(globalTasks).length + Object.keys(copyTasks).length;
}

function updateTasksUI() {
	const total = getTotalActiveTasks();
	$(".total-tasks").text(total > 0 ? total : "");
	if (total > 0) {
		$(".empty-tasks-message").hide();
	} else {
		$(".empty-tasks-message").show();
	}
}

function initTaskView(view) {
	view.querySelector(".control .cancel").addEventListener("click", e => {
		const taskId = e.currentTarget.dataset.taskId;
		$(e.currentTarget).hide();
		$(e.currentTarget).parent().find(".spinner-border").show();

		$.getJSON(`/stop-transcoding?task_id=${taskId}`, function(resp) {
      if(resp.status == "stopped" && resp.task_id == taskId) {
      	if(typeof globalTasks[resp.task_id] != "undefined") {
      		// delete globalTasks[resp.task_id];
      	}
      } else {
      	pushErrMsg("Failed to complete request to stop transcoding");
      }
    });
	});

	return view;
}

function updateExistsView(data, view) {
	const taskContainer = $(view);
	const href = `/single?path=${encodeURIComponent(data.task.file)}`;
	const fileLink = taskContainer.find(".file a");

	if(fileLink.attr("href") != href) {
		fileLink.text(data.task.file.split("/").at(-1));
		fileLink.attr("href", href);
	}

	let time = getTimeFromTranscodingProgressMessage(data.message);
	const mInf = getMediaInfo(data.task.file);

	if(!mInf) {
		return; // media info not yet loaded — keep showing 0%
	}

	const progressPercent = calcTranscodingProgress(time, mInf.info.container.duration);
	const displayPercent = (isNaN(progressPercent) || progressPercent < 0) ? 0 : Math.min(progressPercent, 99);

	const cpbar = taskContainer.find(".file .circle-progress-bar")[0];
	if(cpbar.dataset.value != displayPercent) {
		taskContainer.find(".file .progress-percent").text(`${displayPercent}%`);
		cpbar.dataset.value = displayPercent;
	}
}

function globalTranscodingTasksInit() {
	const tasksContainer = $(".transcoding-tasks-container > ul");

	socket.on("copy-progress", data => {
		if (typeof copyTasks[data.file] === "undefined") {
			const view = createCopyTaskView(data.file);
			tasksContainer.append(view);
			copyTasks[data.file] = { view };
			updateTasksUI();
		}

		const view = copyTasks[data.file].view;
		const cpbar = view.querySelector(".circle-progress-bar");
		cpbar.dataset.value = data.percent;
		view.querySelector(".progress-percent").textContent = data.percent + "%";

		taskPercents[`copy:${data.file}`] = { percent: data.percent, phase: 'copying', file: data.file };
		updatePageTitle();
	});

	socket.on("progress", data => {
		if(typeof data.task.id == "undefined") {
			return;
		}

		// Remove copy-phase card if it exists for this file
		if (typeof copyTasks[data.task.file] !== "undefined") {
			$(copyTasks[data.task.file].view).remove();
			delete copyTasks[data.task.file];
			delete taskPercents[`copy:${data.task.file}`];
		}

		if(typeof globalTasks[data.task.id] == "undefined") {
			const view = createEmptyTaskView(data.task.id);
			tasksContainer.append(view);
			globalTasks[data.task.id] = { "data": data, "view": view };
		}

		updateExistsView(data, globalTasks[data.task.id].view);
		updateTasksUI();

		const displayPercent = (function() {
			const cpbar = globalTasks[data.task.id]?.view?.querySelector(".circle-progress-bar");
			return cpbar ? parseInt(cpbar.dataset.value) || 0 : 0;
		})();
		taskPercents[`tx:${data.task.id}`] = { percent: displayPercent, phase: 'transcoding', file: data.task.file };
		updatePageTitle();
	});

	function cleanupTask(data) {
		if (typeof copyTasks[data.task.file] !== "undefined") {
			$(copyTasks[data.task.file].view).remove();
			delete copyTasks[data.task.file];
			delete taskPercents[`copy:${data.task.file}`];
		}
		if (typeof globalTasks[data.task.id] !== "undefined") {
			$(globalTasks[data.task.id].view).remove();
			delete globalTasks[data.task.id];
			delete taskPercents[`tx:${data.task.id}`];
		}
		updateTasksUI();
		updatePageTitle();
	}

	socket.on("completed", data => { cleanupTask(data); });
	socket.on("canceled",  data => { cleanupTask(data); });

	socket.on("error", data => {
		cleanupTask(data);
		const cmd = data.task.command.join(" ");
		const log = data.ffmpeg_log ? `<pre style="font-size:0.7rem;max-height:200px;overflow-y:auto;white-space:pre-wrap;margin-top:8px;color:var(--tn-fg-dim)">${data.ffmpeg_log}</pre>` : "";
		pushErrMsg(`${data.message}<hr>${cmd}${log}`);
	});

	socket.on("medialib-scaning-process", () => startScanTitleAnimation());
	socket.on("medialib-scaning-complete", () => stopScanTitleAnimation());
}

function getMediaInfo(path) {
	if(typeof mediaInfoCenter[path] == "undefined") {
		getSingleMediaFileInfo(path, resp => {
			console.log(resp);
			mediaInfoCenter[resp.path] = resp;
		});

		return false;
	}

	return mediaInfoCenter[path];
}

$(document).ready(function() {
	if(typeof mediaInfo != "undefined") {
		mediaInfoCenter[mediaInfo.path] = mediaInfo;
	}

	document.title = pageBaseTitle;
	globalTranscodingTasksInit();

	$.getJSON("/scan-status", function(data) {
		if (data.scanning) startScanTitleAnimation();
	});
});