2020-07-04 23:33:19 +09:00
|
|
|
<!DOCTYPE html>
|
|
|
|
<html lang="en">
|
2022-05-30 02:23:36 -05:00
|
|
|
|
2020-07-04 23:33:19 +09:00
|
|
|
<head>
|
|
|
|
<meta charset="utf-8">
|
|
|
|
</head>
|
2022-05-30 02:23:36 -05:00
|
|
|
|
2020-07-04 23:33:19 +09:00
|
|
|
<body>
|
2022-05-30 02:23:36 -05:00
|
|
|
<div id="TemplateConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
|
|
|
|
data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
2020-07-04 23:33:19 +09:00
|
|
|
<div data-role="content">
|
|
|
|
<div class="content-primary">
|
2022-05-05 18:10:34 -05:00
|
|
|
<form id="FingerprintConfigForm">
|
2022-06-09 20:34:18 -05:00
|
|
|
<fieldset class="verticalSection-extrabottompadding">
|
|
|
|
<legend>Analysis</legend>
|
|
|
|
|
|
|
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
|
|
<label class="emby-checkbox-label">
|
2022-09-27 21:42:51 -05:00
|
|
|
<input id="AnalyzeSeasonZero" type="checkbox" is="emby-checkbox" />
|
|
|
|
<span>Analyze show extras</span>
|
2022-06-09 20:34:18 -05:00
|
|
|
</label>
|
|
|
|
|
|
|
|
<div class="fieldDescription">
|
2022-09-27 21:42:51 -05:00
|
|
|
Analyze show extras (specials).
|
2022-06-09 20:34:18 -05:00
|
|
|
</div>
|
2022-06-07 18:33:59 -05:00
|
|
|
</div>
|
2022-05-05 18:10:34 -05:00
|
|
|
|
2022-06-09 20:34:18 -05:00
|
|
|
<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>
|
2022-06-27 00:21:30 -05:00
|
|
|
|
|
|
|
<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>
|
2022-07-08 00:57:12 -05:00
|
|
|
|
2022-07-29 03:34:55 -05:00
|
|
|
<details id="edl">
|
2022-07-08 01:02:50 -05:00
|
|
|
<summary>EDL file generation</summary>
|
2022-06-20 01:39:56 -05:00
|
|
|
|
2022-07-08 01:02:50 -05:00
|
|
|
<div class="selectContainer">
|
2022-07-16 21:53:08 -05:00
|
|
|
<label class="selectLabel" for="EdlAction">EDL Action</label>
|
2022-07-08 01:02:50 -05:00
|
|
|
<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 (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 />
|
|
|
|
|
2022-07-17 01:54:05 -05:00
|
|
|
If this value is changed after EDL files are generated, you must check the
|
|
|
|
"Regenerate EDL files" checkbox below.
|
2022-07-08 01:02:50 -05:00
|
|
|
</div>
|
2022-06-15 01:00:03 -05:00
|
|
|
</div>
|
2022-07-08 01:02:50 -05:00
|
|
|
|
|
|
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
|
|
<label class="emby-checkbox-label">
|
2022-09-25 00:11:07 -05:00
|
|
|
<input id="RegenerateEdlFiles" type="checkbox" is="emby-checkbox" />
|
2022-07-08 01:02:50 -05:00
|
|
|
<span>Regenerate EDL files during next scan</span>
|
|
|
|
</label>
|
|
|
|
|
|
|
|
<div class="fieldDescription">
|
2022-07-17 01:54:05 -05:00
|
|
|
If checked, the plugin will <strong>overwrite all EDL files</strong> associated with
|
|
|
|
your episodes with the currently discovered introduction timestamps and EDL action.
|
2022-07-08 01:02:50 -05:00
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</details>
|
|
|
|
|
2022-07-29 03:34:55 -05:00
|
|
|
<details id="intro_reqs">
|
2022-06-26 22:54:47 -05:00
|
|
|
<summary>Modify introduction requirements</summary>
|
|
|
|
|
|
|
|
<div class="inputContainer">
|
2022-07-16 21:53:08 -05:00
|
|
|
<label class="inputLabel inputLabelUnfocused" for="AnalysisPercent">
|
2022-06-26 22:54:47 -05:00
|
|
|
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">
|
2022-07-16 21:53:08 -05:00
|
|
|
<label class="inputLabel inputLabelUnfocused" for="AnalysisLengthLimit">
|
2022-06-26 22:54:47 -05:00
|
|
|
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>
|
|
|
|
|
2022-07-03 01:59:16 -05:00
|
|
|
<div class="inputContainer">
|
2022-07-16 22:12:42 -05:00
|
|
|
<label class="inputLabel inputLabelUnfocused" for="MinimumIntroDuration">
|
2022-07-03 01:59:16 -05:00
|
|
|
Minimum introduction duration (in seconds)
|
|
|
|
</label>
|
2022-07-16 22:12:42 -05:00
|
|
|
<input id="MinimumIntroDuration" type="number" is="emby-input" min="1" />
|
2022-07-03 01:59:16 -05:00
|
|
|
<div class="fieldDescription">
|
|
|
|
Similar sounding audio which is shorter than this duration will not be considered an
|
|
|
|
introduction.
|
|
|
|
</div>
|
|
|
|
</div>
|
2022-06-26 22:54:47 -05:00
|
|
|
|
2022-09-03 00:39:35 -05:00
|
|
|
<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>
|
|
|
|
|
2022-07-03 01:59:16 -05:00
|
|
|
<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
|
2022-07-08 00:57:12 -05:00
|
|
|
be analyzed.
|
2022-06-26 22:54:47 -05:00
|
|
|
</p>
|
|
|
|
|
2022-07-08 00:57:12 -05:00
|
|
|
<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>
|
|
|
|
|
2022-07-03 01:59:16 -05:00
|
|
|
Increasing either of these settings will cause episode analysis to take much longer.
|
2022-07-08 00:57:12 -05:00
|
|
|
</p>
|
2022-06-26 22:54:47 -05:00
|
|
|
</details>
|
2022-09-02 01:27:49 -05:00
|
|
|
|
|
|
|
<details id="silence">
|
|
|
|
<summary>Silence detection options</summary>
|
|
|
|
|
|
|
|
<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>
|
2022-06-09 20:34:18 -05:00
|
|
|
</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">
|
2022-09-27 21:42:51 -05:00
|
|
|
If checked, intros will be automatically skipped. If you access Jellyfin through a reverse proxy, it must be configured to proxy web
|
|
|
|
sockets.<br />
|
2022-06-09 20:34:18 -05:00
|
|
|
</div>
|
2022-05-05 18:10:34 -05:00
|
|
|
</div>
|
2022-05-05 18:26:02 -05:00
|
|
|
|
2022-09-02 01:54:49 -05:00
|
|
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
|
|
<label class="emby-checkbox-label">
|
|
|
|
<input id="SkipFirstEpisode" type="checkbox" is="emby-checkbox" />
|
|
|
|
<span>Automatically skip intros in the first episode of a season</span>
|
|
|
|
</label>
|
|
|
|
|
|
|
|
<div class="fieldDescription">
|
|
|
|
If checked, auto skip will skip introductions in the first episode of a season.<br />
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
2022-06-09 20:34:18 -05:00
|
|
|
<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>
|
2022-05-05 18:26:02 -05:00
|
|
|
</div>
|
|
|
|
|
2022-06-09 20:34:18 -05:00
|
|
|
<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>
|
2022-05-05 18:26:02 -05:00
|
|
|
</div>
|
2022-09-02 01:27:49 -05:00
|
|
|
|
|
|
|
<div class="inputContainer">
|
|
|
|
<label class="inputLabel inputLabelUnfocused" for="SecondsOfIntroToPlay">
|
|
|
|
Seconds of intro to play
|
|
|
|
</label>
|
|
|
|
<input id="SecondsOfIntroToPlay" type="number" is="emby-input" min="0" />
|
|
|
|
<div class="fieldDescription">
|
|
|
|
Seconds of introduction that should be played. Defaults to 2.
|
|
|
|
</div>
|
|
|
|
</div>
|
2022-06-09 20:34:18 -05:00
|
|
|
</fieldset>
|
2022-05-05 18:26:02 -05:00
|
|
|
|
2020-07-04 23:33:19 +09:00
|
|
|
<div>
|
|
|
|
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
|
|
|
<span>Save</span>
|
|
|
|
</button>
|
2022-06-07 12:18:03 -05:00
|
|
|
<br />
|
|
|
|
|
|
|
|
<button id="btnEraseTimestamps" is="emby-button" class="raised block emby-button">
|
|
|
|
<span>Erase introduction timestamps</span>
|
|
|
|
</button>
|
2020-07-04 23:33:19 +09:00
|
|
|
</div>
|
|
|
|
</form>
|
|
|
|
</div>
|
2022-05-30 02:23:36 -05:00
|
|
|
|
2022-08-25 00:39:20 -05:00
|
|
|
<details id="support">
|
|
|
|
<summary>Support Bundle</summary>
|
|
|
|
|
|
|
|
<textarea id="supportBundle" rows="20" cols="75" readonly></textarea>
|
|
|
|
</details>
|
|
|
|
|
2022-06-29 20:52:16 -05:00
|
|
|
<details id="visualizer">
|
2022-07-17 02:13:02 -05:00
|
|
|
<summary>Advanced</summary>
|
2022-05-31 16:12:11 -05:00
|
|
|
|
2022-07-17 02:13:02 -05:00
|
|
|
<h3 style="margin:0">Select episodes to manage</h3>
|
|
|
|
<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 />
|
|
|
|
|
|
|
|
<button id="btnUpdateTimestamps" type="button">
|
|
|
|
Update timestamps
|
|
|
|
</button>
|
|
|
|
<br />
|
|
|
|
<br />
|
|
|
|
|
|
|
|
<button id="btnEraseSeasonTimestamps" type="button">
|
|
|
|
Erase all timestamps for this season
|
|
|
|
</button>
|
|
|
|
</div>
|
|
|
|
<br />
|
|
|
|
<br />
|
|
|
|
|
|
|
|
<h3>Fingerprint Visualizer</h3>
|
2022-05-31 16:12:11 -05:00
|
|
|
<p>
|
|
|
|
Interactively compare the audio fingerprints of two episodes. <br />
|
|
|
|
The blue and red bar to the right of the fingerprint diff turns blue
|
2022-06-01 21:52:40 -05:00
|
|
|
when the corresponding fingerprint points are at least 80% similar.
|
2022-05-31 16:12:11 -05:00
|
|
|
</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 />
|
2022-05-30 02:23:36 -05:00
|
|
|
|
2022-05-31 16:12:11 -05:00
|
|
|
<span>Shift amount:</span>
|
2022-05-30 02:23:36 -05:00
|
|
|
<input type="number" min="-3000" max="3000" value="0" id="offset">
|
|
|
|
<br />
|
2022-07-03 01:47:55 -05:00
|
|
|
<span id="suggestedShifts">Suggested shifts:</span>
|
|
|
|
<br />
|
2022-05-30 02:23:36 -05:00
|
|
|
<br />
|
|
|
|
|
|
|
|
<canvas id="troubleshooter"></canvas>
|
2022-06-01 21:52:40 -05:00
|
|
|
<span id="timestampContainer">
|
|
|
|
<span id="timestamps"></span> <br />
|
|
|
|
<span id="intros"></span>
|
|
|
|
</span>
|
2022-05-31 16:12:11 -05:00
|
|
|
</details>
|
2020-07-04 23:33:19 +09:00
|
|
|
</div>
|
2022-07-16 23:35:15 -05:00
|
|
|
|
|
|
|
<script src="configurationpage?name=visualizer.js"></script>
|
|
|
|
|
2022-05-30 02:23:36 -05:00
|
|
|
<script>
|
|
|
|
// first and second episodes to fingerprint & compare
|
|
|
|
var lhs = [];
|
|
|
|
var rhs = [];
|
2022-05-31 16:12:11 -05:00
|
|
|
|
2022-06-01 21:52:40 -05:00
|
|
|
// fingerprint point comparison & miminum similarity threshold (at most 6 bits out of 32 can be different)
|
2022-05-31 16:12:11 -05:00
|
|
|
var fprDiffs = [];
|
2022-06-01 21:52:40 -05:00
|
|
|
var fprDiffMinimum = (1 - 6 / 32) * 100;
|
2022-05-30 02:23:36 -05:00
|
|
|
|
|
|
|
// seasons grouped by show
|
|
|
|
var shows = {};
|
|
|
|
|
2022-07-03 01:20:33 -05:00
|
|
|
// settings elements
|
2022-06-29 20:52:16 -05:00
|
|
|
var visualizer = document.querySelector("details#visualizer");
|
2022-08-25 00:39:20 -05:00
|
|
|
var support = document.querySelector("details#support");
|
2022-07-03 01:20:33 -05:00
|
|
|
var btnEraseTimestamps = document.querySelector("button#btnEraseTimestamps");
|
|
|
|
|
2022-07-16 22:12:42 -05:00
|
|
|
// all plugin configuration fields that can be get or set with .value (i.e. strings or numbers).
|
|
|
|
var configurationFields = [
|
2022-09-02 01:27:49 -05:00
|
|
|
// analysis
|
2022-07-16 22:12:42 -05:00
|
|
|
"MaxParallelism",
|
|
|
|
"SelectedLibraries",
|
|
|
|
"AnalysisPercent",
|
|
|
|
"AnalysisLengthLimit",
|
|
|
|
"MinimumIntroDuration",
|
2022-09-03 00:39:35 -05:00
|
|
|
"MaximumIntroDuration",
|
2022-07-16 22:12:42 -05:00
|
|
|
"EdlAction",
|
2022-09-02 01:27:49 -05:00
|
|
|
// playback
|
2022-07-16 22:12:42 -05:00
|
|
|
"ShowPromptAdjustment",
|
2022-09-02 01:27:49 -05:00
|
|
|
"HidePromptAdjustment",
|
|
|
|
"SecondsOfIntroToPlay",
|
|
|
|
// internals
|
|
|
|
"SilenceDetectionMaximumNoise",
|
|
|
|
"SilenceDetectionMinimumDuration",
|
2022-07-16 22:12:42 -05:00
|
|
|
]
|
|
|
|
|
2022-09-02 01:54:49 -05:00
|
|
|
var booleanConfigurationFields = [
|
2022-09-27 21:42:51 -05:00
|
|
|
"AnalyzeSeasonZero",
|
2022-09-25 00:11:07 -05:00
|
|
|
"RegenerateEdlFiles",
|
2022-09-02 01:54:49 -05:00
|
|
|
"AutoSkip",
|
|
|
|
"SkipFirstEpisode"
|
|
|
|
]
|
|
|
|
|
2022-07-03 01:20:33 -05:00
|
|
|
// visualizer elements
|
2022-06-01 23:53:12 -05:00
|
|
|
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");
|
2022-07-03 01:47:55 -05:00
|
|
|
var txtSuggested = document.querySelector("span#suggestedShifts");
|
2022-07-03 01:20:33 -05:00
|
|
|
var btnSeasonEraseTimestamps = document.querySelector("button#btnEraseSeasonTimestamps");
|
2022-07-17 01:54:05 -05:00
|
|
|
var btnUpdateTimestamps = document.querySelector("button#btnUpdateTimestamps");
|
2022-06-01 23:53:12 -05:00
|
|
|
var timeContainer = document.querySelector("span#timestampContainer");
|
2022-07-03 01:20:33 -05:00
|
|
|
|
2022-06-02 01:06:15 -05:00
|
|
|
var windowHashInterval = 0;
|
2022-05-30 02:23:36 -05:00
|
|
|
|
2022-06-29 20:52:16 -05:00
|
|
|
// when the fingerprint visualizer opens, populate show names
|
|
|
|
async function visualizerToggled() {
|
|
|
|
if (!visualizer.open) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2022-08-30 02:39:10 -05:00
|
|
|
// ensure the series select is empty
|
|
|
|
while (selectShow.options.length > 0) {
|
|
|
|
selectShow.remove(0);
|
|
|
|
}
|
|
|
|
|
2022-06-29 20:52:16 -05:00
|
|
|
Dashboard.showLoadingMsg();
|
|
|
|
|
2022-05-30 02:23:36 -05:00
|
|
|
shows = await getJson("Intros/Shows");
|
|
|
|
|
2022-05-31 16:12:11 -05:00
|
|
|
var sorted = [];
|
|
|
|
for (var series in shows) { sorted.push(series); }
|
|
|
|
sorted.sort();
|
|
|
|
|
|
|
|
for (var show of sorted) {
|
2022-05-30 02:23:36 -05:00
|
|
|
addItem(selectShow, show, show);
|
|
|
|
}
|
|
|
|
|
|
|
|
selectShow.value = "";
|
2022-06-29 20:52:16 -05:00
|
|
|
|
|
|
|
Dashboard.hideLoadingMsg();
|
2022-05-30 02:23:36 -05:00
|
|
|
}
|
|
|
|
|
2022-08-25 00:39:20 -05:00
|
|
|
// 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();
|
|
|
|
|
2022-10-26 19:30:21 -05:00
|
|
|
// 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);
|
2022-08-25 00:39:20 -05:00
|
|
|
|
2022-10-26 19:30:21 -05:00
|
|
|
// Attempt to copy it to the clipboard automatically, falling back
|
|
|
|
// to prompting the user to press Ctrl + C.
|
2022-08-25 00:39:20 -05:00
|
|
|
try {
|
2022-10-26 19:30:21 -05:00
|
|
|
navigator.clipboard.writeText(bundleText);
|
2022-08-25 00:39:20 -05:00
|
|
|
Dashboard.alert("Support bundle copied to clipboard");
|
|
|
|
} catch {
|
2022-10-26 19:30:21 -05:00
|
|
|
Dashboard.alert("Press Ctrl+C to copy support bundle");
|
2022-08-25 00:39:20 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-30 02:23:36 -05:00
|
|
|
// 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++;
|
|
|
|
}
|
|
|
|
|
2022-05-31 16:12:11 -05:00
|
|
|
setTimeout(() => {
|
|
|
|
selectEpisode1.selectedIndex = 0;
|
|
|
|
selectEpisode2.selectedIndex = 1;
|
|
|
|
episodeChanged();
|
|
|
|
}, 100);
|
2022-05-30 02:23:36 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
// episode changed, get fingerprints & calculate diff
|
|
|
|
async function episodeChanged() {
|
|
|
|
if (!selectEpisode1.value || !selectEpisode2.value) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
Dashboard.showLoadingMsg();
|
|
|
|
|
2022-07-17 01:54:05 -05:00
|
|
|
lhs = await getJson("Intros/Episode/" + selectEpisode1.value + "/Chromaprint");
|
|
|
|
rhs = await getJson("Intros/Episode/" + selectEpisode2.value + "/Chromaprint");
|
2022-05-30 02:23:36 -05:00
|
|
|
|
|
|
|
Dashboard.hideLoadingMsg();
|
|
|
|
|
2022-07-17 01:59:42 -05:00
|
|
|
txtOffset.value = "0";
|
2022-05-30 02:23:36 -05:00
|
|
|
refreshBounds();
|
|
|
|
renderTroubleshooter();
|
2022-07-03 01:47:55 -05:00
|
|
|
findExactMatches();
|
2022-07-17 01:54:05 -05:00
|
|
|
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
|
2022-07-18 01:15:33 -05:00
|
|
|
let leftEpisodeIntro = await getJson("Episode/" + leftEpisode.value + "/IntroTimestamps/v1");
|
|
|
|
if (!leftEpisodeIntro.hasOwnProperty("IntroStart")) {
|
|
|
|
leftEpisodeIntro = { IntroStart: 0, IntroEnd: 0 };
|
|
|
|
}
|
2022-07-17 01:54:05 -05:00
|
|
|
|
2022-07-18 01:15:33 -05:00
|
|
|
let rightEpisodeIntro = await getJson("Episode/" + rightEpisode.value + "/IntroTimestamps/v1");
|
|
|
|
if (!rightEpisodeIntro.hasOwnProperty("IntroStart")) {
|
|
|
|
rightEpisodeIntro = { IntroStart: 0, IntroEnd: 0 };
|
2022-07-17 01:54:05 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
// Update the editor for the first and second episodes
|
|
|
|
document.querySelector("#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);
|
2022-05-30 02:23:36 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// make an authenticated GET to the server and parse the response as JSON
|
2022-07-18 00:38:38 -05:00
|
|
|
async function getJson(url) {
|
|
|
|
return await fetchWithAuth(url, "GET").then(r => { return r.json(); });
|
2022-07-17 01:54:05 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
// make an authenticated fetch to the server
|
|
|
|
async function fetchWithAuth(url, method, body) {
|
2022-05-30 02:23:36 -05:00
|
|
|
url = ApiClient.serverAddress() + "/" + url;
|
|
|
|
|
|
|
|
const reqInit = {
|
2022-06-07 12:18:03 -05:00
|
|
|
method: method,
|
2022-05-30 02:23:36 -05:00
|
|
|
headers: {
|
|
|
|
"Authorization": "MediaBrowser Token=" + ApiClient.accessToken()
|
2022-07-17 01:54:05 -05:00
|
|
|
},
|
|
|
|
body: body,
|
2022-05-30 02:23:36 -05:00
|
|
|
};
|
|
|
|
|
2022-07-17 01:54:05 -05:00
|
|
|
if (method === "POST") {
|
|
|
|
reqInit.headers["Content-Type"] = "application/json";
|
|
|
|
}
|
|
|
|
|
|
|
|
return await fetch(url, reqInit);
|
2022-05-30 02:23:36 -05:00
|
|
|
}
|
2020-07-04 23:33:19 +09:00
|
|
|
|
2022-05-31 16:12:11 -05:00
|
|
|
// 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();
|
|
|
|
}
|
|
|
|
|
2022-06-02 01:06:15 -05:00
|
|
|
// check that the user is still on the configuration page
|
|
|
|
function checkWindowHash() {
|
2022-06-07 12:18:03 -05:00
|
|
|
const h = location.hash;
|
|
|
|
if (h === "#!/configurationpage?name=Intro%20Skipper" || h.includes("#!/dialog")) {
|
2022-06-02 01:06:15 -05:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
console.debug("navigated away from intro skipper configuration page");
|
|
|
|
document.removeEventListener("keydown", keyDown);
|
|
|
|
clearInterval(windowHashInterval);
|
|
|
|
}
|
|
|
|
|
2022-05-31 16:12:11 -05:00
|
|
|
// converts seconds to a readable timestamp (i.e. 127 becomes "02:07").
|
|
|
|
function secondsToString(seconds) {
|
|
|
|
return new Date(seconds * 1000).toISOString().substr(14, 5);
|
|
|
|
}
|
|
|
|
|
2020-12-02 17:47:22 -07:00
|
|
|
document.querySelector('#TemplateConfigPage')
|
2022-05-30 02:23:36 -05:00
|
|
|
.addEventListener('pageshow', function () {
|
2020-12-02 17:47:22 -07:00
|
|
|
Dashboard.showLoadingMsg();
|
2022-05-31 16:12:11 -05:00
|
|
|
ApiClient.getPluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b").then(function (config) {
|
2022-07-16 22:12:42 -05:00
|
|
|
for (const field of configurationFields) {
|
|
|
|
document.querySelector("#" + field).value = config[field];
|
|
|
|
}
|
2022-05-05 18:26:02 -05:00
|
|
|
|
2022-09-02 01:54:49 -05:00
|
|
|
for (const field of booleanConfigurationFields) {
|
|
|
|
document.querySelector("#" + field).checked = config[field];
|
|
|
|
}
|
|
|
|
|
2020-12-02 17:47:22 -07:00
|
|
|
Dashboard.hideLoadingMsg();
|
2022-05-05 18:10:34 -05:00
|
|
|
});
|
2021-01-10 00:25:51 +09:00
|
|
|
});
|
2022-05-05 18:10:34 -05:00
|
|
|
|
|
|
|
document.querySelector('#FingerprintConfigForm')
|
2022-06-02 01:06:15 -05:00
|
|
|
.addEventListener('submit', function (e) {
|
2022-05-30 02:23:36 -05:00
|
|
|
Dashboard.showLoadingMsg();
|
2022-05-31 16:12:11 -05:00
|
|
|
ApiClient.getPluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b").then(function (config) {
|
2022-07-16 22:12:42 -05:00
|
|
|
for (const field of configurationFields) {
|
|
|
|
config[field] = document.querySelector("#" + field).value;
|
|
|
|
}
|
2022-05-05 18:26:02 -05:00
|
|
|
|
2022-09-02 01:54:49 -05:00
|
|
|
for (const field of booleanConfigurationFields) {
|
|
|
|
config[field] = document.querySelector("#" + field).checked;
|
|
|
|
}
|
|
|
|
|
2022-06-20 01:39:56 -05:00
|
|
|
ApiClient.updatePluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b", config)
|
|
|
|
.then(function (result) {
|
|
|
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
|
|
|
});
|
2020-07-04 23:33:19 +09:00
|
|
|
});
|
2022-05-30 02:23:36 -05:00
|
|
|
|
2022-06-02 01:06:15 -05:00
|
|
|
e.preventDefault();
|
2022-05-30 02:23:36 -05:00
|
|
|
return false;
|
2020-07-04 23:33:19 +09:00
|
|
|
});
|
2021-01-10 00:25:51 +09:00
|
|
|
|
2022-06-29 20:52:16 -05:00
|
|
|
visualizer.addEventListener("toggle", visualizerToggled);
|
2022-08-25 00:39:20 -05:00
|
|
|
support.addEventListener("toggle", supportToggled);
|
2022-05-30 02:23:36 -05:00
|
|
|
txtOffset.addEventListener("change", renderTroubleshooter);
|
|
|
|
selectShow.addEventListener("change", showChanged);
|
|
|
|
selectSeason.addEventListener("change", seasonChanged);
|
|
|
|
selectEpisode1.addEventListener("change", episodeChanged);
|
|
|
|
selectEpisode2.addEventListener("change", episodeChanged);
|
2022-06-07 12:18:03 -05:00
|
|
|
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
|
2022-07-18 00:38:38 -05:00
|
|
|
fetchWithAuth("Intros/EraseTimestamps", "POST", null);
|
2022-06-07 12:18:03 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
});
|
2022-07-03 01:20:33 -05:00
|
|
|
btnSeasonEraseTimestamps.addEventListener("click", () => {
|
2022-07-17 02:13:02 -05:00
|
|
|
Dashboard.confirm(
|
|
|
|
"Are you sure you want to erase all timestamps for this season?",
|
|
|
|
"Confirm timestamp erasure",
|
|
|
|
(result) => {
|
|
|
|
if (!result) {
|
|
|
|
return;
|
|
|
|
}
|
2022-07-03 01:20:33 -05:00
|
|
|
|
2022-07-17 02:13:02 -05:00
|
|
|
const show = selectShow.value;
|
|
|
|
const season = selectSeason.value;
|
2022-07-03 01:20:33 -05:00
|
|
|
|
2022-07-17 02:13:02 -05:00
|
|
|
const url = "Intros/Show/" + encodeURIComponent(show) + "/" + encodeURIComponent(season);
|
2022-07-18 00:38:38 -05:00
|
|
|
fetchWithAuth(url, "DELETE", null);
|
2022-07-17 02:13:02 -05:00
|
|
|
|
|
|
|
Dashboard.alert("Erased timestamps for " + season + " of " + show);
|
|
|
|
}
|
|
|
|
);
|
2022-07-03 01:20:33 -05:00
|
|
|
});
|
2022-07-17 01:54:05 -05:00
|
|
|
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");
|
|
|
|
});
|
2022-06-02 01:06:15 -05:00
|
|
|
document.addEventListener("keydown", keyDown);
|
|
|
|
windowHashInterval = setInterval(checkWindowHash, 2500);
|
2022-05-30 02:23:36 -05:00
|
|
|
|
|
|
|
canvas.addEventListener("mousemove", (e) => {
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
|
|
const y = e.clientY - rect.top;
|
|
|
|
const shift = Number(txtOffset.value);
|
|
|
|
|
2022-05-30 03:24:19 -05:00
|
|
|
let lTime, rTime, diffPos;
|
2022-05-30 02:23:36 -05:00
|
|
|
if (shift < 0) {
|
|
|
|
lTime = y * 0.128;
|
|
|
|
rTime = (y + shift) * 0.128;
|
2022-05-30 03:24:19 -05:00
|
|
|
diffPos = y + shift;
|
2022-05-30 02:23:36 -05:00
|
|
|
} else {
|
|
|
|
lTime = (y - shift) * 0.128;
|
|
|
|
rTime = y * 0.128;
|
2022-05-30 03:24:19 -05:00
|
|
|
diffPos = y - shift;
|
2022-05-30 02:23:36 -05:00
|
|
|
}
|
|
|
|
|
2022-05-31 16:12:11 -05:00
|
|
|
const diff = fprDiffs[Math.floor(diffPos)];
|
2022-05-30 02:23:36 -05:00
|
|
|
|
2022-05-31 16:12:11 -05:00
|
|
|
if (!diff) {
|
2022-06-01 21:52:40 -05:00
|
|
|
timeContainer.style.display = "none";
|
2022-05-31 16:12:11 -05:00
|
|
|
return;
|
|
|
|
} else {
|
2022-06-01 21:52:40 -05:00
|
|
|
timeContainer.style.display = "unset";
|
2022-05-31 16:12:11 -05:00
|
|
|
}
|
|
|
|
|
2022-06-01 23:53:12 -05:00
|
|
|
const times = document.querySelector("span#timestamps");
|
|
|
|
|
2022-05-31 16:12:11 -05:00
|
|
|
// LHS timestamp, RHS timestamp, percent similarity
|
|
|
|
times.textContent =
|
|
|
|
secondsToString(lTime) + ", " +
|
|
|
|
secondsToString(rTime) + ", " +
|
|
|
|
Math.round(diff) + "%";
|
2022-05-30 02:23:36 -05:00
|
|
|
|
2022-06-01 21:52:40 -05:00
|
|
|
timeContainer.style.position = "relative";
|
|
|
|
timeContainer.style.left = "25px";
|
|
|
|
timeContainer.style.top = (-1 * rect.height + y).toString() + "px";
|
2020-07-04 23:33:19 +09:00
|
|
|
});
|
2022-05-30 02:23:36 -05:00
|
|
|
</script>
|
2020-07-04 23:33:19 +09:00
|
|
|
</div>
|
|
|
|
</body>
|
2022-05-30 02:23:36 -05:00
|
|
|
|
2020-07-04 23:33:19 +09:00
|
|
|
</html>
|