<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> </head> <body> <div id="TemplateConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox,emby-linkbutton"> <div data-role="content"> <style> summary { cursor: pointer; padding: 10px; width: inherit; margin: auto; border: none; text-align: center; outline: none; font-size: 1.0em; outline: 2px solid rgba(155, 155, 155, 0.5); } </style> <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="AutoDetectIntros" type="checkbox" is="emby-checkbox" /> <span>Automatically Scan Intros</span> </label> <div class="fieldDescription"> If enabled, introductions will be automatically analyzed for new media </div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input id="AutoDetectCredits" type="checkbox" is="emby-checkbox" /> <span>Automatically Scan Credits</span> </label> <div class="fieldDescription"> If enabled, credits will be automatically analyzed for new media <br /> <br /> Note: Not selecting at least one automatic detection type will disable automatic scans. To configure the scheduled task, see <a is="emby-linkbutton" class="button-link" href="scheduledtasks.html">scheduled tasks</a>. </div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input id="AnalyzeSeasonZero" type="checkbox" is="emby-checkbox" /> <span>Analyze season 0</span> </label> <div class="fieldDescription"> If checked, season 0 (specials / extras) will be included in analysis. <br /> <br /> Note: Shows containing both a specials and extra folder will identify extras as season 0 and ignore specials, regardless of this setting. </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 number of simultaneous async episode analysis operations. </div> </div> <div class="inputContainer"> <label class="inputLabel inputLabelUnfocused" for="SelectedLibraries"> Limit analysis to the following libraries </label> <input id="SelectedLibraries" type="text" is="emby-input" /> <div class="fieldDescription"> Enter the names of libraries to analyze, separated by commas. If this field is left blank, all libraries on the server containing television episodes will be analyzed. </div> </div> <details id="intro_reqs"> <summary>Modify Segment Parameters</summary> <br /> <div class="inputContainer"> <label class="inputLabel inputLabelUnfocused" for="AnalysisPercent"> Percent of audio to analyze </label> <input id="AnalysisPercent" type="number" is="emby-input" min="1" max="90" /> <div class="fieldDescription"> Analysis will be limited to this percentage of each episode's audio. For example, a value of 25 (the default) will limit analysis to the first quarter of each episode. </div> </div> <div class="inputContainer"> <label class="inputLabel inputLabelUnfocused" for="AnalysisLengthLimit"> Maximum runtime of audio to analyze (in minutes) </label> <input id="AnalysisLengthLimit" type="number" is="emby-input" min="1" /> <div class="fieldDescription"> Analysis will be limited to this amount of each episode's audio. For example, a value of 10 (the default) will limit analysis to the first 10 minutes of each episode. </div> </div> <div class="inputContainer"> <label class="inputLabel inputLabelUnfocused" for="MinimumIntroDuration"> Minimum introduction duration (in seconds) </label> <input id="MinimumIntroDuration" type="number" is="emby-input" min="1" /> <div class="fieldDescription"> Similar sounding audio which is shorter than this duration will not be considered an introduction. </div> </div> <div class="inputContainer"> <label class="inputLabel inputLabelUnfocused" for="MaximumIntroDuration"> Maximum introduction duration (in seconds) </label> <input id="MaximumIntroDuration" type="number" is="emby-input" min="1" /> <div class="fieldDescription"> Similar sounding audio which is longer than this duration will not be considered an introduction. </div> </div> <div class="inputContainer"> <label class="inputLabel inputLabelUnfocused" for="MinimumCreditsDuration"> Minimum credits duration (in seconds) </label> <input id="MinimumCreditsDuration" type="number" is="emby-input" min="1" /> <div class="fieldDescription"> Similar sounding audio which is shorter than this duration will not be considered credits. </div> </div> <div class="inputContainer"> <label class="inputLabel inputLabelUnfocused" for="MaximumCreditsDuration"> Maximum credits duration (in seconds) </label> <input id="MaximumCreditsDuration" type="number" is="emby-input" min="1" /> <div class="fieldDescription"> Similar sounding audio which is longer than this duration will not be considered credits. </div> </div> <p> The amount of each episode's audio that will be analyzed is determined using both the percentage of audio and maximum runtime of audio to analyze. The minimum of (episode duration * percent, maximum runtime) is the amount of audio that will be analyzed. </p> <p> If the audio percentage or maximum runtime settings are modified, the cached fingerprints and introduction timestamps for each season you want to analyze with the modified settings <strong>will have to be deleted.</strong> Increasing either of these settings will cause episode analysis to take much longer. </p> </details> <details id="edl"> <summary>EDL File Generation</summary> <br /> <div class="selectContainer"> <label class="selectLabel" for="EdlAction">EDL Action</label> <select is="emby-select" id="EdlAction" class="emby-select-withcolor emby-select"> <option value="None"> None (do not create or modify EDL files) </option> <option value="CommercialBreak"> Commercial Break (recommended, skips past the intro once) </option> <option value="Cut"> Cut (player will remove the intro from the video) </option> <option value="Intro"> Intro/Credit (show a skip button, *experimental*) </option> <option value="Mute"> Mute (audio will be muted) </option> <option value="SceneMarker"> Scene Marker (create a chapter marker) </option> </select> <div class="fieldDescription"> If set to a value other than None, specifies which action to write to <a href="https://kodi.wiki/view/Edit_decision_list">MPlayer compatible EDL files</a> alongside your episode files. <br /> If this value is changed after EDL files are generated, you must check the "Regenerate EDL files" checkbox below. </div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input id="RegenerateEdlFiles" type="checkbox" is="emby-checkbox" /> <span>Regenerate EDL files during next scan</span> </label> <div class="fieldDescription"> If checked, the plugin will <strong>overwrite all EDL files</strong> associated with your episodes with the currently discovered introduction/credit timestamps and EDL action. </div> </div> </details> <details id="silence"> <summary>Silence Detection Options</summary> <br /> <div class="inputContainer"> <label class="inputLabel inputLabelUnfocused" for="SilenceDetectionMaximumNoise"> Noise tolerance </label> <input id="SilenceDetectionMaximumNoise" type="number" is="emby-input" min="-90" max="0" /> <div class="fieldDescription"> Noise tolerance in negative decibels. </div> </div> <div class="inputContainer"> <label class="inputLabel inputLabelUnfocused" for="SilenceDetectionMinimumDuration"> Minimum silence duration </label> <input id="SilenceDetectionMinimumDuration" type="number" is="emby-input" min="0" step="0.01" /> <div class="fieldDescription"> Minimum silence duration in seconds before adjusting introduction end time. </div> </div> </details> <details id="detection"> <summary>Process Configuration</summary> <br /> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input id="UseChromaprint" type="checkbox" is="emby-checkbox" /> <span>Chromaprint analysis</span> </label> <div class="fieldDescription"> If checked, analysis will use Chromaprint to compare episode audio and identify intros. <br /> <strong>WARNING: Disabling this option may result in incomplete or innaccurate analysis!</strong> <br /> </div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input id="CacheFingerprints" type="checkbox" is="emby-checkbox" /> <span>Cache episode fingerprints</span> </label> <div class="fieldDescription"> If checked, episode fingerprints will be saved on the filesystem to improve analysis speed. <br /> <strong>WARNING: May result in lengthy detection! Not recommended for large libraries!</strong> <br /> </div> </div> <div class="selectContainer"> <label class="selectLabel" for="ProcessPriority">ffmpeg Priority</label> <select is="emby-select" id="ProcessPriority" class="emby-select-withcolor emby-select"> <option value="Idle"> Idle </option> <option value="BelowNormal"> Below Normal </option> <option value="Normal"> Normal </option> <option value="AboveNormal"> Above Normal </option> <option value="High"> High </option> <option value="RealTime"> Highest </option> </select> <div class="fieldDescription"> Sets the relative priority of the analysis ffmpeg process to other parallel operations (ie. transcoding, chapter detection, etc). </div> </div> <div class="inputContainer"> <label class="inputLabel inputLabelUnfocused" for="ProcessThreads"> ffmpeg Threads </label> <input id="ProcessThreads" type="number" is="emby-input" min="0" max="16" /> <div class="fieldDescription"> Number of simultaneous processes to use for ffmpeg operations. <br /> This value is most often defined as 1 thread per CPU core, but setting a value of 0 (default) will use the maximum threads available. </div> </div> </details> </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. If you access Jellyfin through a reverse proxy, it must be configured to proxy web sockets.<br /> </div> </div> <div id="divSkipFirstEpisode" class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input id="SkipFirstEpisode" type="checkbox" is="emby-checkbox" /> <span>Play intro for first episode of a season</span> </label> <div class="fieldDescription"> If checked, auto skip will play the introduction of the first episode in a season.<br /> </div> <br /> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input id="AutoSkipCredits" type="checkbox" is="emby-checkbox" /> <span>Automatically skip credits</span> </label> <div class="fieldDescription"> If checked, credits will be automatically skipped. If you access Jellyfin through a reverse proxy, it must be configured to proxy web sockets.<br /> </div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input id="SkipButtonVisible" type="checkbox" is="emby-checkbox" /> <span>Show skip intro button</span> </label> <div class="fieldDescription"> If checked, a skip button will be displayed at the start of an episode's introduction. <strong>Only applies to the web interface and compatible applications.</strong> <br /> </div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input id="PersistSkipButton" type="checkbox" is="emby-checkbox" /> <span>Display button for intro duration</span> </label> <div class="fieldDescription"> If checked, skip button will remain visible throught the intro (offset and timeout are ignored). <br /> Note: If unchecked, button will only appear in the player controls after the set timeout. </div> </div> <div id="divShowPromptAdjustment" class="inputContainer"> <label class="inputLabel inputLabelUnfocused" for="ShowPromptAdjustment"> Skip prompt offset (in seoncds) </label> <input id="ShowPromptAdjustment" type="number" is="emby-input" min="0" /> <div class="fieldDescription"> Seconds to display skip prompt before introduction begins. </div> </div> <br /> <div id="divHidePromptAdjustment" class="inputContainer"> <label class="inputLabel inputLabelUnfocused" for="HidePromptAdjustment"> Skip prompt timeout (in seconds) </label> <input id="HidePromptAdjustment" type="number" is="emby-input" min="2" /> <div class="fieldDescription"> Seconds after introduction before skip prompt is hidden. </div> </div> <br /> <div class="inputContainer"> <label class="inputLabel inputLabelUnfocused" for="SecondsOfIntroToPlay"> Intro playback duration (in seconds) </label> <input id="SecondsOfIntroToPlay" type="number" is="emby-input" min="0" /> <div class="fieldDescription"> Seconds of introduction ending that should be played. Defaults to 2. </div> </div> <details> <summary>User Interface Customization</summary> <br /> <div class="inputContainer"> <label class="inputLabel" for="SkipButtonIntroText"> Skip intro button text </label> <input id="SkipButtonIntroText" type="text" is="emby-input" /> <div class="fieldDescription"> Text to display in the skip intro button. </div> </div> <div class="inputContainer"> <label class="inputLabel" for="SkipButtonEndCreditsText"> Skip end credits button text </label> <input id="SkipButtonEndCreditsText" type="text" is="emby-input" /> <div class="fieldDescription"> Text to display in the skip end credits button. </div> </div> <div id="divAutoSkipNotificationText" class="inputContainer"> <label class="inputLabel" for="AutoSkipNotificationText"> Automatic skip notification message </label> <input id="AutoSkipNotificationText" type="text" is="emby-input" /> <div class="fieldDescription"> Message shown after automatically skipping an introduction. Leave blank to disable notification. </div> </div> <div id="divAutoSkipCreditsNotificationText" class="inputContainer"> <label class="inputLabel" for="AutoSkipCreditsNotificationText"> Automatic skip notification message </label> <input id="AutoSkipCreditsNotificationText" type="text" is="emby-input" /> <div class="fieldDescription"> Message shown after automatically skipping credits. Leave blank to disable notification. </div> </div> </details> </fieldset> <div> <button is="emby-button" type="submit" class="raised button-submit block emby-button"> <span>Save</span> </button> </div> </form> <br /> <fieldset class="verticalSection-extrabottompadding"> <legend>Advanced</legend> <details id="support"> <summary>Support Bundle Info</summary> <textarea id="supportBundle" rows="20" cols="75" readonly></textarea> </details> <details id="visualizer"> <summary>Manage Fingerprints</summary> <br /> <h3 style="margin:0">Select episodes to manage</h3> <br /> <select id="troubleshooterShow"></select> <select id="troubleshooterSeason"></select> <br /> <select id="troubleshooterEpisode1"></select> <select id="troubleshooterEpisode2"></select> <br /> <br /> <div id="timestampEditor" style="display:none"> <h3 style="margin:0">Introduction timestamp editor</h3> <p style="margin:0">All times are in seconds.</p> <p id="editLeftEpisodeTitle" style="margin-bottom:0"></p> <input style="width:4em" type="number" min="0" id="editLeftEpisodeStart"> to <input style="width:4em;margin-bottom:10px" type="number" min="0" id="editLeftEpisodeEnd"> <p id="editRightEpisodeTitle" style="margin-top:0;margin-bottom:0"></p> <input style="width:4em" type="number" min="0" id="editRightEpisodeStart"> to <input style="width:4em;margin-bottom:10px" type="number" min="0" id="editRightEpisodeEnd"> <br /> <br /> <button id="btnUpdateTimestamps" type="button"> Update timestamps </button> <br /> </div> <div id="timestampErrorDiv" style="display:none"> <textarea id="timestampError" rows="2" cols="75" readonly></textarea> <br /> <br /> </div> <h3>Fingerprint Visualizer</h3> <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.1238 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.1238 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 /> <span>Shift amount:</span> <input type="number" min="-3000" max="3000" value="0" id="offset"> <br /> <span id="suggestedShifts">Suggested shifts:</span> <br /> <br /> <canvas id="troubleshooter" style="display:none;"></canvas> <span id="timestampContainer"> <span id="timestamps"></span> <br /> <span id="intros"></span> </span> <br /> <br /> <button id="btnEraseSeasonTimestamps" type="button" style="display:none;"> Erase all timestamps for this season </button> <hr /> <button id="btnEraseIntroTimestamps"> Erase all introduction timestamps (globally) </button> <br /> <button id="btnEraseCreditTimestamps"> Erase all end credits timestamps (globally) </button> </details> </fieldset> </div> </div> <script src="configurationpage?name=visualizer.js"></script> <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 = {}; // settings elements var visualizer = document.querySelector("details#visualizer"); var support = document.querySelector("details#support"); var btnEraseIntroTimestamps = document.querySelector("button#btnEraseIntroTimestamps"); var btnEraseCreditTimestamps = document.querySelector("button#btnEraseCreditTimestamps"); // all plugin configuration fields that can be get or set with .value (i.e. strings or numbers). var configurationFields = [ // analysis "MaxParallelism", "SelectedLibraries", "AnalysisPercent", "AnalysisLengthLimit", "MinimumIntroDuration", "MaximumIntroDuration", "MinimumCreditsDuration", "MaximumCreditsDuration", "EdlAction", "ProcessPriority", "ProcessThreads", // playback "ShowPromptAdjustment", "HidePromptAdjustment", "SecondsOfIntroToPlay", // internals "SilenceDetectionMaximumNoise", "SilenceDetectionMinimumDuration", // UI customization "SkipButtonIntroText", "SkipButtonEndCreditsText", "AutoSkipNotificationText", "AutoSkipCreditsNotificationText" ] var booleanConfigurationFields = [ "AutoDetectIntros", "AutoDetectCredits", "AnalyzeSeasonZero", "RegenerateEdlFiles", "UseChromaprint", "CacheFingerprints", "AutoSkip", "AutoSkipCredits", "SkipFirstEpisode", "PersistSkipButton", "SkipButtonVisible" ] // visualizer 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 txtSuggested = document.querySelector("span#suggestedShifts"); var btnSeasonEraseTimestamps = document.querySelector("button#btnEraseSeasonTimestamps"); var timestampError = document.querySelector("textarea#timestampError"); var timestampEditor = document.querySelector("#timestampEditor"); var btnUpdateTimestamps = document.querySelector("button#btnUpdateTimestamps"); var timeContainer = document.querySelector("span#timestampContainer"); var windowHashInterval = 0; var autoSkip = document.querySelector("input#AutoSkip"); var skipFirstEpisode = document.querySelector("div#divSkipFirstEpisode"); var autoSkipNotificationText = document.querySelector("div#divAutoSkipNotificationText"); var autoSkipCredits = document.querySelector("input#AutoSkipCredits"); var autoSkipCreditsNotificationText = document.querySelector("div#divAutoSkipCreditsNotificationText"); async function autoSkipChanged() { if (autoSkip.checked) { skipFirstEpisode.style.display = 'unset'; autoSkipNotificationText.style.display = 'unset'; } else { skipFirstEpisode.style.display = 'none'; autoSkipNotificationText.style.display = 'none'; } } autoSkip.addEventListener("change", autoSkipChanged); async function autoSkipCreditsChanged() { if (autoSkipCredits.checked) { autoSkipCreditsNotificationText.style.display = 'unset'; } else { autoSkipCreditsNotificationText.style.display = 'none'; } } autoSkipCredits.addEventListener("change", autoSkipCreditsChanged); var persistSkip = document.querySelector("input#PersistSkipButton"); var showAdjustment = document.querySelector("div#divShowPromptAdjustment"); var hideAdjustment = document.querySelector("div#divHidePromptAdjustment"); // prevent setting unavailable options async function persistSkipChanged() { if (persistSkip.checked) { showAdjustment.style.display = 'none'; hideAdjustment.style.display = 'none'; } else { showAdjustment.style.display = 'unset'; hideAdjustment.style.display = 'unset'; } } persistSkip.addEventListener("change", persistSkipChanged); // when the fingerprint visualizer opens, populate show names async function visualizerToggled() { if (!visualizer.open) { return; } // ensure the series select is empty while (selectShow.options.length > 0) { selectShow.remove(0); } Dashboard.showLoadingMsg(); 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 = ""; Dashboard.hideLoadingMsg(); } // fetch the support bundle whenever the detail section is opened. async function supportToggled() { if (!support.open) { return; } // Fetch the support bundle const bundle = await fetchWithAuth("IntroSkipper/SupportBundle", "GET", null); const bundleText = await bundle.text(); // Display it to the user and select all const ta = document.querySelector("textarea#supportBundle"); ta.value = bundleText; ta.focus(); ta.setSelectionRange(0, ta.value.length); // Attempt to copy it to the clipboard automatically, falling back // to prompting the user to press Ctrl + C. try { navigator.clipboard.writeText(bundleText); Dashboard.alert("Support bundle copied to clipboard"); } catch { Dashboard.alert("Press Ctrl+C to copy support bundle"); } } // show changed, populate seasons async function showChanged() { clearSelect(selectSeason); btnSeasonEraseTimestamps.style.display = "none"; clearSelect(selectEpisode1); clearSelect(selectEpisode2); // 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); btnSeasonEraseTimestamps.style.display = "unset"; 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(); timestampError.value = ""; canvas.style.display = "none"; lhs = await getJson("Intros/Episode/" + selectEpisode1.value + "/Chromaprint"); if (lhs === undefined) { timestampError.value += "Error: " + selectEpisode1.value + " fingerprints failed!\n"; } else if (lhs === null) { timestampError.value += "Error: " + selectEpisode1.value + " fingerprints missing!\n"; } rhs = await getJson("Intros/Episode/" + selectEpisode2.value + "/Chromaprint"); if (rhs === undefined) { timestampError.value += "Error: " + selectEpisode2.value + " fingerprints failed!"; } else if (rhs === null) { timestampError.value += "Error: " + selectEpisode2.value + " fingerprints missing!\n"; } if (timestampError.value == "") { timestampErrorDiv.style.display = "none"; } else { timestampErrorDiv.style.display = "unset"; } Dashboard.hideLoadingMsg(); txtOffset.value = "0"; refreshBounds(); renderTroubleshooter(); findExactMatches(); updateTimestampEditor(); } // updates the timestamp editor async function updateTimestampEditor() { // Get the title and ID of the left and right episodes const leftEpisode = selectEpisode1.options[selectEpisode1.selectedIndex]; const rightEpisode = selectEpisode2.options[selectEpisode2.selectedIndex]; // Try to get the timestamps of each intro, falling back a default value of zero if no intro was found let leftEpisodeIntro = await getJson("Episode/" + leftEpisode.value + "/IntroTimestamps/v1"); if (leftEpisodeIntro === null) { leftEpisodeIntro = { IntroStart: 0, IntroEnd: 0 }; } let rightEpisodeIntro = await getJson("Episode/" + rightEpisode.value + "/IntroTimestamps/v1"); if (rightEpisodeIntro === null) { rightEpisodeIntro = { IntroStart: 0, IntroEnd: 0 }; } // Update the editor for the first and second episodes timestampEditor.style.display = "unset"; document.querySelector("#editLeftEpisodeTitle").textContent = leftEpisode.text; document.querySelector("#editLeftEpisodeStart").value = Math.round(leftEpisodeIntro.IntroStart); document.querySelector("#editLeftEpisodeEnd").value = Math.round(leftEpisodeIntro.IntroEnd); document.querySelector("#editRightEpisodeTitle").textContent = rightEpisode.text; document.querySelector("#editRightEpisodeStart").value = Math.round(rightEpisodeIntro.IntroStart); document.querySelector("#editRightEpisodeEnd").value = Math.round(rightEpisodeIntro.IntroEnd); } // 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) { timestampError.value = ""; timestampErrorDiv.style.display = "none"; timestampEditor.style.display = "none"; timeContainer.style.display = "none"; canvas.style.display = "none"; let i, L = select.options.length - 1; for (i = L; i >= 0; i--) { select.remove(i); } } // make an authenticated GET to the server and parse the response as JSON async function getJson(url) { return await fetchWithAuth(url, "GET") .then(r => { if (r.ok) { return r.json(); } else { return null; } }) .catch(err => { console.debug(err); }); } // make an authenticated fetch to the server async function fetchWithAuth(url, method, body) { url = ApiClient.serverAddress() + "/" + url; const reqInit = { method: method, headers: { "Authorization": "MediaBrowser Token=" + ApiClient.accessToken() }, body: body, }; if (method === "POST") { reqInit.headers["Content-Type"] = "application/json"; } return await fetch(url, reqInit); } // key pressed function keyDown(e) { let episodeDelta = 0; let offsetDelta = 0; switch (e.key) { case "ArrowDown": if (timestampError.value != "") { // if the control key is pressed, shift LHS by 10s. Otherwise, shift by 1. offsetDelta = e.ctrlKey ? 10 / 0.1238 : 1; } break; case "ArrowUp": if (timestampError.value != "") { offsetDelta = e.ctrlKey ? -10 / 0.1238 : -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(); } // 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); } // erase all intro/credits timestamps function eraseTimestamps(mode) { const lower = mode.toLocaleLowerCase(); const title = "Confirm timestamp erasure"; const body = "Are you sure you want to erase all previously discovered " + mode.toLocaleLowerCase() + " timestamps?"; Dashboard.confirm( body, title, (result) => { if (!result) { return; } fetchWithAuth("Intros/EraseTimestamps?mode=" + mode, "POST", null); Dashboard.alert(mode + " timestamps erased"); }); } document.querySelector('#TemplateConfigPage') .addEventListener('pageshow', function () { Dashboard.showLoadingMsg(); ApiClient.getPluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b").then(function (config) { for (const field of configurationFields) { document.querySelector("#" + field).value = config[field]; } for (const field of booleanConfigurationFields) { document.querySelector("#" + field).checked = config[field]; } autoSkipChanged(); autoSkipCreditsChanged(); persistSkipChanged(); Dashboard.hideLoadingMsg(); }); }); document.querySelector('#FingerprintConfigForm') .addEventListener('submit', function (e) { Dashboard.showLoadingMsg(); ApiClient.getPluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b").then(function (config) { for (const field of configurationFields) { config[field] = document.querySelector("#" + field).value; } for (const field of booleanConfigurationFields) { config[field] = document.querySelector("#" + field).checked; } ApiClient.updatePluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b", config) .then(function (result) { Dashboard.processPluginConfigurationUpdateResult(result); }); }); e.preventDefault(); return false; }); visualizer.addEventListener("toggle", visualizerToggled); support.addEventListener("toggle", supportToggled); txtOffset.addEventListener("change", renderTroubleshooter); selectShow.addEventListener("change", showChanged); selectSeason.addEventListener("change", seasonChanged); selectEpisode1.addEventListener("change", episodeChanged); selectEpisode2.addEventListener("change", episodeChanged); btnEraseIntroTimestamps.addEventListener("click", (e) => { eraseTimestamps("Introduction"); e.preventDefault(); }); btnEraseCreditTimestamps.addEventListener("click", (e) => { eraseTimestamps("Credits"); e.preventDefault(); }); btnSeasonEraseTimestamps.addEventListener("click", () => { Dashboard.confirm( "Are you sure you want to erase all timestamps for this season?", "Confirm timestamp erasure", (result) => { if (!result) { return; } const show = selectShow.value; const season = selectSeason.value; const url = "Intros/Show/" + encodeURIComponent(show) + "/" + encodeURIComponent(season); fetchWithAuth(url, "DELETE", null); Dashboard.alert("Erased timestamps for " + season + " of " + show); } ); }); btnUpdateTimestamps.addEventListener("click", () => { const lhsId = selectEpisode1.options[selectEpisode1.selectedIndex].value; const newLhsIntro = { IntroStart: document.querySelector("#editLeftEpisodeStart").value, IntroEnd: document.querySelector("#editLeftEpisodeEnd").value, }; const rhsId = selectEpisode2.options[selectEpisode2.selectedIndex].value; const newRhsIntro = { IntroStart: document.querySelector("#editRightEpisodeStart").value, IntroEnd: document.querySelector("#editRightEpisodeEnd").value, }; fetchWithAuth("Intros/Episode/" + lhsId + "/UpdateIntroTimestamps", "POST", JSON.stringify(newLhsIntro)); fetchWithAuth("Intros/Episode/" + rhsId + "/UpdateIntroTimestamps", "POST", JSON.stringify(newRhsIntro)); Dashboard.alert("New introduction timestamps saved"); }); 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.1238; rTime = (y + shift) * 0.1238; diffPos = y + shift; } else { lTime = (y - shift) * 0.1238; rTime = y * 0.1238; 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"; }); </script> </div> </body> </html>