<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>Template</title> </head> <body> <div id="TemplateConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox"> <div data-role="content"> <div class="content-primary"> <form id="FingerprintConfigForm"> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input id="CacheFingerprints" type="checkbox" is="emby-checkbox" /> <span>Cache fingerprints to disk</span> </label> <div class="fieldDescription"> If checked, will store the fingerprints for all subsequently scanned files to disk. Caching fingerprints avoids having to re-run fpcalc on each file, at the expense of disk usage. </div> </div> <div class="inputContainer"> <label class="inputLabel inputLabelUnfocused" for="AnInteger">Show skip prompt at</label> <input id="ShowPromptAdjustment" type="number" is="emby-input" min="0" /> <div class="fieldDescription"> Seconds before the introduction starts to display the skip prompt at. </div> </div> <div class="inputContainer"> <label class="inputLabel inputLabelUnfocused" for="AnInteger">Hide skip prompt at</label> <input id="HidePromptAdjustment" type="number" is="emby-input" min="0" /> <div class="fieldDescription"> Seconds after the introduction starts to hide the skip prompt at. </div> </div> <div> <button is="emby-button" type="submit" class="raised button-submit block emby-button"> <span>Save</span> </button> </div> </form> </div> <details> <summary>Fingerprint Visualizer</summary> <p> Interactively compare the audio fingerprints of two episodes. <br /> The blue and red bar to the right of the fingerprint diff turns blue when the corresponding fingerprint points are at least 75% similar. </p> <table> <thead> <td style="min-width: 100px; font-weight: bold">Key</td> <td style="font-weight: bold">Function</td> </thead> <tbody> <tr> <td>Up arrow</td> <td> Shift the left episode up by 0.128 seconds. Holding control will shift the episode by 10 seconds. </td> </tr> <tr> <td>Down arrow</td> <td> Shift the left episode down by 0.128 seconds. Holding control will shift the episode by 10 seconds. </td> </tr> <tr> <td>Right arrow</td> <td>Advance to the next pair of episodes.</td> </tr> <tr> <td>Left arrow</td> <td>Go back to the previous pair of episodes.</td> </tr> </tbody> </table> <br /> <select id="troubleshooterShow"></select> <select id="troubleshooterSeason"></select> <br /> <select id="troubleshooterEpisode1"></select> <select id="troubleshooterEpisode2"></select> <br /> <span>Shift amount:</span> <input type="number" min="-3000" max="3000" value="0" id="offset"> <br /> <br /> <canvas id="troubleshooter"></canvas> <span id="timestamps"></span> </details> </div> <script> // first and second episodes to fingerprint & compare var lhs = []; var rhs = []; // fingerprint point comparison & miminum similarity threshold var fprDiffs = []; var fprDiffMinimum = 75.0; // seasons grouped by show var shows = {}; // ui elements const canvas = document.querySelector("canvas#troubleshooter"); const selectShow = document.querySelector("select#troubleshooterShow"); const selectSeason = document.querySelector("select#troubleshooterSeason"); const selectEpisode1 = document.querySelector("select#troubleshooterEpisode1"); const selectEpisode2 = document.querySelector("select#troubleshooterEpisode2"); const txtOffset = document.querySelector("input#offset"); // config page loaded, populate show names async function onLoad() { shows = await getJson("Intros/Shows"); var sorted = []; for (var series in shows) { sorted.push(series); } sorted.sort(); for (var show of sorted) { addItem(selectShow, show, show); } selectShow.value = ""; } // show changed, populate seasons async function showChanged() { clearSelect(selectSeason); // add all seasons from this show to the season select for (var season of shows[selectShow.value]) { addItem(selectSeason, season, season); } selectSeason.value = ""; } // season changed, reload all episodes async function seasonChanged() { const url = "Intros/Show/" + encodeURI(selectShow.value) + "/" + selectSeason.value; const episodes = await getJson(url); clearSelect(selectEpisode1); clearSelect(selectEpisode2); let i = 1; for (let episode of episodes) { const strI = i.toLocaleString("en", { minimumIntegerDigits: 2, maximumFractionDigits: 0 }); addItem(selectEpisode1, strI + ": " + episode.Name, episode.Id); addItem(selectEpisode2, strI + ": " + episode.Name, episode.Id); i++; } setTimeout(() => { selectEpisode1.selectedIndex = 0; selectEpisode2.selectedIndex = 1; episodeChanged(); }, 100); } // episode changed, get fingerprints & calculate diff async function episodeChanged() { if (!selectEpisode1.value || !selectEpisode2.value) { return; } Dashboard.showLoadingMsg(); lhs = await getJson("Intros/Fingerprint/" + selectEpisode1.value); rhs = await getJson("Intros/Fingerprint/" + selectEpisode2.value); Dashboard.hideLoadingMsg(); refreshBounds(); renderTroubleshooter(); } // adds an item to a dropdown function addItem(select, text, value) { let item = new Option(text, value); select.add(item); } // clear a select of items function clearSelect(select) { let i, L = select.options.length - 1; for (i = L; i >= 0; i--) { select.remove(i); } } // re-render the troubleshooter with the latest offset function renderTroubleshooter() { paintFingerprintDiff(canvas, lhs, rhs, Number(offset.value)); } // refresh the upper & lower bounds for the offset function refreshBounds() { const len = Math.min(lhs.length, rhs.length) - 1; offset.min = -1 * len; offset.max = len; } // make an authenticated GET to the server and parse the response as JSON async function getJson(url) { url = ApiClient.serverAddress() + "/" + url; const reqInit = { headers: { "Authorization": "MediaBrowser Token=" + ApiClient.accessToken() } }; return await fetch(url, reqInit) .then(r => { return r.json(); }); } // key pressed function keyDown(e) { let episodeDelta = 0; let offsetDelta = 0; switch (e.key) { case "ArrowDown": // if the control key is pressed, shift LHS by 10s. Otherwise, shift by 1. offsetDelta = e.ctrlKey ? 10 / 0.128 : 1; break; case "ArrowUp": offsetDelta = e.ctrlKey ? -10 / 0.128 : -1; break; case "ArrowRight": episodeDelta = 2; break; case "ArrowLeft": episodeDelta = -2; break; default: return; } if (offsetDelta != 0) { txtOffset.value = Number(txtOffset.value) + Math.floor(offsetDelta); } if (episodeDelta != 0) { // calculate the number of episodes remaining in the LHS and RHS episode pickers const lhsRemaining = selectEpisode1.selectedIndex; const rhsRemaining = selectEpisode2.length - selectEpisode2.selectedIndex - 1; // if we're moving forward and the right episode picker is close to the end, don't move. if (episodeDelta > 0 && rhsRemaining <= 1) { return; } else if (episodeDelta < 0 && lhsRemaining <= 1) { return; } selectEpisode1.selectedIndex += episodeDelta; selectEpisode2.selectedIndex += episodeDelta; episodeChanged(); } renderTroubleshooter(); e.preventDefault(); } // converts seconds to a readable timestamp (i.e. 127 becomes "02:07"). function secondsToString(seconds) { return new Date(seconds * 1000).toISOString().substr(14, 5); } document.querySelector('#TemplateConfigPage') .addEventListener('pageshow', function () { Dashboard.showLoadingMsg(); ApiClient.getPluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b").then(function (config) { document.querySelector('#CacheFingerprints').checked = config.CacheFingerprints; document.querySelector('#ShowPromptAdjustment').value = config.ShowPromptAdjustment; document.querySelector('#HidePromptAdjustment').value = config.HidePromptAdjustment; Dashboard.hideLoadingMsg(); }); }); document.querySelector('#FingerprintConfigForm') .addEventListener('submit', function () { Dashboard.showLoadingMsg(); ApiClient.getPluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b").then(function (config) { config.CacheFingerprints = document.querySelector('#CacheFingerprints').checked; config.ShowPromptAdjustment = document.querySelector("#ShowPromptAdjustment").value; config.HidePromptAdjustment = document.querySelector("#HidePromptAdjustment").value; ApiClient.updatePluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b", config).then(function (result) { Dashboard.processPluginConfigurationUpdateResult(result); }); }); return false; }); txtOffset.addEventListener("change", renderTroubleshooter); selectShow.addEventListener("change", showChanged); selectSeason.addEventListener("change", seasonChanged); selectEpisode1.addEventListener("change", episodeChanged); selectEpisode2.addEventListener("change", episodeChanged); document.addEventListener("keydown", keyDown); // TODO: remove document wide listener when the user exits the page canvas.addEventListener("mousemove", (e) => { const rect = e.currentTarget.getBoundingClientRect(); const y = e.clientY - rect.top; const shift = Number(txtOffset.value); let lTime, rTime, diffPos; if (shift < 0) { lTime = y * 0.128; rTime = (y + shift) * 0.128; diffPos = y + shift; } else { lTime = (y - shift) * 0.128; rTime = y * 0.128; diffPos = y - shift; } const diff = fprDiffs[Math.floor(diffPos)]; const times = document.querySelector("span#timestamps"); if (!diff) { times.style.display = "none"; return; } else { times.style.display = "unset"; } // LHS timestamp, RHS timestamp, percent similarity times.textContent = secondsToString(lTime) + ", " + secondsToString(rTime) + ", " + Math.round(diff) + "%"; times.style.position = "relative"; times.style.left = "25px"; times.style.top = (-1 * rect.height + y).toString() + "px"; }); // TODO: fix setTimeout(onLoad, 250); </script> <script> // Modified from https://github.com/dnknth/acoustid-match/blob/ffbf21d8c53c40d3b3b4c92238c35846545d3cd7/fingerprints/static/fingerprints/fputils.js // Originally licensed as MIT. function renderFingerprintData(ctx, fp, xor = false) { const pixels = ctx.createImageData(32, fp.length); let idx = 0; for (let i = 0; i < fp.length; i++) { for (let j = 0; j < 32; j++) { if (fp[i] & (1 << j)) { pixels.data[idx + 0] = 255; pixels.data[idx + 1] = 255; pixels.data[idx + 2] = 255; } else { pixels.data[idx + 0] = 0; pixels.data[idx + 1] = 0; pixels.data[idx + 2] = 0; } pixels.data[idx + 3] = 255; idx += 4; } } if (!xor) { return pixels; } // if rendering the XOR of the fingerprints, count how many bits are different at each timecode fprDiffs = []; for (let i = 0; i < fp.length; i++) { let count = 0; for (let j = 0; j < 32; j++) { if (fp[i] & (1 << j)) { count++; } } // push the percentage similarity fprDiffs[i] = 100 - (count * 100) / 32; } return pixels; } function paintFingerprintDiff(canvas, fp1, fp2, offset) { let leftOffset = 0, rightOffset = 0; if (offset < 0) { leftOffset -= offset; } else { rightOffset += offset; } let fpDiff = []; fpDiff.length = Math.min(fp1.length, fp2.length) - Math.abs(offset); for (let i = 0; i < fpDiff.length; i++) { fpDiff[i] = fp1[i + leftOffset] ^ fp2[i + rightOffset]; } const ctx = canvas.getContext('2d'); const pixels1 = renderFingerprintData(ctx, fp1); const pixels2 = renderFingerprintData(ctx, fp2); const pixelsDiff = renderFingerprintData(ctx, fpDiff, true); const border = 4; canvas.width = pixels1.width + border + // left fingerprint pixels2.width + border + // right fingerprint pixelsDiff.width + border // fingerprint diff + 4; // if diff[x] >= fprDiffMinimum canvas.height = Math.max(pixels1.height, pixels2.height) + Math.abs(offset); ctx.rect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "#C5C5C5"; ctx.fill(); // draw left fingerprint let dx = 0; ctx.putImageData(pixels1, dx, rightOffset); dx += pixels1.width + border; // draw right fingerprint ctx.putImageData(pixels2, dx, leftOffset); dx += pixels2.width + border; // draw fingerprint diff ctx.putImageData(pixelsDiff, dx, Math.abs(offset)); dx += pixelsDiff.width + border; // draw the fingerprint diff similarity indicator // https://davidmathlogic.com/colorblind/#%23EA3535-%232C92EF for (var i in fprDiffs) { const j = Number(i); const y = Math.abs(offset) + j; ctx.fillStyle = fprDiffs[j] >= fprDiffMinimum ? "#2C92EF" : "#EA3535"; ctx.fillRect(dx, y, 4, 1); } } </script> </div> </body> </html>