diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs index 5454f84..85e87d5 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Diagnostics; using ConfusedPolarBear.Plugin.IntroSkipper.Data; using MediaBrowser.Model.Plugins; diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html index c7ba160..2917d87 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html @@ -537,7 +537,7 @@
- Manage Fingerprints + Manage Timestamps & Fingerprints
@@ -546,6 +546,35 @@
+ +
+ @@ -705,6 +734,8 @@ // seasons grouped by show var shows = {}; + var IgnoreListSeasonId; + // settings elements var visualizer = document.querySelector("details#visualizer"); var support = document.querySelector("details#support"); @@ -759,6 +790,11 @@ ] // visualizer elements + var ignorelistSection = document.querySelector("div#ignorelistSection"); + var ignorelistIntro = ignorelistSection.querySelector("input#ignorelistIntro"); + var ignorelistCredits = ignorelistSection.querySelector("input#ignorelistCredits"); + var saveIgnoreListSeasonButton = ignorelistSection.querySelector("button#saveIgnoreListSeason"); + var saveIgnoreListSeriesButton = ignorelistSection.querySelector("button#saveIgnoreListSeries"); var canvas = document.querySelector("canvas#troubleshooter"); var selectShow = document.querySelector("select#troubleshooterShow"); var selectSeason = document.querySelector("select#troubleshooterSeason"); @@ -965,6 +1001,15 @@ clearSelect(selectEpisode1); clearSelect(selectEpisode2); + // show the ignore list editor. + Dashboard.showLoadingMsg(); + const IgnoreList = await getJson("Intros/IgnoreList/" + encodeURI(selectShow.value)); + ignorelistIntro.checked = IgnoreList.IgnoreIntro; + ignorelistCredits.checked = IgnoreList.IgnoreCredits; + ignorelistSection.style.display = "unset"; + saveIgnoreListSeasonButton.style.display = "none"; + Dashboard.hideLoadingMsg(); + // add all seasons from this show to the season select for (var season of shows[selectShow.value]) { addItem(selectSeason, season, season); @@ -975,20 +1020,30 @@ // season changed, reload all episodes async function seasonChanged() { - const url = "Intros/Show/" + encodeURI(selectShow.value) + "/" + selectSeason.value; - const episodes = await getJson(url); + const seasonData = encodeURI(selectShow.value) + "/" + encodeURI(selectSeason.value); + + Dashboard.showLoadingMsg(); + // show the ignore list editor. + saveIgnoreListSeasonButton.style.display = "block"; + const IgnoreList = await getJson("Intros/IgnoreList/" + seasonData); + ignorelistIntro.checked = IgnoreList.IgnoreIntro; + ignorelistCredits.checked = IgnoreList.IgnoreCredits; + IgnoreListSeasonId = IgnoreList.SeasonId; + + // show the erase season button + eraseSeasonContainer.style.display = "unset"; clearSelect(selectEpisode1); clearSelect(selectEpisode2); - eraseSeasonContainer.style.display = "unset"; - let i = 1; + const episodes = await getJson("Intros/Show/" + seasonData); 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++; } + Dashboard.hideLoadingMsg(); setTimeout(() => { selectEpisode1.selectedIndex = 0; @@ -1292,6 +1347,35 @@ } ); }); + saveIgnoreListSeasonButton.addEventListener("click", () => { + Dashboard.showLoadingMsg(); + + var url ="Intros/IgnoreList/UpdateSeason"; + const newRhs = { + IgnoreIntro: ignorelistIntro.checked, + IgnoreCredits: ignorelistCredits.checked, + SeasonId: IgnoreListSeasonId, + }; + + fetchWithAuth(url, "POST", JSON.stringify(newRhs)); + + Dashboard.alert("Ignore list updated for " + selectSeason.value + " of " + selectShow.value); + Dashboard.hideLoadingMsg(); + }); + saveIgnoreListSeriesButton.addEventListener("click", () => { + Dashboard.showLoadingMsg(); + + var url ="Intros/IgnoreList/UpdateSeries" + "/" + encodeURIComponent(selectShow.value); + const newRhs = { + IgnoreIntro: ignorelistIntro.checked, + IgnoreCredits: ignorelistCredits.checked, + }; + + fetchWithAuth(url, "POST", JSON.stringify(newRhs)); + + Dashboard.alert("Ignore list updated for " + selectShow.value); + Dashboard.hideLoadingMsg(); + }); btnUpdateTimestamps.addEventListener("click", () => { const lhsId = selectEpisode1.options[selectEpisode1.selectedIndex].value; const newLhs = { diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs index 59d000e..12047d3 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Controllers/VisualizationController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using System.Net.Mime; using ConfusedPolarBear.Plugin.IntroSkipper.Data; using MediaBrowser.Common.Api; @@ -72,6 +73,48 @@ public class VisualizationController : ControllerBase return showSeasons; } + /// + /// Returns the ignore list for the provided season. + /// + /// Show name. + /// Season name. + /// List of episode titles. + [HttpGet("IgnoreList/{Series}/{Season}")] + public ActionResult GetIgnoreListSeason([FromRoute] string series, [FromRoute] string season) + { + if (!LookupSeasonIdByName(series, season, out var seasonId)) + { + return NotFound(); + } + + if (!Plugin.Instance!.IgnoreList.TryGetValue(seasonId, out _)) + { + return new IgnoreListItem(seasonId); + } + + return new IgnoreListItem(Plugin.Instance!.IgnoreList[seasonId]); + } + + /// + /// Returns the ignore list for the provided series. + /// + /// Show name. + /// List of episode titles. + [HttpGet("IgnoreList/{Series}")] + public ActionResult GetIgnoreListSeries([FromRoute] string series) + { + if (!LookupSeasonIdsByName(series, out var seasonIds)) + { + return NotFound(); + } + + return new IgnoreListItem(Guid.Empty) + { + IgnoreIntro = seasonIds.All(seasonId => Plugin.Instance!.IsIgnored(seasonId, AnalysisMode.Introduction)), + IgnoreCredits = seasonIds.All(seasonId => Plugin.Instance!.IsIgnored(seasonId, AnalysisMode.Credits)) + }; + } + /// /// Returns the names and unique identifiers of all episodes in the provided season. /// @@ -79,9 +122,7 @@ public class VisualizationController : ControllerBase /// Season name. /// List of episode titles. [HttpGet("Show/{Series}/{Season}")] - public ActionResult> GetSeasonEpisodes( - [FromRoute] string series, - [FromRoute] string season) + public ActionResult> GetSeasonEpisodes([FromRoute] string series, [FromRoute] string season) { var visualEpisodes = new List(); @@ -157,6 +198,61 @@ public class VisualizationController : ControllerBase return NoContent(); } + /// + /// Updates the ignore list for the provided season. + /// + /// New ignore list items. + /// Save the ignore list. + /// No content. + [HttpPost("IgnoreList/UpdateSeason")] + public ActionResult UpdateIgnoreListSeason([FromBody] IgnoreListItem ignoreListItem, bool save = true) + { + if (!Plugin.Instance!.QueuedMediaItems.ContainsKey(ignoreListItem.SeasonId)) + { + return NotFound(); + } + + if (ignoreListItem.IgnoreIntro || ignoreListItem.IgnoreCredits) + { + Plugin.Instance!.IgnoreList.AddOrUpdate(ignoreListItem.SeasonId, ignoreListItem, (_, _) => ignoreListItem); + } + else + { + Plugin.Instance!.IgnoreList.TryRemove(ignoreListItem.SeasonId, out _); + } + + if (save) + { + Plugin.Instance!.SaveIgnoreList(); + } + + return NoContent(); + } + + /// + /// Updates the ignore list for the provided series. + /// + /// Series name. + /// New ignore list items. + /// No content. + [HttpPost("IgnoreList/UpdateSeries/{Series}")] + public ActionResult UpdateIgnoreListSeries([FromRoute] string series, [FromBody] IgnoreListItem ignoreListItem) + { + if (!LookupSeasonIdsByName(series, out var seasonIds)) + { + return NotFound(); + } + + foreach (var seasonId in seasonIds) + { + UpdateIgnoreListSeason(new IgnoreListItem(ignoreListItem) { SeasonId = seasonId }, false); + } + + Plugin.Instance!.SaveIgnoreList(); + + return NoContent(); + } + /// /// Updates the introduction timestamps for the provided episode. /// @@ -212,4 +308,60 @@ public class VisualizationController : ControllerBase episodes = []; return false; } + + /// + /// Lookup a named season of a series and return its season id. + /// + /// Series name. + /// Season name. + /// Season id. + /// Boolean indicating if the requested season was found. + private bool LookupSeasonIdByName(string series, string season, out Guid seasonId) + { + foreach (var queuedEpisodes in Plugin.Instance!.QueuedMediaItems) + { + var first = queuedEpisodes.Value[0]; + var firstSeasonName = GetSeasonName(first); + + // Assert that the queued episode series and season are equal to what was requested + if ( + !string.Equals(first.SeriesName, series, StringComparison.OrdinalIgnoreCase) || + !string.Equals(firstSeasonName, season, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + seasonId = queuedEpisodes.Key; + return true; + } + + seasonId = Guid.Empty; + return false; + } + + /// + /// Lookup a named series and return all the season ids. + /// + /// Series name. + /// Seasons. + /// Boolean indicating if the requested series was found. + private bool LookupSeasonIdsByName(string series, out List seasons) + { + seasons = new List(); + + foreach (var queuedEpisodes in Plugin.Instance!.QueuedMediaItems) + { + var first = queuedEpisodes.Value[0]; + + // Assert that the queued episode series is equal to what was requested + if (!string.Equals(first.SeriesName, series, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + seasons.Add(queuedEpisodes.Key); + } + + return seasons.Count > 0; + } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/IgnoreListItem.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/IgnoreListItem.cs new file mode 100644 index 0000000..933bd3d --- /dev/null +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Data/IgnoreListItem.cs @@ -0,0 +1,89 @@ +using System; +using System.Runtime.Serialization; + +namespace ConfusedPolarBear.Plugin.IntroSkipper.Data; + +/// +/// Represents an item to ignore. +/// +[DataContract(Namespace = "http://schemas.datacontract.org/2004/07/ConfusedPolarBear.Plugin.IntroSkipper")] +public class IgnoreListItem +{ + /// + /// Initializes a new instance of the class. + /// + public IgnoreListItem() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The season id. + public IgnoreListItem(Guid seasonId) + { + SeasonId = seasonId; + } + + /// + /// Initializes a new instance of the class. + /// + /// The item to copy. + public IgnoreListItem(IgnoreListItem item) + { + SeasonId = item.SeasonId; + IgnoreIntro = item.IgnoreIntro; + IgnoreCredits = item.IgnoreCredits; + } + + /// + /// Gets or sets the season id. + /// + [DataMember] + public Guid SeasonId { get; set; } = Guid.Empty; + + /// + /// Gets or sets a value indicating whether to ignore the intro. + /// + [DataMember] + public bool IgnoreIntro { get; set; } = false; + + /// + /// Gets or sets a value indicating whether to ignore the credits. + /// + [DataMember] + public bool IgnoreCredits { get; set; } = false; + + /// + /// Toggles the provided mode to the provided value. + /// + /// Analysis mode. + /// Value to set. + public void Toggle(AnalysisMode mode, bool value) + { + switch (mode) + { + case AnalysisMode.Introduction: + IgnoreIntro = value; + break; + case AnalysisMode.Credits: + IgnoreCredits = value; + break; + } + } + + /// + /// Checks if the provided mode is ignored. + /// + /// Analysis mode. + /// True if ignored, false otherwise. + public bool IsIgnored(AnalysisMode mode) + { + return mode switch + { + AnalysisMode.Introduction => IgnoreIntro, + AnalysisMode.Credits => IgnoreCredits, + _ => false, + }; + } +} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs index 2bf15e8..d2efb52 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text.RegularExpressions; using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using ConfusedPolarBear.Plugin.IntroSkipper.Data; @@ -30,6 +31,7 @@ public partial class Plugin : BasePlugin, IHasWebPages private readonly ILogger _logger; private readonly string _introPath; private readonly string _creditsPath; + private string _ignorelistPath; /// /// Initializes a new instance of the class. @@ -66,6 +68,7 @@ public partial class Plugin : BasePlugin, IHasWebPages FingerprintCachePath = Path.Join(introsDirectory, pluginCachePath); _introPath = Path.Join(applicationPaths.DataPath, pluginDirName, "intros.xml"); _creditsPath = Path.Join(applicationPaths.DataPath, pluginDirName, "credits.xml"); + _ignorelistPath = Path.Join(applicationPaths.DataPath, pluginDirName, "ignorelist.xml"); var cacheRoot = applicationPaths.CachePath; var oldIntrosDirectory = Path.Join(cacheRoot, pluginDirName); @@ -129,6 +132,15 @@ public partial class Plugin : BasePlugin, IHasWebPages _logger.LogWarning("Unable to load introduction timestamps: {Exception}", ex); } + try + { + LoadIgnoreList(); + } + catch (Exception ex) + { + _logger.LogWarning("Unable to load ignore list: {Exception}", ex); + } + // Inject the skip intro button code into the web interface. try { @@ -164,6 +176,11 @@ public partial class Plugin : BasePlugin, IHasWebPages /// public ConcurrentDictionary EpisodeStates { get; } = new(); + /// + /// Gets the ignore list. + /// + public ConcurrentDictionary IgnoreList { get; } = new(); + /// /// Gets or sets the total number of episodes in the queue. /// @@ -226,6 +243,53 @@ public partial class Plugin : BasePlugin, IHasWebPages } } + /// + /// Save IgnoreList to disk. + /// + public void SaveIgnoreList() + { + var ignorelist = Instance!.IgnoreList.Values.ToList(); + + lock (_serializationLock) + { + try + { + XmlSerializationHelper.SerializeToXml(ignorelist, _ignorelistPath); + } + catch (Exception e) + { + _logger.LogError("SaveIgnoreList {Message}", e.Message); + } + } + } + + /// + /// Check if an item is ignored. + /// + /// Item id. + /// Mode. + /// True if ignored, false otherwise. + public bool IsIgnored(Guid id, AnalysisMode mode) + { + return Instance!.IgnoreList.TryGetValue(id, out var item) && item.IsIgnored(mode); + } + + /// + /// Load IgnoreList from disk. + /// + public void LoadIgnoreList() + { + if (File.Exists(_ignorelistPath)) + { + var ignorelist = XmlSerializationHelper.DeserializeFromXml(_ignorelistPath); + + foreach (var item in ignorelist) + { + Instance!.IgnoreList.TryAdd(item.SeasonId, item); + } + } + } + /// /// Restore previous analysis results from disk. /// @@ -234,7 +298,7 @@ public partial class Plugin : BasePlugin, IHasWebPages if (File.Exists(_introPath)) { // Since dictionaries can't be easily serialized, analysis results are stored on disk as a list. - var introList = XmlSerializationHelper.DeserializeFromXml(_introPath); + var introList = XmlSerializationHelper.DeserializeFromXml(_introPath); foreach (var intro in introList) { @@ -244,7 +308,7 @@ public partial class Plugin : BasePlugin, IHasWebPages if (File.Exists(_creditsPath)) { - var creditList = XmlSerializationHelper.DeserializeFromXml(_creditsPath); + var creditList = XmlSerializationHelper.DeserializeFromXml(_creditsPath); foreach (var credit in creditList) { diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs index 266ef33..1c92b98 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/BaseItemAnalyzerTask.cs @@ -114,7 +114,7 @@ public class BaseItemAnalyzerTask // of the current media items were deleted from Jellyfin since the task was started. var (episodes, requiredModes) = queueManager.VerifyQueue( season.Value.AsReadOnly(), - _analysisModes); + _analysisModes.Where(m => !Plugin.Instance!.IsIgnored(season.Key, m)).ToList().AsReadOnly()); var episodeCount = episodes.Count; diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/CleanCacheTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/CleanCacheTask.cs index 385b361..152122a 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/CleanCacheTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/CleanCacheTask.cs @@ -58,7 +58,9 @@ public class CleanCacheTask : IScheduledTask public string Key => "CPBIntroSkipperCleanCache"; /// - /// Cleans the Intro Skipper cache by removing files that are no longer associated with episodes in the library. + /// Cleans the cache of unused files. + /// Clears the Segment cache by removing files that are no longer associated with episodes in the library. + /// Clears the IgnoreList cache by removing items that are no longer associated with seasons in the library. /// /// Task progress. /// Cancellation token. @@ -103,6 +105,30 @@ public class CleanCacheTask : IScheduledTask FFmpegWrapper.DeleteEpisodeCache(episodeId); } + // Clean up ignore list by removing items that are no longer exist.. + var removedItems = false; + foreach (var ignoredItem in Plugin.Instance.IgnoreList.Values.ToList()) + { + if (!Plugin.Instance.QueuedMediaItems.ContainsKey(ignoredItem.SeasonId)) + { + removedItems = true; + Plugin.Instance.IgnoreList.TryRemove(ignoredItem.SeasonId, out _); + } + } + + // Save ignore list if at least one item was removed. + if (removedItems) + { + try + { + Plugin.Instance!.SaveIgnoreList(); + } + catch (Exception e) + { + _logger.LogError("Failed to save ignore list: {Error}", e.Message); + } + } + return Task.CompletedTask; } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/XmlSerializationHelper.cs b/ConfusedPolarBear.Plugin.IntroSkipper/XmlSerializationHelper.cs index 0388be5..f02d126 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/XmlSerializationHelper.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/XmlSerializationHelper.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Runtime.Serialization; using System.Xml; using ConfusedPolarBear.Plugin.IntroSkipper.Data; @@ -22,40 +23,17 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper public static void MigrateFromIntro(string filePath) { - var intros = new List(); - var segments = new List(); - try - { - // Create a FileStream to read the XML file - using FileStream fileStream = new FileStream(filePath, FileMode.Open); - // Create an XmlDictionaryReader to read the XML - XmlDictionaryReader reader = XmlDictionaryReader.CreateTextReader(fileStream, new XmlDictionaryReaderQuotas()); - - // Create a DataContractSerializer for type T - DataContractSerializer serializer = new DataContractSerializer(typeof(List)); - - // Deserialize the object from the XML - intros = serializer.ReadObject(reader) as List; - - // Close the reader - reader.Close(); - } - catch (Exception ex) - { - Console.WriteLine($"Error deserializing XML: {ex.Message}"); - } - + List intros = DeserializeFromXml(filePath); ArgumentNullException.ThrowIfNull(intros); - intros.ForEach(delegate(Intro name) - { - segments.Add(new Segment(name)); - }); + + var segments = intros.Select(name => new Segment(name)).ToList(); + SerializeToXml(segments, filePath); } - public static List DeserializeFromXml(string filePath) + public static List DeserializeFromXml(string filePath) { - var result = new List(); + var result = new List(); try { // Create a FileStream to read the XML file @@ -63,11 +41,11 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper // Create an XmlDictionaryReader to read the XML XmlDictionaryReader reader = XmlDictionaryReader.CreateTextReader(fileStream, new XmlDictionaryReaderQuotas()); - // Create a DataContractSerializer for type T - DataContractSerializer serializer = new DataContractSerializer(typeof(List)); + // Create a DataContractSerializer for type List + DataContractSerializer serializer = new DataContractSerializer(typeof(List)); // Deserialize the object from the XML - result = serializer.ReadObject(reader) as List; + result = serializer.ReadObject(reader) as List; // Close the reader reader.Close();