<!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"> <fieldset class="verticalSection-extrabottompadding"> <legend>Analysis</legend> <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 audio fingerprints for all subsequently scanned files to disk. Caching fingerprints avoids having to re-run ffmpeg on each file, at the expense of disk usage. </div> </div> <div class="inputContainer"> <label class="inputLabel inputLabelUnfocused" for="MaxParallelism"> Maximum degree of parallelism </label> <input id="MaxParallelism" type="number" is="emby-input" min="1" /> <div class="fieldDescription"> Maximum degree of parallelism to use when analyzing episodes. </div> </div> </fieldset> <fieldset class="verticalSection-extrabottompadding"> <legend>Playback</legend> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input id="AutoSkip" type="checkbox" is="emby-checkbox" /> <span>Automatically skip intros</span> </label> <div class="fieldDescription"> If checked, intros will be automatically skipped. Will only work if web sockets are configured correctly.<br /> </div> </div> <div class="inputContainer"> <label class="inputLabel inputLabelUnfocused" for="ShowPromptAdjustment"> 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="HidePromptAdjustment"> Hide skip prompt after </label> <input id="HidePromptAdjustment" type="number" is="emby-input" min="2" /> <div class="fieldDescription"> Seconds after the introduction starts to hide the skip prompt at. </div> </div> </fieldset> <div> <button is="emby-button" type="submit" class="raised button-submit block emby-button"> <span>Save</span> </button> <br /> <button id="btnEraseTimestamps" is="emby-button" class="raised block emby-button"> <span>Erase introduction timestamps</span> </button> <p> Erasing introduction timestamps is only necessary after upgrading the plugin if specifically requested to do so in the plugin's changelog. After the timestamps are erased, run the Analyze episodes scheduled task to re-analyze all media on the server. </p> </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 80% 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="timestampContainer"> <span id="timestamps"></span> <br /> <span id="intros"></span> </span> </details> </div> <script> // first and second episodes to fingerprint & compare var lhs = []; var rhs = []; // fingerprint point comparison & miminum similarity threshold (at most 6 bits out of 32 can be different) var fprDiffs = []; var fprDiffMinimum = (1 - 6 / 32) * 100; // seasons grouped by show var shows = {}; // ui elements var canvas = document.querySelector("canvas#troubleshooter"); var selectShow = document.querySelector("select#troubleshooterShow"); var selectSeason = document.querySelector("select#troubleshooterSeason"); var selectEpisode1 = document.querySelector("select#troubleshooterEpisode1"); var selectEpisode2 = document.querySelector("select#troubleshooterEpisode2"); var txtOffset = document.querySelector("input#offset"); var timeContainer = document.querySelector("span#timestampContainer"); var btnEraseTimestamps = document.querySelector("button#btnEraseTimestamps"); var windowHashInterval = 0; // 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)); findIntros(); } // 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, method = "GET") { url = ApiClient.serverAddress() + "/" + url; const reqInit = { method: method, 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(); } function findIntros() { let times = []; // get the times of all similar fingerprint points for (let i in fprDiffs) { if (fprDiffs[i] > fprDiffMinimum) { times.push(i * 0.128); } } // always close the last range times.push(Number.MAX_VALUE); let last = times[0]; let start = last; let end = last; let ranges = []; for (let t of times) { const diff = t - last; if (diff <= 3.5) { end = t; last = t; continue; } const dur = Math.round(end - start); if (dur >= 15) { ranges.push({ "start": start, "end": end, "duration": dur }); } start = t; end = t; last = t; } const introsLog = document.querySelector("span#intros"); introsLog.style.position = "relative"; introsLog.style.left = "115px"; introsLog.innerHTML = ""; const offset = Number(txtOffset.value) * 0.128; for (let r of ranges) { let lStart, lEnd, rStart, rEnd; if (offset < 0) { // negative offset, the diff is aligned with the RHS lStart = r.start - offset; lEnd = r.end - offset; rStart = r.start; rEnd = r.end; } else { // positive offset, the diff is aligned with the LHS lStart = r.start; lEnd = r.end; rStart = r.start + offset; rEnd = r.end + offset; } const lTitle = selectEpisode1.options[selectEpisode1.selectedIndex].text; const rTitle = selectEpisode2.options[selectEpisode2.selectedIndex].text; introsLog.innerHTML += "<span>" + lTitle + ": " + secondsToString(lStart) + " - " + secondsToString(lEnd) + "</span> <br />"; introsLog.innerHTML += "<span>" + rTitle + ": " + secondsToString(rStart) + " - " + secondsToString(rEnd) + "</span> <br />"; } } // check that the user is still on the configuration page function checkWindowHash() { const h = location.hash; if (h === "#!/configurationpage?name=Intro%20Skipper" || h.includes("#!/dialog")) { return; } console.debug("navigated away from intro skipper configuration page"); document.removeEventListener("keydown", keyDown); clearInterval(windowHashInterval); } // 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('#AutoSkip').checked = config.AutoSkip; document.querySelector('#MaxParallelism').value = config.MaxParallelism; 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 (e) { Dashboard.showLoadingMsg(); ApiClient.getPluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b").then(function (config) { config.AutoSkip = document.querySelector('#AutoSkip').checked; config.MaxParallelism = document.querySelector('#MaxParallelism').value; 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); }); }); e.preventDefault(); return false; }); txtOffset.addEventListener("change", renderTroubleshooter); selectShow.addEventListener("change", showChanged); selectSeason.addEventListener("change", seasonChanged); selectEpisode1.addEventListener("change", episodeChanged); selectEpisode2.addEventListener("change", episodeChanged); btnEraseTimestamps.addEventListener("click", (e) => { Dashboard.confirm( "Are you sure you want to erase all previously discovered introduction timestamps?", "Confirm timestamp erasure", (result) => { if (!result) { return; } // reset all intro timestamps on the server so a new fingerprint comparison algorithm can be tested getJson("Intros/EraseTimestamps", "POST"); }); e.preventDefault(); }); document.addEventListener("keydown", keyDown); windowHashInterval = setInterval(checkWindowHash, 2500); 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)]; if (!diff) { timeContainer.style.display = "none"; return; } else { timeContainer.style.display = "unset"; } const times = document.querySelector("span#timestamps"); // LHS timestamp, RHS timestamp, percent similarity times.textContent = secondsToString(lTime) + ", " + secondsToString(rTime) + ", " + Math.round(diff) + "%"; timeContainer.style.position = "relative"; timeContainer.style.left = "25px"; timeContainer.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) { if (fp1.length == 0) { return; } 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 (let 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>