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>
This commit is contained in:
rlauuzo 2024-10-18 14:15:09 +02:00 committed by TwistedUmbrellaX
parent 7198dfdfca
commit 627ae05def
13 changed files with 314 additions and 107 deletions

View File

@ -22,6 +22,8 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
private readonly int _maximumCreditsDuration; private readonly int _maximumCreditsDuration;
private readonly int _maximumMovieCreditsDuration;
private readonly int _blackFrameMinimumPercentage; private readonly int _blackFrameMinimumPercentage;
/// <summary> /// <summary>
@ -32,7 +34,8 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
{ {
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
_minimumCreditsDuration = config.MinimumCreditsDuration; _minimumCreditsDuration = config.MinimumCreditsDuration;
_maximumCreditsDuration = 2 * config.MaximumCreditsDuration; _maximumCreditsDuration = config.MaximumCreditsDuration;
_maximumMovieCreditsDuration = config.MaximumMovieCreditsDuration;
_blackFrameMinimumPercentage = config.BlackFrameMinimumPercentage; _blackFrameMinimumPercentage = config.BlackFrameMinimumPercentage;
_logger = logger; _logger = logger;
@ -66,7 +69,21 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
break; 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) if (isFirstEpisode)
{ {
var scanTime = episode.Duration - searchStart; var scanTime = episode.Duration - searchStart;
@ -83,9 +100,9 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
frames = FFmpegWrapper.DetectBlackFrames(episode, tr, _blackFrameMinimumPercentage); frames = FFmpegWrapper.DetectBlackFrames(episode, tr, _blackFrameMinimumPercentage);
if (searchStart > _maximumCreditsDuration) if (searchStart > creditDuration)
{ {
searchStart = _maximumCreditsDuration; searchStart = creditDuration;
break; break;
} }
} }
@ -143,6 +160,8 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
var end = TimeSpan.FromSeconds(lowerLimit); var end = TimeSpan.FromSeconds(lowerLimit);
var firstFrameTime = 0.0; 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 // Continue bisecting the end of the file until the range that contains the first black
// frame is smaller than the maximum permitted error. // frame is smaller than the maximum permitted error.
while (start - end > _maximumError) while (start - end > _maximumError)
@ -189,7 +208,7 @@ public class BlackFrameAnalyzer : IMediaFileAnalyzer
if (TimeSpan.FromSeconds(upperLimit) - midpoint < _maximumError) 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 // Reset start for a new search with the increased duration
start = TimeSpan.FromSeconds(upperLimit); start = TimeSpan.FromSeconds(upperLimit);

View File

@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@ -92,9 +91,10 @@ public class ChapterAnalyzer(ILogger<ChapterAnalyzer> logger) : IMediaFileAnalyz
} }
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration(); var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
var creditDuration = episode.IsMovie ? config.MaximumMovieCreditsDuration : config.MaximumCreditsDuration;
var reversed = mode != AnalysisMode.Introduction; var reversed = mode != AnalysisMode.Introduction;
var (minDuration, maxDuration) = reversed var (minDuration, maxDuration) = reversed
? (config.MinimumCreditsDuration, config.MaximumCreditsDuration) ? (config.MinimumCreditsDuration, creditDuration)
: (config.MinimumIntroDuration, config.MaximumIntroDuration); : (config.MinimumIntroDuration, config.MaximumIntroDuration);
// Check all chapters // Check all chapters

View File

@ -33,6 +33,11 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary> /// </summary>
public bool SelectAllLibraries { get; set; } = true; 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> /// <summary>
/// Gets or sets the list of client to auto skip for. /// Gets or sets the list of client to auto skip for.
/// </summary> /// </summary>
@ -107,7 +112,12 @@ public class PluginConfiguration : BasePluginConfiguration
/// <summary> /// <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. /// 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> /// </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> /// <summary>
/// Gets or sets the minimum percentage of a frame that must consist of black pixels before it is considered a black frame. /// Gets or sets the minimum percentage of a frame that must consist of black pixels before it is considered a black frame.

View File

