analyze movies (#348)
* scan movies * Update ConfusedPolarBear.Plugin.IntroSkipper.csproj * fix * Update SegmentProvider.cs * fix * update * add movies to endpoints * Update * Update QueueManager.cs * revert * Update configPage.html Battery died. I’ll be back * “Borrow” show config to hide seasons * Add IsMovie to ShowInfos * remove unused usings * Add option to enable/disble movies * Use the left episode as movie editor * Timestamp erasure for movies * Add max credits duration for movies * Formatting and button style cleanup * remove fingerprint timings for movies * remove x2 from MaximumCreditsDuration in blackframe analyzer * Update SegmentProvider.cs * Update SegmentProvider.cs * Update SegmentProvider.cs * Update SegmentProvider.cs * Update BaseItemAnalyzerTask.cs --------- Co-authored-by: rlauu <46294892+rlauu@users.noreply.github.com> Co-authored-by: TwistedUmbrellaX <1173913+AbandonedCart@users.noreply.github.com> Co-authored-by: TwistedUmbrellaX <twistedumbrella@gmail.com>
This commit is contained in:
parent
f6c8fca28f
commit
5bc8913668
@ -22,6 +22,8 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
||||
|
||||
private readonly int _maximumCreditsDuration;
|
||||
|
||||
private readonly int _maximumMovieCreditsDuration;
|
||||
|
||||
private readonly int _blackFrameMinimumPercentage;
|
||||
|
||||
/// <summary>
|
||||
@ -32,7 +34,8 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
_minimumCreditsDuration = config.MinimumCreditsDuration;
|
||||
_maximumCreditsDuration = 2 * config.MaximumCreditsDuration;
|
||||
_maximumCreditsDuration = config.MaximumCreditsDuration;
|
||||
_maximumMovieCreditsDuration = config.MaximumMovieCreditsDuration;
|
||||
_blackFrameMinimumPercentage = config.BlackFrameMinimumPercentage;
|
||||
|
||||
_logger = logger;
|
||||
@ -66,7 +69,21 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
||||
break;
|
||||
}
|
||||
|
||||
// Pre-check to find reasonable starting point.
|
||||
var creditDuration = episode.IsMovie ? _maximumMovieCreditsDuration : _maximumCreditsDuration;
|
||||
|
||||
var chapters = Plugin.Instance!.GetChapters(episode.EpisodeId);
|
||||
var lastSuitableChapter = chapters.LastOrDefault(c =>
|
||||
{
|
||||
var start = TimeSpan.FromTicks(c.StartPositionTicks).TotalSeconds;
|
||||
return start >= _minimumCreditsDuration && start <= creditDuration;
|
||||
});
|
||||
|
||||
if (lastSuitableChapter is not null)
|
||||
{
|
||||
searchStart = TimeSpan.FromTicks(lastSuitableChapter.StartPositionTicks).TotalSeconds;
|
||||
isFirstEpisode = false;
|
||||
}
|
||||
|
||||
if (isFirstEpisode)
|
||||
{
|
||||
var scanTime = episode.Duration - searchStart;
|
||||
@ -83,9 +100,9 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
||||
|
||||
frames = FFmpegWrapper.DetectBlackFrames(episode, tr, _blackFrameMinimumPercentage);
|
||||
|
||||
if (searchStart > _maximumCreditsDuration)
|
||||
if (searchStart > creditDuration)
|
||||
{
|
||||
searchStart = _maximumCreditsDuration;
|
||||
searchStart = creditDuration;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -143,6 +160,8 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
||||
var end = TimeSpan.FromSeconds(lowerLimit);
|
||||
var firstFrameTime = 0.0;
|
||||
|
||||
var creditDuration = episode.IsMovie ? _maximumMovieCreditsDuration : _maximumCreditsDuration;
|
||||
|
||||
// Continue bisecting the end of the file until the range that contains the first black
|
||||
// frame is smaller than the maximum permitted error.
|
||||
while (start - end > _maximumError)
|
||||
@ -189,7 +208,7 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
|
||||
|
||||
if (TimeSpan.FromSeconds(upperLimit) - midpoint < _maximumError)
|
||||
{
|
||||
upperLimit = Math.Min(upperLimit + (0.5 * searchDistance), _maximumCreditsDuration);
|
||||
upperLimit = Math.Min(upperLimit + (0.5 * searchDistance), creditDuration);
|
||||
|
||||
// Reset start for a new search with the increased duration
|
||||
start = TimeSpan.FromSeconds(upperLimit);
|
||||
|
@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
@ -92,9 +91,10 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
var creditDuration = episode.IsMovie ? config.MaximumMovieCreditsDuration : config.MaximumCreditsDuration;
|
||||
var reversed = mode != AnalysisMode.Introduction;
|
||||
var (minDuration, maxDuration) = reversed
|
||||
? (config.MinimumCreditsDuration, config.MaximumCreditsDuration)
|
||||
? (config.MinimumCreditsDuration, creditDuration)
|
||||
: (config.MinimumIntroDuration, config.MaximumIntroDuration);
|
||||
|
||||
// Check all chapters
|
||||
|
@ -33,6 +33,11 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// </summary>
|
||||
public bool SelectAllLibraries { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether movies should be analyzed.
|
||||
/// </summary>
|
||||
public bool AnalyzeMovies { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of client to auto skip for.
|
||||
/// </summary>
|
||||
@ -107,7 +112,12 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// <summary>
|
||||
/// Gets or sets the upper limit (in seconds) on the length of each episode's audio track that will be analyzed when searching for ending credits.
|
||||
/// </summary>
|
||||
public int MaximumCreditsDuration { get; set; } = 300;
|
||||
public int MaximumCreditsDuration { get; set; } = 450;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the upper limit (in seconds) on the length of a movie segment that will be analyzed when searching for ending credits.
|
||||
/// </summary>
|
||||
public int MaximumMovieCreditsDuration { get; set; } = 900;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum percentage of a frame that must consist of black pixels before it is considered a black frame.
|
||||
|
@ -52,6 +52,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="AnalyzeMovies" type="checkbox" is="emby-checkbox" />
|
||||
<span>Analyze Movies</span>
|
||||
</label>
|
||||
|
||||
<div class="fieldDescription">If checked, movies will be included in analysis.</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="AnalyzeSeasonZero" type="checkbox" is="emby-checkbox" />
|
||||
@ -135,6 +144,13 @@
|
||||
|
||||
Increasing either of these settings will cause episode analysis to take much longer.
|
||||
</p>
|
||||
|
||||
<div class="inputContainer" id="movieCreditsDuration">
|
||||
<label class="inputLabel inputLabelUnfocused" for="MaximumMovieCreditsDuration"> Maximum movie credits duration (in seconds) </label>
|
||||
<input id="MaximumMovieCreditsDuration" type="number" is="emby-input" min="1" />
|
||||
<div class="fieldDescription">Segments longer than this duration will not be considered movie credits.</div>
|
||||
<br />upda
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details id="edl">
|
||||
@ -398,10 +414,12 @@
|
||||
<summary>Manage Timestamps & Fingerprints</summary>
|
||||
|
||||
<br />
|
||||
<label class="inputLabel" for="troubleshooterShow">Select TV Series to manage</label>
|
||||
<label class="inputLabel" for="troubleshooterShow">Select TV series / movie to manage</label>
|
||||
<select is="emby-select" id="troubleshooterShow" class="emby-select-withcolor emby-select"></select>
|
||||
<label class="inputLabel" for="troubleshooterSeason">Select Season to manage</label>
|
||||
<select is="emby-select" id="troubleshooterSeason" class="emby-select-withcolor emby-select"></select>
|
||||
<div id="seasonSelection">
|
||||
<label class="inputLabel" for="troubleshooterSeason">Select season to manage</label>
|
||||
<select is="emby-select" id="troubleshooterSeason" class="emby-select-withcolor emby-select"></select>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<div id="ignorelistSection" style="display: none">
|
||||
@ -424,16 +442,18 @@
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<button id="saveIgnoreListSeries" class="raised button-submit block emby-button">Apply to series</button>
|
||||
<button id="saveIgnoreListSeason" class="raised button-submit block emby-button" style="display: none">Apply to season</button>
|
||||
<button is="emby-button" id="saveIgnoreListSeries" class="raised button-submit block emby-button">Apply to series / movie</button>
|
||||
<button is="emby-button" id="saveIgnoreListSeason" class="raised button-submit block emby-button" style="display: none">Apply to season</button>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<label class="inputLabel" for="troubleshooterEpisode1">Select the first episode</label>
|
||||
<select is="emby-select" id="troubleshooterEpisode1" class="emby-select-withcolor emby-select"></select>
|
||||
<label class="inputLabel" for="troubleshooterEpisode1">Select the second episode</label>
|
||||
<select is="emby-select" id="troubleshooterEpisode2" class="emby-select-withcolor emby-select"></select>
|
||||
<br />
|
||||
<div id="episodeSelection">
|
||||
<label class="inputLabel" for="troubleshooterEpisode1">Select the first episode</label>
|
||||
<select is="emby-select" id="troubleshooterEpisode1" class="emby-select-withcolor emby-select"></select>
|
||||
<label class="inputLabel" for="troubleshooterEpisode1">Select the second episode</label>
|
||||
<select is="emby-select" id="troubleshooterEpisode2" class="emby-select-withcolor emby-select"></select>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<div id="timestampEditor" style="display: none">
|
||||
<h3 style="margin: 0">Introduction timestamp editor</h3>
|
||||
@ -465,34 +485,36 @@
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<h4 style="margin: 0" id="editRightEpisodeTitle"></h4>
|
||||
<br />
|
||||
<div class="inlineForm">
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="introStart">Intro Start</label>
|
||||
<input type="text" id="editRightIntroEpisodeStartDisplay" class="emby-input custom-time-input" readonly />
|
||||
<input type="number" id="editRightIntroEpisodeStartEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
|
||||
<div id="rightEpisodeEditor">
|
||||
<h4 style="margin: 0" id="editRightEpisodeTitle"></h4>
|
||||
<br />
|
||||
<div class="inlineForm">
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="introStart">Intro Start</label>
|
||||
<input type="text" id="editRightIntroEpisodeStartDisplay" class="emby-input custom-time-input" readonly />
|
||||
<input type="number" id="editRightIntroEpisodeStartEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="introEnd">Intro End</label>
|
||||
<input type="text" id="editRightIntroEpisodeEndDisplay" class="emby-input custom-time-input" readonly />
|
||||
<input type="number" id="editRightIntroEpisodeEndEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="introEnd">Intro End</label>
|
||||
<input type="text" id="editRightIntroEpisodeEndDisplay" class="emby-input custom-time-input" readonly />
|
||||
<input type="number" id="editRightIntroEpisodeEndEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
|
||||
<div class="inlineForm">
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="creditsStart">Credits Start</label>
|
||||
<input type="text" id="editRightCreditEpisodeStartDisplay" class="emby-input custom-time-input" readonly />
|
||||
<input type="number" id="editRightCreditEpisodeStartEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="creditsEnd">Credits End</label>
|
||||
<input type="text" id="editRightCreditEpisodeEndDisplay" class="emby-input custom-time-input" readonly />
|
||||
<input type="number" id="editRightCreditEpisodeEndEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<div class="inlineForm">
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="creditsStart">Credits Start</label>
|
||||
<input type="text" id="editRightCreditEpisodeStartDisplay" class="emby-input custom-time-input" readonly />
|
||||
<input type="number" id="editRightCreditEpisodeStartEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="creditsEnd">Credits End</label>
|
||||
<input type="text" id="editRightCreditEpisodeEndDisplay" class="emby-input custom-time-input" readonly />
|
||||
<input type="number" id="editRightCreditEpisodeEndEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" />
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<button is="emby-select" id="btnUpdateTimestamps" class="raised button-submit block emby-button" type="button">Update timestamps</button>
|
||||
<button is="emby-button" id="btnUpdateTimestamps" class="raised button-submit block emby-button" type="button">Update timestamps</button>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
@ -502,70 +524,85 @@
|
||||
<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>
|
||||
<tr>
|
||||
<td style="min-width: 100px; font-weight: bold">Key</td>
|
||||
<td style="font-weight: bold">Function</td>
|
||||
</tr>
|
||||
</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 />
|
||||
<div id="fingerprintVisualizer">
|
||||
|
||||
<span>Shift amount:</span>
|
||||
<input type="number" min="-3000" max="3000" value="0" id="offset" />
|
||||
<br />
|
||||
<span id="suggestedShifts">Suggested shifts:</span>
|
||||
<br />
|
||||
<br />
|
||||
<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>
|
||||
<tr>
|
||||
<td style="min-width: 100px; font-weight: bold">Key</td>
|
||||
<td style="font-weight: bold">Function</td>
|
||||
</tr>
|
||||
</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 />
|
||||
|
||||
<canvas id="troubleshooter" style="display: none"></canvas>
|
||||
<span id="timestampContainer">
|
||||
<span id="timestamps"></span> <br />
|
||||
<span id="intros"></span>
|
||||
</span>
|
||||
<span>Shift amount:</span>
|
||||
<input type="number" min="-3000" max="3000" value="0" id="offset" />
|
||||
<br />
|
||||
<span id="suggestedShifts">
|
||||
<span>Suggested shifts:</span>
|
||||
<br />
|
||||
<br />
|
||||
</span>
|
||||
|
||||
<canvas id="troubleshooter" style="display: none"></canvas>
|
||||
<span id="timestampContainer">
|
||||
<span id="timestamps"></span> <br />
|
||||
<span id="intros"></span>
|
||||
</span>
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<div id="eraseSeasonContainer" style="display: none">
|
||||
<button id="btnEraseSeasonTimestamps" type="button">Erase all timestamps for this season</button>
|
||||
<button is="emby-button" id="btnEraseSeasonTimestamps" class="button-submit emby-button" type="button">Erase all timestamps for this season</button>
|
||||
|
||||
<input type="checkbox" id="eraseSeasonCacheCheckbox" style="margin-left: 10px" />
|
||||
<label for="eraseSeasonCacheCheckbox" style="margin-left: 5px">Erase cached fingerprint files</label>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<div id="eraseMovieContainer" style="display: none">
|
||||
<button is="emby-button" id="btnEraseMovieTimestamps" class="button-submit emby-button" type="button">Erase all timestamps for this movie</button>
|
||||
|
||||
<input type="checkbox" id="eraseMovieCacheCheckbox" style="margin-left: 10px" />
|
||||
<label for="eraseMovieCacheCheckbox" style="margin-left: 5px">Erase cached fingerprint files</label>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<button is="emby-button" class="button-submit emby-button" id="btnEraseIntroTimestamps">Erase all introduction timestamps (globally)</button>
|
||||
<br />
|
||||
|
||||
<button id="btnEraseIntroTimestamps">Erase all introduction timestamps (globally)</button>
|
||||
<button is="emby-button" class="button-submit emby-button" id="btnEraseCreditTimestamps">Erase all end credits timestamps (globally)</button>
|
||||
<br />
|
||||
|
||||
<button id="btnEraseCreditTimestamps">Erase all end credits timestamps (globally)</button>
|
||||
<br />
|
||||
|
||||
<input type="checkbox" id="eraseModeCacheCheckbox" style="margin-left: 10px" />
|
||||
<label for="eraseModeCacheCheckbox" style="margin-left: 5px">Erase cached fingerprint files</label>
|
||||
<br />
|
||||
<br />
|
||||
</details>
|
||||
|
||||
<details id="storage">
|
||||
@ -614,6 +651,7 @@
|
||||
"MaximumIntroDuration",
|
||||
"MinimumCreditsDuration",
|
||||
"MaximumCreditsDuration",
|
||||
"MaximumMovieCreditsDuration",
|
||||
"EdlAction",
|
||||
"ProcessPriority",
|
||||
"ProcessThreads",
|
||||
@ -633,7 +671,7 @@
|
||||
"AutoSkipCreditsNotificationText",
|
||||
];
|
||||
|
||||
var booleanConfigurationFields = ["AutoDetectIntros", "AutoDetectCredits", "AnalyzeSeasonZero", "SelectAllLibraries", "RegenerateEdlFiles", "CacheFingerprints", "AutoSkip", "AutoSkipCredits", "SkipFirstEpisode", "PersistSkipButton", "SkipButtonVisible"];
|
||||
var booleanConfigurationFields = ["AutoDetectIntros", "AutoDetectCredits", "AnalyzeMovies", "AnalyzeSeasonZero", "SelectAllLibraries", "RegenerateEdlFiles", "CacheFingerprints", "AutoSkip", "AutoSkipCredits", "SkipFirstEpisode", "PersistSkipButton", "SkipButtonVisible"];
|
||||
|
||||
// visualizer elements
|
||||
var ignorelistSection = document.querySelector("div#ignorelistSection");
|
||||
@ -643,20 +681,27 @@
|
||||
var saveIgnoreListSeriesButton = ignorelistSection.querySelector("button#saveIgnoreListSeries");
|
||||
var canvas = document.querySelector("canvas#troubleshooter");
|
||||
var selectShow = document.querySelector("select#troubleshooterShow");
|
||||
var seasonSelection = document.getElementById("seasonSelection");
|
||||
var selectSeason = document.querySelector("select#troubleshooterSeason");
|
||||
var episodeSelection = document.getElementById("episodeSelection");
|
||||
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 eraseSeasonContainer = document.getElementById("eraseSeasonContainer");
|
||||
var btnMovieEraseTimestamps = document.querySelector("button#btnEraseMovieTimestamps");
|
||||
var eraseMovieContainer = document.getElementById("eraseMovieContainer");
|
||||
var timestampError = document.querySelector("textarea#timestampError");
|
||||
var timestampEditor = document.querySelector("#timestampEditor");
|
||||
var rightEpisodeEditor = document.getElementById("rightEpisodeEditor");
|
||||
var btnUpdateTimestamps = document.querySelector("button#btnUpdateTimestamps");
|
||||
var timeContainer = document.querySelector("span#timestampContainer");
|
||||
var fingerprintVisualizer = document.getElementById("fingerprintVisualizer");
|
||||
|
||||
var windowHashInterval = 0;
|
||||
|
||||
var analyzeMovies = document.getElementById("AnalyzeMovies");
|
||||
var autoSkip = document.querySelector("input#AutoSkip");
|
||||
var skipButtonVisible = document.getElementById("SkipButtonVisible");
|
||||
var skipButtonVisibleLabel = document.getElementById("SkipButtonVisibleLabel");
|
||||
@ -667,6 +712,7 @@
|
||||
var secondsOfIntroStartToPlay = document.querySelector("div#divSecondsOfIntroStartToPlay");
|
||||
var autoSkipClientList = document.getElementById("AutoSkipClientList");
|
||||
var secondsOfCreditsStartToPlay = document.querySelector("div#divSecondsOfCreditsStartToPlay");
|
||||
var movieCreditsDuration = document.getElementById("movieCreditsDuration");
|
||||
var autoSkipNotificationText = document.querySelector("div#divAutoSkipNotificationText");
|
||||
var autoSkipCredits = document.querySelector("input#AutoSkipCredits");
|
||||
var autoSkipCreditsNotificationText = document.querySelector("div#divAutoSkipCreditsNotificationText");
|
||||
@ -778,7 +824,7 @@
|
||||
|
||||
async function populateLibraries() {
|
||||
const response = await getJson("Library/VirtualFolders");
|
||||
const tvLibraries = response.filter((item) => item.CollectionType === undefined || item.CollectionType === "tvshows");
|
||||
const tvLibraries = response.filter((item) => item.CollectionType === undefined || item.CollectionType === "tvshows" || item.CollectionType === "movies");
|
||||
const libraryNames = tvLibraries.map((lib) => lib.Name || "Unnamed Library");
|
||||
generateCheckboxList(libraryNames, "libraryCheckboxes", "SelectedLibraries");
|
||||
}
|
||||
@ -800,6 +846,16 @@
|
||||
|
||||
persistSkip.addEventListener("change", persistSkipChanged);
|
||||
|
||||
async function analyzeMoviesChanged() {
|
||||
if (analyzeMovies.checked) {
|
||||
movieCreditsDuration.style.display = "unset";
|
||||
} else {
|
||||
movieCreditsDuration.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
analyzeMovies.addEventListener("change", analyzeMoviesChanged);
|
||||
|
||||
// when the fingerprint visualizer opens, populate show names
|
||||
async function visualizerToggled() {
|
||||
if (!visualizer.open) {
|
||||
@ -891,8 +947,11 @@
|
||||
|
||||
// show changed, populate seasons
|
||||
async function showChanged() {
|
||||
seasonSelection.style.display = "unset";
|
||||
clearSelect(selectSeason);
|
||||
eraseSeasonContainer.style.display = "none";
|
||||
eraseMovieContainer.style.display = "none";
|
||||
episodeSelection.style.display = "unset";
|
||||
clearSelect(selectEpisode1);
|
||||
clearSelect(selectEpisode2);
|
||||
|
||||
@ -905,6 +964,11 @@
|
||||
saveIgnoreListSeasonButton.style.display = "none";
|
||||
Dashboard.hideLoadingMsg();
|
||||
|
||||
if (shows[selectShow.value].IsMovie) {
|
||||
movieLoaded();
|
||||
return;
|
||||
}
|
||||
|
||||
// add all seasons from this show to the season select
|
||||
for (const season in shows[selectShow.value].Seasons) {
|
||||
addItem(selectSeason, "Season " + shows[selectShow.value].Seasons[season], season);
|
||||
@ -955,6 +1019,7 @@
|
||||
Dashboard.showLoadingMsg();
|
||||
|
||||
timestampError.value = "";
|
||||
fingerprintVisualizer.style.display = "unset";
|
||||
canvas.style.display = "none";
|
||||
|
||||
lhs = await getJson("Intros/Episode/" + selectEpisode1.value + "/Chromaprint");
|
||||
@ -964,6 +1029,8 @@
|
||||
timestampError.value += "Error: " + selectEpisode1.value + " fingerprints missing!\n";
|
||||
}
|
||||
|
||||
rightEpisodeEditor.style.display = "unset";
|
||||
|
||||
rhs = await getJson("Intros/Episode/" + selectEpisode2.value + "/Chromaprint");
|
||||
if (rhs === undefined) {
|
||||
timestampError.value += "Error: " + selectEpisode2.value + " fingerprints failed!";
|
||||
@ -986,6 +1053,57 @@
|
||||
updateTimestampEditor();
|
||||
}
|
||||
|
||||
async function movieLoaded() {
|
||||
|
||||
Dashboard.showLoadingMsg();
|
||||
|
||||
seasonSelection.style.display = "none";
|
||||
episodeSelection.style.display = "none";
|
||||
eraseMovieContainer.style.display = "unset";
|
||||
|
||||
timestampError.value = "";
|
||||
fingerprintVisualizer.style.display = "none";
|
||||
|
||||
lhs = await getJson("Intros/Episode/" + selectShow.value + "/Chromaprint");
|
||||
if (lhs === undefined) {
|
||||
timestampError.value += "Error: " + selectShow.value + " fingerprints failed!\n";
|
||||
} else if (lhs === null) {
|
||||
timestampError.value += "Error: " + selectShow.value + " fingerprints missing!\n";
|
||||
}
|
||||
|
||||
rightEpisodeEditor.style.display = "none";
|
||||
|
||||
if (timestampError.value == "") {
|
||||
timestampErrorDiv.style.display = "none";
|
||||
} else {
|
||||
timestampErrorDiv.style.display = "unset";
|
||||
}
|
||||
|
||||
Dashboard.hideLoadingMsg();
|
||||
|
||||
txtOffset.value = "0";
|
||||
|
||||
// Try to get the timestamps of each intro, falling back a default value of zero if no intro was found
|
||||
const leftEpisodeJson = await getJson("Episode/" + selectShow.value + "/Timestamps");
|
||||
|
||||
// Update the editor for the first and second episodes
|
||||
timestampEditor.style.display = "unset";
|
||||
document.querySelector("#editLeftEpisodeTitle").textContent = selectShow.value;
|
||||
document.querySelector("#editLeftIntroEpisodeStartEdit").value = leftEpisodeJson.Introduction.Start;
|
||||
document.querySelector("#editLeftIntroEpisodeEndEdit").value = leftEpisodeJson.Introduction.End;
|
||||
document.querySelector("#editLeftCreditEpisodeStartEdit").value = leftEpisodeJson.Credits.Start;
|
||||
document.querySelector("#editLeftCreditEpisodeEndEdit").value = leftEpisodeJson.Credits.End
|
||||
|
||||
// Update display inputs
|
||||
const inputs = document.querySelectorAll('#timestampEditor input[type="number"]');
|
||||
inputs.forEach((input) => {
|
||||
const displayInput = document.getElementById(input.id.replace("Edit", "Display"));
|
||||
displayInput.value = formatTime(parseFloat(input.value) || 0);
|
||||
});
|
||||
|
||||
setupTimeInputs();
|
||||
}
|
||||
|
||||
function setupTimeInputs() {
|
||||
const timestampEditor = document.getElementById("timestampEditor");
|
||||
timestampEditor.querySelectorAll(".inputContainer").forEach((container) => {
|
||||
@ -1283,6 +1401,22 @@
|
||||
document.getElementById("eraseSeasonCacheCheckbox").checked = false;
|
||||
});
|
||||
});
|
||||
btnMovieEraseTimestamps.addEventListener("click", () => {
|
||||
Dashboard.confirm("Are you sure you want to erase all timestamps for this movie?", "Confirm timestamp erasure", (result) => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const show = selectShow.value;
|
||||
const eraseCacheChecked = document.getElementById("eraseMovieCacheCheckbox").checked;
|
||||
|
||||
const url = "Intros/Show/" + encodeURIComponent(show);
|
||||
fetchWithAuth(url + "?eraseCache=" + eraseCacheChecked, "DELETE", null);
|
||||
|
||||
Dashboard.alert("Erased timestamps for " + show);
|
||||
document.getElementById("eraseMovieCacheCheckbox").checked = false;
|
||||
});
|
||||
});
|
||||
saveIgnoreListSeasonButton.addEventListener("click", () => {
|
||||
Dashboard.showLoadingMsg();
|
||||
|
||||
|
@ -4,6 +4,7 @@ using System.Net.Mime;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@ -63,7 +64,7 @@ public class SkipIntroController : ControllerBase
|
||||
{
|
||||
// only update existing episodes
|
||||
var rawItem = Plugin.Instance!.GetItem(id);
|
||||
if (rawItem == null || rawItem is not Episode episode)
|
||||
if (rawItem == null || rawItem is not Episode and not Movie)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
@ -99,7 +100,7 @@ public class SkipIntroController : ControllerBase
|
||||
{
|
||||
// only get return content for episodes
|
||||
var rawItem = Plugin.Instance!.GetItem(id);
|
||||
if (rawItem == null || rawItem is not Episode episode)
|
||||
if (rawItem == null || rawItem is not Episode and not Movie)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
@ -47,7 +47,7 @@ public class VisualizationController(ILogger<VisualizationController> logger) :
|
||||
var seasonNumber = first.SeasonNumber;
|
||||
if (!showSeasons.TryGetValue(seriesId, out var showInfo))
|
||||
{
|
||||
showInfo = new ShowInfos { SeriesName = first.SeriesName, ProductionYear = GetProductionYear(seriesId), LibraryName = GetLibraryName(seriesId), Seasons = [] };
|
||||
showInfo = new ShowInfos { SeriesName = first.SeriesName, ProductionYear = GetProductionYear(seriesId), LibraryName = GetLibraryName(seriesId), IsMovie = first.IsMovie, Seasons = [] };
|
||||
showSeasons[seriesId] = showInfo;
|
||||
}
|
||||
|
||||
@ -65,6 +65,7 @@ public class VisualizationController(ILogger<VisualizationController> logger) :
|
||||
SeriesName = kvp.Value.SeriesName,
|
||||
ProductionYear = kvp.Value.ProductionYear,
|
||||
LibraryName = kvp.Value.LibraryName,
|
||||
IsMovie = kvp.Value.IsMovie,
|
||||
Seasons = kvp.Value.Seasons
|
||||
.OrderBy(s => s.Value)
|
||||
.ToDictionary(s => s.Key, s => s.Value)
|
||||
|
@ -47,6 +47,11 @@ public class QueuedEpisode
|
||||
/// </summary>
|
||||
public bool IsAnime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether an item is a movie.
|
||||
/// </summary>
|
||||
public bool IsMovie { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp (in seconds) to stop searching for an introduction at.
|
||||
/// </summary>
|
||||
|
@ -23,6 +23,11 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Controllers
|
||||
/// </summary>
|
||||
public required string LibraryName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether its a movie.
|
||||
/// </summary>
|
||||
public required bool IsMovie { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Seasons of the show.
|
||||
/// </summary>
|
||||
|
@ -6,6 +6,7 @@ using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@ -28,6 +29,7 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Manager
|
||||
private double _analysisPercent;
|
||||
private List<string> _selectedLibraries = [];
|
||||
private bool _selectAllLibraries;
|
||||
private bool _analyzeMovies;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all media items on the server.
|
||||
@ -90,6 +92,8 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Manager
|
||||
|
||||
_selectAllLibraries = config.SelectAllLibraries;
|
||||
|
||||
_analyzeMovies = config.AnalyzeMovies;
|
||||
|
||||
if (!_selectAllLibraries)
|
||||
{
|
||||
// Get the list of library names which have been selected for analysis, ignoring whitespace and empty entries.
|
||||
@ -123,7 +127,7 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Manager
|
||||
// Order by series name, season, and then episode number so that status updates are logged in order
|
||||
ParentId = id,
|
||||
OrderBy = [(ItemSortBy.SeriesSortName, SortOrder.Ascending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Ascending),],
|
||||
IncludeItemTypes = [BaseItemKind.Episode],
|
||||
IncludeItemTypes = [BaseItemKind.Episode, BaseItemKind.Movie],
|
||||
Recursive = true,
|
||||
IsVirtualItem = false
|
||||
};
|
||||
@ -141,13 +145,18 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Manager
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item is not Episode episode)
|
||||
if (item is Episode episode)
|
||||
{
|
||||
_logger.LogDebug("Item {Name} is not an episode", item.Name);
|
||||
continue;
|
||||
QueueEpisode(episode);
|
||||
}
|
||||
else if (_analyzeMovies && item is Movie movie)
|
||||
{
|
||||
QueueMovie(movie);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Item {Name} is not an episode or movie", item.Name);
|
||||
}
|
||||
|
||||
QueueEpisode(episode);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Queued {Count} episodes", items.Count);
|
||||
@ -197,8 +206,11 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Manager
|
||||
duration >= 5 * 60 ? duration * _analysisPercent : duration,
|
||||
60 * pluginInstance.Configuration.AnalysisLengthLimit);
|
||||
|
||||
var maxCreditsDuration = Math.Min(
|
||||
duration >= 5 * 60 ? duration * _analysisPercent : duration,
|
||||
60 * pluginInstance.Configuration.MaximumCreditsDuration);
|
||||
|
||||
// Queue the episode for analysis
|
||||
var maxCreditsDuration = pluginInstance.Configuration.MaximumCreditsDuration;
|
||||
seasonEpisodes.Add(new QueuedEpisode
|
||||
{
|
||||
SeriesName = episode.SeriesName,
|
||||
@ -216,6 +228,38 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Manager
|
||||
pluginInstance.TotalQueued++;
|
||||
}
|
||||
|
||||
private void QueueMovie(Movie movie)
|
||||
{
|
||||
var pluginInstance = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance was null");
|
||||
|
||||
if (string.IsNullOrEmpty(movie.Path))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Not queuing movie \"{Name}\" ({Id}) as no path was provided by Jellyfin",
|
||||
movie.Name,
|
||||
movie.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Allocate a new list for each Movie
|
||||
_queuedEpisodes.TryAdd(movie.Id, []);
|
||||
|
||||
var duration = TimeSpan.FromTicks(movie.RunTimeTicks ?? 0).TotalSeconds;
|
||||
|
||||
_queuedEpisodes[movie.Id].Add(new QueuedEpisode
|
||||
{
|
||||
SeriesName = movie.Name,
|
||||
SeriesId = movie.Id,
|
||||
EpisodeId = movie.Id,
|
||||
Name = movie.Name,
|
||||
Path = movie.Path,
|
||||
Duration = Convert.ToInt32(duration),
|
||||
IsMovie = true
|
||||
});
|
||||
|
||||
pluginInstance.TotalQueued++;
|
||||
}
|
||||
|
||||
private Guid GetSeasonId(Episode episode)
|
||||
{
|
||||
if (episode.ParentIndexNumber == 0 && episode.AiredSeasonNumber != 0) // In-season special
|
||||
|
@ -5,7 +5,6 @@ using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Model;
|
||||
using MediaBrowser.Model.MediaSegments;
|
||||
|
||||
@ -16,14 +15,14 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Providers
|
||||
/// </summary>
|
||||
public class SegmentProvider : IMediaSegmentProvider
|
||||
{
|
||||
private readonly int _remainingSecondsOfIntro;
|
||||
private readonly long _remainingTicks;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SegmentProvider"/> class.
|
||||
/// </summary>
|
||||
public SegmentProvider()
|
||||
{
|
||||
_remainingSecondsOfIntro = Plugin.Instance?.Configuration.RemainingSecondsOfIntro ?? 2;
|
||||
_remainingTicks = TimeSpan.FromSeconds(Plugin.Instance?.Configuration.RemainingSecondsOfIntro ?? 2).Ticks;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@ -39,7 +38,7 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Providers
|
||||
segments.Add(new MediaSegmentDto
|
||||
{
|
||||
StartTicks = TimeSpan.FromSeconds(introValue.Start).Ticks,
|
||||
EndTicks = TimeSpan.FromSeconds(introValue.End - _remainingSecondsOfIntro).Ticks,
|
||||
EndTicks = TimeSpan.FromSeconds(introValue.End).Ticks - _remainingTicks,
|
||||
ItemId = request.ItemId,
|
||||
Type = MediaSegmentType.Intro
|
||||
});
|
||||
@ -47,19 +46,31 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper.Providers
|
||||
|
||||
if (Plugin.Instance!.Credits.TryGetValue(request.ItemId, out var creditValue))
|
||||
{
|
||||
segments.Add(new MediaSegmentDto
|
||||
var outroSegment = new MediaSegmentDto
|
||||
{
|
||||
StartTicks = TimeSpan.FromSeconds(creditValue.Start).Ticks,
|
||||
EndTicks = TimeSpan.FromSeconds(creditValue.End - _remainingSecondsOfIntro).Ticks,
|
||||
ItemId = request.ItemId,
|
||||
Type = MediaSegmentType.Outro
|
||||
});
|
||||
};
|
||||
|
||||
var creditEndTicks = TimeSpan.FromSeconds(creditValue.End).Ticks;
|
||||
|
||||
if (Plugin.Instance.GetItem(request.ItemId) is IHasMediaSources item && creditEndTicks >= item.RunTimeTicks - TimeSpan.TicksPerSecond)
|
||||
{
|
||||
outroSegment.EndTicks = item.RunTimeTicks ?? creditEndTicks;
|
||||
}
|
||||
else
|
||||
{
|
||||
outroSegment.EndTicks = creditEndTicks - _remainingTicks;
|
||||
}
|
||||
|
||||
segments.Add(outroSegment);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<MediaSegmentDto>>(segments);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask<bool> Supports(BaseItem item) => ValueTask.FromResult(item is Episode);
|
||||
public ValueTask<bool> Supports(BaseItem item) => ValueTask.FromResult(item is IHasMediaSources);
|
||||
}
|
||||
}
|
||||
|
@ -123,11 +123,8 @@ public class BaseItemAnalyzerTask
|
||||
|
||||
Interlocked.Add(ref totalProcessed, episodes.Count * _analysisModes.Count); // Update total Processed directly
|
||||
progress.Report(totalProcessed * 100 / totalQueued);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (_analysisModes.Count != requiredModes.Count)
|
||||
else if (_analysisModes.Count != requiredModes.Count)
|
||||
{
|
||||
Interlocked.Add(ref totalProcessed, episodes.Count);
|
||||
progress.Report(totalProcessed * 100 / totalQueued); // Partial analysis some modes have already been analyzed
|
||||
@ -189,7 +186,7 @@ public class BaseItemAnalyzerTask
|
||||
|
||||
// Only analyze specials (season 0) if the user has opted in.
|
||||
var first = items[0];
|
||||
if (first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero)
|
||||
if (!first.IsMovie && first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
@ -212,7 +209,7 @@ public class BaseItemAnalyzerTask
|
||||
new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>())
|
||||
};
|
||||
|
||||
if (first.IsAnime && Plugin.Instance!.Configuration.WithChromaprint)
|
||||
if (first.IsAnime && Plugin.Instance!.Configuration.WithChromaprint && !first.IsMovie)
|
||||
{
|
||||
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
|
||||
}
|
||||
@ -222,7 +219,7 @@ public class BaseItemAnalyzerTask
|
||||
analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>()));
|
||||
}
|
||||
|
||||
if (!first.IsAnime && Plugin.Instance!.Configuration.WithChromaprint)
|
||||
if (!first.IsAnime && Plugin.Instance!.Configuration.WithChromaprint && !first.IsMovie)
|
||||
{
|
||||
analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
|
@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
|
@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
|
||||
|
Loading…
x
Reference in New Issue
Block a user