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 6da8bfe..db86ce3 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/PluginConfiguration.cs @@ -31,6 +31,20 @@ public class PluginConfiguration : BasePluginConfiguration /// public string SelectedLibraries { get; set; } = string.Empty; + // ===== EDL handling ===== + + /// + /// Gets or sets a value indicating the action to write to created EDL files. + /// + public EdlAction EdlAction { get; set; } = EdlAction.None; + + /// + /// Gets or sets a value indicating whether to regenerate all EDL files during the next scan. + /// By default, EDL files are only written for a season if the season had at least one newly analyzed episode. + /// If this is set, all EDL files will be regenerated and overwrite any existing EDL file. + /// + public bool RegenerateEdlFiles { get; set; } = false; + // ===== Custom analysis settings ===== /// diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html index f1ebe42..fb94997 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html @@ -49,6 +49,58 @@ +
+ EDL file generation + +
+ + + +
+ 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 after EDL files are generated, you must check the "Regenerate EDL files" checkbox below. +
+
+ +
+ + +
+ If checked, the plugin will overwrite all EDL files associated with your episodes with the currently discovered introduction timestamps and EDL action. +
+
+
+
Modify introduction requirements @@ -619,6 +671,9 @@ document.querySelector('#AnalysisLengthLimit').value = config.AnalysisLengthLimit; document.querySelector('#MinimumDuration').value = config.MinimumIntroDuration; + document.querySelector('#EdlAction').value = config.EdlAction; + document.querySelector('#RegenerateEdl').checked = config.RegenerateEdlFiles; + document.querySelector('#CacheFingerprints').checked = config.CacheFingerprints; document.querySelector('#ShowPromptAdjustment').value = config.ShowPromptAdjustment; document.querySelector('#HidePromptAdjustment').value = config.HidePromptAdjustment; @@ -639,13 +694,17 @@ config.AnalysisLengthLimit = document.querySelector('#AnalysisLengthLimit').value; config.MinimumIntroDuration = document.querySelector('#MinimumDuration').value; + config.EdlAction = document.querySelector('#EdlAction').value; + config.RegenerateEdlFiles = document.querySelector('#RegenerateEdl').checked; + config.CacheFingerprints = document.querySelector('#CacheFingerprints').checked; config.ShowPromptAdjustment = document.querySelector("#ShowPromptAdjustment").value; config.HidePromptAdjustment = document.querySelector("#HidePromptAdjustment").value; - ApiClient.updatePluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b", config).then(function (result) { - Dashboard.processPluginConfigurationUpdateResult(result); - }); + ApiClient.updatePluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b", config) + .then(function (result) { + Dashboard.processPluginConfigurationUpdateResult(result); + }); }); e.preventDefault(); 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..0620c78 --- /dev/null +++ b/ConfusedPolarBear.Plugin.IntroSkipper/EdlManager.cs @@ -0,0 +1,100 @@ +namespace ConfusedPolarBear.Plugin.IntroSkipper; + +using System; +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; + } + + /// + /// Logs the configuration that will be used during EDL file creation. + /// + public static void LogConfiguration() + { + if (_logger is null) + { + throw new InvalidOperationException("Logger must not be null"); + } + + var config = Plugin.Instance!.Configuration; + + if (config.EdlAction == EdlAction.None) + { + _logger.LogDebug("EDL action: None - taking no further action"); + return; + } + + _logger.LogDebug("EDL action: {Action}", config.EdlAction); + _logger.LogDebug("Regenerate EDL files: {Regenerate}", config.RegenerateEdlFiles); + } + + /// + /// 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 regenerate = Plugin.Instance!.Configuration.RegenerateEdlFiles; + 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; + + if (!Plugin.Instance!.Intros.TryGetValue(id, out var intro)) + { + _logger?.LogDebug("Episode {Id} did not have an introduction, skipping", id); + continue; + } + else if (!intro.Valid) + { + _logger?.LogDebug("Episode {Id} did not have a valid introduction, skipping", id); + continue; + } + + var edlPath = GetEdlPath(Plugin.Instance!.GetItemPath(id)); + + _logger?.LogTrace("Episode {Id} has EDL path {Path}", id, edlPath); + + if (!regenerate && File.Exists(edlPath)) + { + _logger?.LogTrace("Refusing to overwrite existing EDL file {Path}", edlPath); + continue; + } + + 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 1e2ad9d..18fcf78 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"); @@ -134,6 +139,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 eac89bc..9aecebc 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/FingerprinterTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/FingerprinterTask.cs @@ -93,6 +93,8 @@ public class FingerprinterTask : IScheduledTask _queueLogger = loggerFactory.CreateLogger(); _fingerprintCache = new Dictionary>(); + + EdlManager.Initialize(_logger); } /// @@ -140,6 +142,9 @@ public class FingerprinterTask : IScheduledTask "No episodes to analyze. If you are limiting the list of libraries to analyze, check that all library names have been spelled correctly."); } + // Log EDL settings + EdlManager.LogConfiguration(); + // Include the previously processed episodes in the percentage reported to the UI. var totalProcessed = CountProcessedEpisodes(); var options = new ParallelOptions() @@ -153,10 +158,12 @@ public class FingerprinterTask : IScheduledTask minimumIntroDuration = Plugin.Instance!.Configuration.MinimumIntroDuration; + // Analyze all episodes in the queue using the degrees of parallelism the user specified. Parallel.ForEach(queue, options, (season) => { var workerStart = DateTime.Now; var first = season.Value[0]; + var writeEdl = false; try { @@ -164,6 +171,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 || Plugin.Instance!.Configuration.RegenerateEdlFiles; } catch (FingerprintException ex) { @@ -191,15 +199,29 @@ public class FingerprinterTask : IScheduledTask } } + if (writeEdl && Plugin.Instance!.Configuration.EdlAction != EdlAction.None) + { + EdlManager.UpdateEDLFiles(season.Value.AsReadOnly()); + } + progress.Report((totalProcessed * 100) / Plugin.Instance!.TotalQueued); analysisStatistics.TotalCPUTime.AddDuration(workerStart); Plugin.Instance!.AnalysisStatistics = analysisStatistics; }); + // Update analysis statistics analysisStatistics.TotalTaskTime.AddDuration(taskStart); Plugin.Instance!.AnalysisStatistics = analysisStatistics; + // Turn the regenerate EDL flag off after the scan completes. + if (Plugin.Instance!.Configuration.RegenerateEdlFiles) + { + _logger.LogInformation("Turning EDL file regeneration flag off"); + Plugin.Instance!.Configuration.RegenerateEdlFiles = false; + Plugin.Instance!.SaveConfiguration(); + } + return Task.CompletedTask; } diff --git a/docs/edl.md b/docs/edl.md new file mode 100644 index 0000000..81f5d0c --- /dev/null +++ b/docs/edl.md @@ -0,0 +1,16 @@ +# EDL support + +The timestamps of discovered introductions can be written to [EDL](https://kodi.wiki/view/Edit_decision_list) files alongside your media files. EDL files are saved when: +* Scanning an episode for the first time, or +* If requested with the regenerate checkbox + +## Configuration + +Jellyfin must have read/write access to your TV show libraries in order to make use of this feature. + +## Usage + +To have the plugin create EDL files: +1. Change the EDL action from the default of None to any of the other supported EDL actions +2. Check the "Regenerate EDL files during next analysis" checkbox + 1. If this option is not selected, only seasons with a newly analyzed episode will have EDL files created.