@ -52,6 +52,15 @@
</div> </div>
</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"> <div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label"> <label class="emby-checkbox-label">
<input id="AnalyzeSeasonZero" type="checkbox" is="emby-checkbox" /> <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. Increasing either of these settings will cause episode analysis to take much longer.
</p> </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>
<details id="edl"> <details id="edl">
@ -398,10 +414,12 @@
<summary>Manage Timestamps & Fingerprints</summary> <summary>Manage Timestamps & Fingerprints</summary>
<br /> <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> <select is="emby-select" id="troubleshooterShow" class="emby-select-withcolor emby-select"></select>
<label class="inputLabel" for="troubleshooterSeason">Select Season to manage</label> <div id="seasonSelection">
<select is="emby-select" id="troubleshooterSeason" 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>
<br /> <br />
<div id="ignorelistSection" style="display: none"> <div id="ignorelistSection" style="display: none">
@ -424,16 +442,18 @@
</div> </div>
<br /> <br />
<button is="emby-button" id="saveIgnoreListSeries" class="raised button-submit block emby-button">Apply to series</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> <button is="emby-button" id="saveIgnoreListSeason" class="raised button-submit block emby-button" style="display: none">Apply to season</button>
</div> </div>
<br /> <br />
<label class="inputLabel" for="troubleshooterEpisode1">Select the first episode</label> <div id="episodeSelection">
<select is="emby-select" id="troubleshooterEpisode1" class="emby-select-withcolor emby-select"></select> <label class="inputLabel" for="troubleshooterEpisode1">Select the first episode</label>
<label class="inputLabel" for="troubleshooterEpisode1">Select the second episode</label> <select is="emby-select" id="troubleshooterEpisode1" class="emby-select-withcolor emby-select"></select>
<select is="emby-select" id="troubleshooterEpisode2" class="emby-select-withcolor emby-select"></select> <label class="inputLabel" for="troubleshooterEpisode1">Select the second episode</label>
<br /> <select is="emby-select" id="troubleshooterEpisode2" class="emby-select-withcolor emby-select"></select>
<br />
</div>
<div id="timestampEditor" style="display: none"> <div id="timestampEditor" style="display: none">
<h3 style="margin: 0">Introduction timestamp editor</h3> <h3 style="margin: 0">Introduction timestamp editor</h3>
@ -465,33 +485,35 @@
</div> </div>
</div> </div>
<br /> <br />
<h4 style="margin: 0" id="editRightEpisodeTitle"></h4> <div id="rightEpisodeEditor">
<br /> <h4 style="margin: 0" id="editRightEpisodeTitle"></h4>
<div class="inlineForm"> <br />
<div class="inputContainer"> <div class="inlineForm">
<label class="inputLabel inputLabelUnfocused" for="introStart">Intro Start</label> <div class="inputContainer">
<input type="text" id="editRightIntroEpisodeStartDisplay" class="emby-input custom-time-input" readonly /> <label class="inputLabel inputLabelUnfocused" for="introStart">Intro Start</label>
<input type="number" id="editRightIntroEpisodeStartEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" /> <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>
<div class="inputContainer"> <div class="inlineForm">
<label class="inputLabel inputLabelUnfocused" for="introEnd">Intro End</label> <div class="inputContainer">
<input type="text" id="editRightIntroEpisodeEndDisplay" class="emby-input custom-time-input" readonly /> <label class="inputLabel inputLabelUnfocused" for="creditsStart">Credits Start</label>
<input type="number" id="editRightIntroEpisodeEndEdit" class="emby-input custom-time-input" style="display: none" step="any" min="0" /> <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> </div>
<br />
</div> </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-button" 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 /> <br />
</div> </div>
@ -502,51 +524,56 @@
<br /> <br />
</div> </div>
<h3>Fingerprint Visualizer</h3> <div id="fingerprintVisualizer">
<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 />
<span>Shift amount:</span> <h3>Fingerprint Visualizer</h3>
<input type="number" min="-3000" max="3000" value="0" id="offset" /> <p>
<br /> Interactively compare the audio fingerprints of two episodes. <br />
<span id="suggestedShifts">Suggested shifts:</span> The blue and red bar to the right of the fingerprint diff turns blue when the corresponding fingerprint points are at least 80% similar.
<br /> </p>
<br /> <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>Shift amount:</span>
<span id="timestampContainer"> <input type="number" min="-3000" max="3000" value="0" id="offset" />
<span id="timestamps"></span> <br /> <br />
<span id="intros"></span> <span id="suggestedShifts">
</span> <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 />
<br /> <br />
@ -555,8 +582,16 @@
<input type="checkbox" id="eraseSeasonCacheCheckbox" style="margin-left: 10px" /> <input type="checkbox" id="eraseSeasonCacheCheckbox" style="margin-left: 10px" />
<label for="eraseSeasonCacheCheckbox" style="margin-left: 5px">Erase cached fingerprint files</label> <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> </div>
<br />
<button is="emby-button" class="button-submit emby-button" id="btnEraseIntroTimestamps">Erase all introduction timestamps (globally)</button> <button is="emby-button" class="button-submit emby-button" id="btnEraseIntroTimestamps">Erase all introduction timestamps (globally)</button>
<br /> <br />
@ -616,6 +651,7 @@
"MaximumIntroDuration", "MaximumIntroDuration",
"MinimumCreditsDuration", "MinimumCreditsDuration",
"MaximumCreditsDuration", "MaximumCreditsDuration",
"MaximumMovieCreditsDuration",
"EdlAction", "EdlAction",
"ProcessPriority", "ProcessPriority",
"ProcessThreads", "ProcessThreads",
@ -635,7 +671,7 @@
"AutoSkipCreditsNotificationText", "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 // visualizer elements
var ignorelistSection = document.querySelector("div#ignorelistSection"); var ignorelistSection = document.querySelector("div#ignorelistSection");
@ -645,20 +681,27 @@
var saveIgnoreListSeriesButton = ignorelistSection.querySelector("button#saveIgnoreListSeries"); var saveIgnoreListSeriesButton = ignorelistSection.querySelector("button#saveIgnoreListSeries");
var canvas = document.querySelector("canvas#troubleshooter"); var canvas = document.querySelector("canvas#troubleshooter");
var selectShow = document.querySelector("select#troubleshooterShow"); var selectShow = document.querySelector("select#troubleshooterShow");
var seasonSelection = document.getElementById("seasonSelection");
var selectSeason = document.querySelector("select#troubleshooterSeason"); var selectSeason = document.querySelector("select#troubleshooterSeason");
var episodeSelection = document.getElementById("episodeSelection");
var selectEpisode1 = document.querySelector("select#troubleshooterEpisode1"); var selectEpisode1 = document.querySelector("select#troubleshooterEpisode1");
var selectEpisode2 = document.querySelector("select#troubleshooterEpisode2"); var selectEpisode2 = document.querySelector("select#troubleshooterEpisode2");
var txtOffset = document.querySelector("input#offset"); var txtOffset = document.querySelector("input#offset");
var txtSuggested = document.querySelector("span#suggestedShifts"); var txtSuggested = document.querySelector("span#suggestedShifts");
var btnSeasonEraseTimestamps = document.querySelector("button#btnEraseSeasonTimestamps"); var btnSeasonEraseTimestamps = document.querySelector("button#btnEraseSeasonTimestamps");
var eraseSeasonContainer = document.getElementById("eraseSeasonContainer"); var eraseSeasonContainer = document.getElementById("eraseSeasonContainer");
var btnMovieEraseTimestamps = document.querySelector("button#btnEraseMovieTimestamps");
var eraseMovieContainer = document.getElementById("eraseMovieContainer");
var timestampError = document.querySelector("textarea#timestampError"); var timestampError = document.querySelector("textarea#timestampError");
var timestampEditor = document.querySelector("#timestampEditor"); var timestampEditor = document.querySelector("#timestampEditor");
var rightEpisodeEditor = document.getElementById("rightEpisodeEditor");
var btnUpdateTimestamps = document.querySelector("button#btnUpdateTimestamps"); var btnUpdateTimestamps = document.querySelector("button#btnUpdateTimestamps");
var timeContainer = document.querySelector("span#timestampContainer"); var timeContainer = document.querySelector("span#timestampContainer");
var fingerprintVisualizer = document.getElementById("fingerprintVisualizer");
var windowHashInterval = 0; var windowHashInterval = 0;
var analyzeMovies = document.getElementById("AnalyzeMovies");
var autoSkip = document.querySelector("input#AutoSkip"); var autoSkip = document.querySelector("input#AutoSkip");
var skipButtonVisible = document.getElementById("SkipButtonVisible"); var skipButtonVisible = document.getElementById("SkipButtonVisible");
var skipButtonVisibleLabel = document.getElementById("SkipButtonVisibleLabel"); var skipButtonVisibleLabel = document.getElementById("SkipButtonVisibleLabel");
@ -669,6 +712,7 @@
var secondsOfIntroStartToPlay = document.querySelector("div#divSecondsOfIntroStartToPlay"); var secondsOfIntroStartToPlay = document.querySelector("div#divSecondsOfIntroStartToPlay");
var autoSkipClientList = document.getElementById("AutoSkipClientList"); var autoSkipClientList = document.getElementById("AutoSkipClientList");
var secondsOfCreditsStartToPlay = document.querySelector("div#divSecondsOfCreditsStartToPlay"); var secondsOfCreditsStartToPlay = document.querySelector("div#divSecondsOfCreditsStartToPlay");
var movieCreditsDuration = document.getElementById("movieCreditsDuration");
var autoSkipNotificationText = document.querySelector("div#divAutoSkipNotificationText"); var autoSkipNotificationText = document.querySelector("div#divAutoSkipNotificationText");
var autoSkipCredits = document.querySelector("input#AutoSkipCredits"); var autoSkipCredits = document.querySelector("input#AutoSkipCredits");
var autoSkipCreditsNotificationText = document.querySelector("div#divAutoSkipCreditsNotificationText"); var autoSkipCreditsNotificationText = document.querySelector("div#divAutoSkipCreditsNotificationText");
@ -780,7 +824,7 @@
async function populateLibraries() { async function populateLibraries() {
const response = await getJson("Library/VirtualFolders"); 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"); const libraryNames = tvLibraries.map((lib) => lib.Name || "Unnamed Library");
generateCheckboxList(libraryNames, "libraryCheckboxes", "SelectedLibraries"); generateCheckboxList(libraryNames, "libraryCheckboxes", "SelectedLibraries");
} }
@ -802,6 +846,16 @@
persistSkip.addEventListener("change", persistSkipChanged); 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 // when the fingerprint visualizer opens, populate show names
async function visualizerToggled() { async function visualizerToggled() {
if (!visualizer.open) { if (!visualizer.open) {
@ -893,8 +947,11 @@
// show changed, populate seasons // show changed, populate seasons
async function showChanged() { async function showChanged() {
seasonSelection.style.display = "unset";
clearSelect(selectSeason); clearSelect(selectSeason);
eraseSeasonContainer.style.display = "none"; eraseSeasonContainer.style.display = "none";
eraseMovieContainer.style.display = "none";
episodeSelection.style.display = "unset";
clearSelect(selectEpisode1); clearSelect(selectEpisode1);
clearSelect(selectEpisode2); clearSelect(selectEpisode2);
@ -907,6 +964,11 @@
saveIgnoreListSeasonButton.style.display = "none"; saveIgnoreListSeasonButton.style.display = "none";
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
if (shows[selectShow.value].IsMovie) {
movieLoaded();
return;
}
// add all seasons from this show to the season select // add all seasons from this show to the season select
for (const season in shows[selectShow.value].Seasons) { for (const season in shows[selectShow.value].Seasons) {
addItem(selectSeason, "Season " + shows[selectShow.value].Seasons[season], season); addItem(selectSeason, "Season " + shows[selectShow.value].Seasons[season], season);
@ -957,6 +1019,7 @@
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
timestampError.value = ""; timestampError.value = "";
fingerprintVisualizer.style.display = "unset";
canvas.style.display = "none"; canvas.style.display = "none";
lhs = await getJson("Intros/Episode/" + selectEpisode1.value + "/Chromaprint"); lhs = await getJson("Intros/Episode/" + selectEpisode1.value + "/Chromaprint");
@ -966,6 +1029,8 @@
timestampError.value += "Error: " + selectEpisode1.value + " fingerprints missing!\n"; timestampError.value += "Error: " + selectEpisode1.value + " fingerprints missing!\n";
} }
rightEpisodeEditor.style.display = "unset";
rhs = await getJson("Intros/Episode/" + selectEpisode2.value + "/Chromaprint"); rhs = await getJson("Intros/Episode/" + selectEpisode2.value + "/Chromaprint");
if (rhs === undefined) { if (rhs === undefined) {
timestampError.value += "Error: " + selectEpisode2.value + " fingerprints failed!"; timestampError.value += "Error: " + selectEpisode2.value + " fingerprints failed!";
@ -988,6 +1053,57 @@
updateTimestampEditor(); 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() { function setupTimeInputs() {
const timestampEditor = document.getElementById("timestampEditor"); const timestampEditor = document.getElementById("timestampEditor");
timestampEditor.querySelectorAll(".inputContainer").forEach((container) => { timestampEditor.querySelectorAll(".inputContainer").forEach((container) => {
@ -1285,6 +1401,22 @@
document.getElementById("eraseSeasonCacheCheckbox").checked = false; 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", () => { saveIgnoreListSeasonButton.addEventListener("click", () => {
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();

View File

@ -4,6 +4,7 @@ using System.Net.Mime;
using IntroSkipper.Configuration; using IntroSkipper.Configuration;
using IntroSkipper.Data; using IntroSkipper.Data;
using MediaBrowser.Common.Api; using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.TV;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -63,7 +64,7 @@ public class SkipIntroController : ControllerBase
{ {
// only update existing episodes // only update existing episodes
var rawItem = Plugin.Instance!.GetItem(id); 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(); return NotFound();
} }
@ -99,7 +100,7 @@ public class SkipIntroController : ControllerBase
{ {
// only get return content for episodes // only get return content for episodes
var rawItem = Plugin.Instance!.GetItem(id); 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(); return NotFound();
} }

View File

@ -47,7 +47,7 @@ public class VisualizationController(ILogger<VisualizationController> logger) :
var seasonNumber = first.SeasonNumber; var seasonNumber = first.SeasonNumber;
if (!showSeasons.TryGetValue(seriesId, out var showInfo)) 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; showSeasons[seriesId] = showInfo;
} }
@ -65,6 +65,7 @@ public class VisualizationController(ILogger<VisualizationController> logger) :
SeriesName = kvp.Value.SeriesName, SeriesName = kvp.Value.SeriesName,
ProductionYear = kvp.Value.ProductionYear, ProductionYear = kvp.Value.ProductionYear,
LibraryName = kvp.Value.LibraryName, LibraryName = kvp.Value.LibraryName,
IsMovie = kvp.Value.IsMovie,
Seasons = kvp.Value.Seasons Seasons = kvp.Value.Seasons
.OrderBy(s => s.Value) .OrderBy(s => s.Value)
.ToDictionary(s => s.Key, s => s.Value) .ToDictionary(s => s.Key, s => s.Value)

View File

@ -47,6 +47,11 @@ public class QueuedEpisode
/// </summary> /// </summary>
public bool IsAnime { get; set; } 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> /// <summary>
/// Gets or sets the timestamp (in seconds) to stop searching for an introduction at. /// Gets or sets the timestamp (in seconds) to stop searching for an introduction at.
/// </summary> /// </summary>

View File

@ -23,6 +23,11 @@ namespace IntroSkipper.Controllers
/// </summary> /// </summary>
public required string LibraryName { get; set; } 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> /// <summary>
/// Gets the Seasons of the show. /// Gets the Seasons of the show.
/// </summary> /// </summary>

View File

@ -6,6 +6,7 @@ using IntroSkipper.Data;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using Jellyfin.Extensions; using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -28,6 +29,7 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
private double _analysisPercent; private double _analysisPercent;
private List<string> _selectedLibraries = []; private List<string> _selectedLibraries = [];
private bool _selectAllLibraries; private bool _selectAllLibraries;
private bool _analyzeMovies;
/// <summary> /// <summary>
/// Gets all media items on the server. /// Gets all media items on the server.
@ -90,6 +92,8 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
_selectAllLibraries = config.SelectAllLibraries; _selectAllLibraries = config.SelectAllLibraries;
_analyzeMovies = config.AnalyzeMovies;
if (!_selectAllLibraries) if (!_selectAllLibraries)
{ {
// Get the list of library names which have been selected for analysis, ignoring whitespace and empty entries. // Get the list of library names which have been selected for analysis, ignoring whitespace and empty entries.
@ -123,7 +127,7 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
// Order by series name, season, and then episode number so that status updates are logged in order // Order by series name, season, and then episode number so that status updates are logged in order
ParentId = id, ParentId = id,
OrderBy = [(ItemSortBy.SeriesSortName, SortOrder.Ascending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Ascending),], OrderBy = [(ItemSortBy.SeriesSortName, SortOrder.Ascending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Ascending),],
IncludeItemTypes = [BaseItemKind.Episode], IncludeItemTypes = [BaseItemKind.Episode, BaseItemKind.Movie],
Recursive = true, Recursive = true,
IsVirtualItem = false IsVirtualItem = false
}; };
@ -141,13 +145,18 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
foreach (var item in items) 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); QueueEpisode(episode);
continue; }
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); _logger.LogDebug("Queued {Count} episodes", items.Count);
@ -197,8 +206,11 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
duration >= 5 * 60 ? duration * _analysisPercent : duration, duration >= 5 * 60 ? duration * _analysisPercent : duration,
60 * pluginInstance.Configuration.AnalysisLengthLimit); 60 * pluginInstance.Configuration.AnalysisLengthLimit);
var maxCreditsDuration = Math.Min(
duration >= 5 * 60 ? duration * _analysisPercent : duration,
60 * pluginInstance.Configuration.MaximumCreditsDuration);
// Queue the episode for analysis // Queue the episode for analysis
var maxCreditsDuration = pluginInstance.Configuration.MaximumCreditsDuration;
seasonEpisodes.Add(new QueuedEpisode seasonEpisodes.Add(new QueuedEpisode
{ {
SeriesName = episode.SeriesName, SeriesName = episode.SeriesName,
@ -216,6 +228,34 @@ public class QueueManager(ILogger<QueueManager> logger, ILibraryManager libraryM
pluginInstance.TotalQueued++; 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) private Guid GetSeasonId(Episode episode)
{ {
if (episode.ParentIndexNumber == 0 && episode.AiredSeasonNumber != 0) // In-season special if (episode.ParentIndexNumber == 0 && episode.AiredSeasonNumber != 0) // In-season special

View File

@ -122,11 +122,8 @@ public class BaseItemAnalyzerTask
Interlocked.Add(ref totalProcessed, episodes.Count * _analysisModes.Count); // Update total Processed directly Interlocked.Add(ref totalProcessed, episodes.Count * _analysisModes.Count); // Update total Processed directly
progress.Report(totalProcessed * 100 / totalQueued); progress.Report(totalProcessed * 100 / totalQueued);
return;
} }
else if (_analysisModes.Count != requiredModes.Count)
if (_analysisModes.Count != requiredModes.Count)
{ {
Interlocked.Add(ref totalProcessed, episodes.Count); Interlocked.Add(ref totalProcessed, episodes.Count);
progress.Report(totalProcessed * 100 / totalQueued); // Partial analysis some modes have already been analyzed progress.Report(totalProcessed * 100 / totalQueued); // Partial analysis some modes have already been analyzed
@ -188,7 +185,7 @@ public class BaseItemAnalyzerTask
// Only analyze specials (season 0) if the user has opted in. // Only analyze specials (season 0) if the user has opted in.
var first = items[0]; var first = items[0];
if (first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero) if (!first.IsMovie && first.SeasonNumber == 0 && !Plugin.Instance!.Configuration.AnalyzeSeasonZero)
{ {
return 0; return 0;
} }
@ -211,7 +208,7 @@ public class BaseItemAnalyzerTask
new ChapterAnalyzer(_loggerFactory.CreateLogger<ChapterAnalyzer>()) 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>())); analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
} }
@ -221,7 +218,7 @@ public class BaseItemAnalyzerTask
analyzers.Add(new BlackFrameAnalyzer(_loggerFactory.CreateLogger<BlackFrameAnalyzer>())); 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>())); analyzers.Add(new ChromaprintAnalyzer(_loggerFactory.CreateLogger<ChromaprintAnalyzer>()));
} }

View File

@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using IntroSkipper.Data; using IntroSkipper.Data;

View File

@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using IntroSkipper.Data; using IntroSkipper.Data;

View File

@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using IntroSkipper.Data; using IntroSkipper.Data;