From 9dbe6847905ab9dee7648ce3e1e687854d644c01 Mon Sep 17 00:00:00 2001 From: ConfusedPolarBear <33811686+ConfusedPolarBear@users.noreply.github.com> Date: Wed, 15 Jun 2022 01:00:03 -0500 Subject: [PATCH] Add initial support for writing EDL files --- CHANGELOG.md | 2 +- .../TestEdl.cs | 44 ++++++++++++++ .../Configuration/PluginConfiguration.cs | 9 +++ .../Configuration/configPage.html | 41 +++++++++++++ .../Data/EdlAction.cs | 37 ++++++++++++ .../Data/Intro.cs | 18 ++++++ .../EdlManager.cs | 59 +++++++++++++++++++ .../Plugin.cs | 17 +++++- .../ScheduledTasks/FingerprinterTask.cs | 10 ++++ 9 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs create mode 100644 ConfusedPolarBear.Plugin.IntroSkipper/Data/EdlAction.cs create mode 100644 ConfusedPolarBear.Plugin.IntroSkipper/EdlManager.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 43e4f5d..8fffef0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## v0.1.6.0 +## v0.1.6.0 (unreleased) * Write EDL files with intro timestamps * Restore per season status updates diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs new file mode 100644 index 0000000..fd71c42 --- /dev/null +++ b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs @@ -0,0 +1,44 @@ +using System; +using Xunit; + +namespace ConfusedPolarBear.Plugin.IntroSkipper.Tests; + +public class TestEdl +{ + // Test data is from https://kodi.wiki/view/Edit_decision_list#MPlayer_EDL + [Theory] + [InlineData(5.3, 7.1, EdlAction.Cut, "5.3 7.1 0")] + [InlineData(15, 16.7, EdlAction.Mute, "15 16.7 1")] + [InlineData(420, 822, EdlAction.CommercialBreak, "420 822 3")] + [InlineData(1, 255.3, EdlAction.SceneMarker, "1 255.3 2")] + [InlineData(1.123456789, 5.654647987, EdlAction.CommercialBreak, "1.12 5.65 3")] + public void TestEdlSerialization(double start, double end, EdlAction action, string expected) + { + var intro = MakeIntro(start, end); + var actual = intro.ToEdl(action); + + Assert.Equal(expected, actual); + } + + [Fact] + public void TestEdlInvalidSerialization() + { + Assert.Throws(() => { + var intro = MakeIntro(0, 5); + intro.ToEdl(EdlAction.None); + }); + } + + [Theory] + [InlineData("Death Note - S01E12 - Love.mkv", "Death Note - S01E12 - Love.edl")] + [InlineData("/full/path/to/file.rm", "/full/path/to/file.edl")] + public void TestEdlPath(string mediaPath, string edlPath) + { + Assert.Equal(edlPath, EdlManager.GetEdlPath(mediaPath)); + } + + private Intro MakeIntro(double start, double end) + { + return new Intro(Guid.Empty, new TimeRange(start, end)); + } +} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs index 5a31357..5242088 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs @@ -14,6 +14,8 @@ public class PluginConfiguration : BasePluginConfiguration { } + // ===== Analysis settings ===== + /// /// Gets or sets a value indicating whether the episode's fingerprint should be cached to the filesystem. /// @@ -24,6 +26,13 @@ public class PluginConfiguration : BasePluginConfiguration /// public int MaxParallelism { get; set; } = 2; + /// + /// Gets or sets a value indicating the action to write to created EDL files. + /// + public EdlAction EdlAction { get; set; } = EdlAction.None; + + // ===== Playback settings ===== + /// /// Gets or sets a value indicating whether introductions should be automatically skipped. /// diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html index 54d8930..d681ff6 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html @@ -37,6 +37,45 @@ Maximum degree of parallelism to use when analyzing episodes. + +
+ + + +
+ If set to a value other than None, specifies which action to write to + MPlayer compatible EDL files + alongside your episode files.
+ + If this value is changed from None, the plugin will overwrite any EDL files + associated with your episode files. + +
+
@@ -438,6 +477,7 @@ ApiClient.getPluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b").then(function (config) { document.querySelector('#AutoSkip').checked = config.AutoSkip; document.querySelector('#MaxParallelism').value = config.MaxParallelism; + document.querySelector('#EdlAction').value = config.EdlAction; document.querySelector('#CacheFingerprints').checked = config.CacheFingerprints; document.querySelector('#ShowPromptAdjustment').value = config.ShowPromptAdjustment; @@ -453,6 +493,7 @@ ApiClient.getPluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b").then(function (config) { config.AutoSkip = document.querySelector('#AutoSkip').checked; config.MaxParallelism = document.querySelector('#MaxParallelism').value; + config.EdlAction = document.querySelector('#EdlAction').value; config.CacheFingerprints = document.querySelector('#CacheFingerprints').checked; config.ShowPromptAdjustment = document.querySelector("#ShowPromptAdjustment").value; diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/EdlAction.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/EdlAction.cs new file mode 100644 index 0000000..5ae2aa9 --- /dev/null +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Data/EdlAction.cs @@ -0,0 +1,37 @@ +namespace ConfusedPolarBear.Plugin.IntroSkipper; + +/// +/// Taken from https://kodi.wiki/view/Edit_decision_list#MPlayer_EDL. +/// +public enum EdlAction +{ + /// + /// Do not create EDL files. + /// + None = -1, + + /// + /// Completely remove the intro from playback as if it was never in the original video. + /// + Cut, + + /// + /// Mute audio, continue playback. + /// + Mute, + + /// + /// Inserts a new scene marker. + /// + SceneMarker, + + /// + /// Automatically skip the intro once during playback. + /// + CommercialBreak, + + /// + /// Show a skip button. + /// + Intro, +} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/Intro.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/Intro.cs index f5adad9..3b174ee 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/Intro.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Data/Intro.cs @@ -68,4 +68,22 @@ public class Intro /// Gets or sets the recommended time to hide the skip intro prompt. /// public double HideSkipPromptAt { get; set; } + + /// + /// Convert this Intro object to a Kodi compatible EDL entry. + /// + /// User specified configuration EDL action. + /// String. + public string ToEdl(EdlAction action) + { + if (action == EdlAction.None) + { + throw new ArgumentException("Cannot serialize an EdlAction of None"); + } + + var start = Math.Round(IntroStart, 2); + var end = Math.Round(IntroEnd, 2); + + return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0} {1} {2}", start, end, (int)action); + } } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/EdlManager.cs b/ConfusedPolarBear.Plugin.IntroSkipper/EdlManager.cs new file mode 100644 index 0000000..6340e0d --- /dev/null +++ b/ConfusedPolarBear.Plugin.IntroSkipper/EdlManager.cs @@ -0,0 +1,59 @@ +namespace ConfusedPolarBear.Plugin.IntroSkipper; + +using System.Collections.ObjectModel; +using System.IO; +using Microsoft.Extensions.Logging; + +/// +/// Update EDL files associated with a list of episodes. +/// +public static class EdlManager +{ + private static ILogger? _logger; + + /// + /// Initialize EDLManager with a logger. + /// + /// ILogger. + public static void Initialize(ILogger logger) + { + _logger = logger; + } + + /// + /// If the EDL action is set to a value other than None, update EDL files for the provided episodes. + /// + /// Episodes to update EDL files for. + public static void UpdateEDLFiles(ReadOnlyCollection episodes) + { + var action = Plugin.Instance!.Configuration.EdlAction; + if (action == EdlAction.None) + { + _logger?.LogDebug("EDL action is set to none, not updating EDL files"); + return; + } + + _logger?.LogDebug("Updating EDL files with action {Action}", action); + + foreach (var episode in episodes) + { + var id = episode.EpisodeId; + var intro = Plugin.Instance!.Intros[id]; + var edlPath = GetEdlPath(Plugin.Instance!.GetItemPath(id)); + + _logger?.LogTrace("Episode {Id} has EDL path {Path}", id, edlPath); + + File.WriteAllText(edlPath, intro.ToEdl(action)); + } + } + + /// + /// Given the path to an episode, return the path to the associated EDL file. + /// + /// Full path to episode. + /// Full path to EDL file. + public static string GetEdlPath(string mediaPath) + { + return Path.ChangeExtension(mediaPath, "edl"); + } +} diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs index d69d790..5e35194 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Plugin.cs @@ -6,6 +6,7 @@ using ConfusedPolarBear.Plugin.IntroSkipper.Configuration; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Library; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; @@ -17,6 +18,7 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper; public class Plugin : BasePlugin, IHasWebPages { private IXmlSerializer _xmlSerializer; + private ILibraryManager _libraryManager; private string _introPath; /// @@ -25,13 +27,16 @@ public class Plugin : BasePlugin, IHasWebPages /// Instance of the interface. /// Instance of the interface. /// Server configuration manager. + /// Library manager. public Plugin( IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, - IServerConfigurationManager serverConfiguration) + IServerConfigurationManager serverConfiguration, + ILibraryManager libraryManager) : base(applicationPaths, xmlSerializer) { _xmlSerializer = xmlSerializer; + _libraryManager = libraryManager; // Create the base & cache directories (if needed). FingerprintCachePath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "cache"); @@ -129,6 +134,16 @@ public class Plugin : BasePlugin, IHasWebPages } } + /// + /// Gets the full path for an item. + /// + /// Item id. + /// Full path to item. + internal string GetItemPath(Guid id) + { + return _libraryManager.GetItemById(id).Path; + } + /// public IEnumerable GetPages() { diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/FingerprinterTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/FingerprinterTask.cs index 23e7f7b..24ee83a 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/FingerprinterTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/FingerprinterTask.cs @@ -88,6 +88,8 @@ public class FingerprinterTask : IScheduledTask _queueLogger = loggerFactory.CreateLogger(); _fingerprintCache = new Dictionary>(); + + EdlManager.Initialize(_logger); } /// @@ -146,6 +148,7 @@ public class FingerprinterTask : IScheduledTask Parallel.ForEach(queue, options, (season) => { var first = season.Value[0]; + var writeEdl = false; try { @@ -153,6 +156,7 @@ public class FingerprinterTask : IScheduledTask // (instead of just using the number of episodes in the current season). var analyzed = AnalyzeSeason(season, cancellationToken); Interlocked.Add(ref totalProcessed, analyzed); + writeEdl = analyzed > 0; } catch (FingerprintException ex) { @@ -180,6 +184,12 @@ public class FingerprinterTask : IScheduledTask } } + if (writeEdl && Plugin.Instance!.Configuration.EdlAction != EdlAction.None) + { + EdlManager.UpdateEDLFiles(season.Value.AsReadOnly()); + } + + totalProcessed += season.Value.Count; progress.Report((totalProcessed * 100) / Plugin.Instance!.TotalQueued); });