Add initial support for writing EDL files

This commit is contained in:
ConfusedPolarBear 2022-06-15 01:00:03 -05:00
parent cf1dc66970
commit 9dbe684790
9 changed files with 235 additions and 2 deletions

View File

@ -1,6 +1,6 @@
# Changelog # Changelog
## v0.1.6.0 ## v0.1.6.0 (unreleased)
* Write EDL files with intro timestamps * Write EDL files with intro timestamps
* Restore per season status updates * Restore per season status updates

View File

@ -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<ArgumentException>(() => {
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));
}
}

View File

@ -14,6 +14,8 @@ public class PluginConfiguration : BasePluginConfiguration
{ {
} }
// ===== Analysis settings =====
/// <summary> /// <summary>
/// Gets or sets a value indicating whether the episode's fingerprint should be cached to the filesystem. /// Gets or sets a value indicating whether the episode's fingerprint should be cached to the filesystem.
/// </summary> /// </summary>
@ -24,6 +26,13 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary> /// </summary>
public int MaxParallelism { get; set; } = 2; public int MaxParallelism { get; set; } = 2;
/// <summary>
/// Gets or sets a value indicating the action to write to created EDL files.
/// </summary>
public EdlAction EdlAction { get; set; } = EdlAction.None;
// ===== Playback settings =====
/// <summary> /// <summary>
/// Gets or sets a value indicating whether introductions should be automatically skipped. /// Gets or sets a value indicating whether introductions should be automatically skipped.
/// </summary> /// </summary>

View File

@ -37,6 +37,45 @@
Maximum degree of parallelism to use when analyzing episodes. Maximum degree of parallelism to use when analyzing episodes.
</div> </div>
</div> </div>
<div class="selectContainer">
<label class="selectLabel" for="Options">EDL Action</label>
<select is="emby-select" id="EdlAction" class="emby-select-withcolor emby-select">
<option value="None">
None (do not create or modify EDL files)
</option>
<option value="CommercialBreak">
Commercial Break (recommended, skips past the intro once)
</option>
<option value="Cut">
Cut (player will remove the intro from the video)
</option>
<option value="Intro">
Intro (show a skip button, *experimental*)
</option>
<option value="Mute">
Mute (audio will be muted)
</option>
<option value="SceneMarker">
Scene Marker (create a chapter marker)
</option>
</select>
<div class="fieldDescription">
If set to a value other than None, specifies which action to write to
<a href="https://kodi.wiki/view/Edit_decision_list">MPlayer compatible EDL files</a>
alongside your episode files. <br />
<strong>
If this value is changed from None, the plugin will overwrite any EDL files
associated with your episode files.
</strong>
</div>
</div>
</fieldset> </fieldset>
<fieldset class="verticalSection-extrabottompadding"> <fieldset class="verticalSection-extrabottompadding">
@ -438,6 +477,7 @@
ApiClient.getPluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b").then(function (config) { ApiClient.getPluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b").then(function (config) {
document.querySelector('#AutoSkip').checked = config.AutoSkip; document.querySelector('#AutoSkip').checked = config.AutoSkip;
document.querySelector('#MaxParallelism').value = config.MaxParallelism; document.querySelector('#MaxParallelism').value = config.MaxParallelism;
document.querySelector('#EdlAction').value = config.EdlAction;
document.querySelector('#CacheFingerprints').checked = config.CacheFingerprints; document.querySelector('#CacheFingerprints').checked = config.CacheFingerprints;
document.querySelector('#ShowPromptAdjustment').value = config.ShowPromptAdjustment; document.querySelector('#ShowPromptAdjustment').value = config.ShowPromptAdjustment;
@ -453,6 +493,7 @@
ApiClient.getPluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b").then(function (config) { ApiClient.getPluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b").then(function (config) {
config.AutoSkip = document.querySelector('#AutoSkip').checked; config.AutoSkip = document.querySelector('#AutoSkip').checked;
config.MaxParallelism = document.querySelector('#MaxParallelism').value; config.MaxParallelism = document.querySelector('#MaxParallelism').value;
config.EdlAction = document.querySelector('#EdlAction').value;
config.CacheFingerprints = document.querySelector('#CacheFingerprints').checked; config.CacheFingerprints = document.querySelector('#CacheFingerprints').checked;
config.ShowPromptAdjustment = document.querySelector("#ShowPromptAdjustment").value; config.ShowPromptAdjustment = document.querySelector("#ShowPromptAdjustment").value;

View File

@ -0,0 +1,37 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper;
/// <summary>
/// Taken from https://kodi.wiki/view/Edit_decision_list#MPlayer_EDL.
/// </summary>
public enum EdlAction
{
/// <summary>
/// Do not create EDL files.
/// </summary>
None = -1,
/// <summary>
/// Completely remove the intro from playback as if it was never in the original video.
/// </summary>
Cut,
/// <summary>
/// Mute audio, continue playback.
/// </summary>
Mute,
/// <summary>
/// Inserts a new scene marker.
/// </summary>
SceneMarker,
/// <summary>
/// Automatically skip the intro once during playback.
/// </summary>
CommercialBreak,
/// <summary>
/// Show a skip button.
/// </summary>
Intro,
}

View File

@ -68,4 +68,22 @@ public class Intro
/// Gets or sets the recommended time to hide the skip intro prompt. /// Gets or sets the recommended time to hide the skip intro prompt.
/// </summary> /// </summary>
public double HideSkipPromptAt { get; set; } public double HideSkipPromptAt { get; set; }
/// <summary>
/// Convert this Intro object to a Kodi compatible EDL entry.
/// </summary>
/// <param name="action">User specified configuration EDL action.</param>
/// <returns>String.</returns>
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);
}
} }

View File

@ -0,0 +1,59 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System.Collections.ObjectModel;
using System.IO;
using Microsoft.Extensions.Logging;
/// <summary>
/// Update EDL files associated with a list of episodes.
/// </summary>
public static class EdlManager
{
private static ILogger? _logger;
/// <summary>
/// Initialize EDLManager with a logger.
/// </summary>
/// <param name="logger">ILogger.</param>
public static void Initialize(ILogger logger)
{
_logger = logger;
}
/// <summary>
/// If the EDL action is set to a value other than None, update EDL files for the provided episodes.
/// </summary>
/// <param name="episodes">Episodes to update EDL files for.</param>
public static void UpdateEDLFiles(ReadOnlyCollection<QueuedEpisode> 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));
}
}
/// <summary>
/// Given the path to an episode, return the path to the associated EDL file.
/// </summary>
/// <param name="mediaPath">Full path to episode.</param>
/// <returns>Full path to EDL file.</returns>
public static string GetEdlPath(string mediaPath)
{
return Path.ChangeExtension(mediaPath, "edl");
}
}

View File

@ -6,6 +6,7 @@ using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Serialization;
@ -17,6 +18,7 @@ namespace ConfusedPolarBear.Plugin.IntroSkipper;
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{ {
private IXmlSerializer _xmlSerializer; private IXmlSerializer _xmlSerializer;
private ILibraryManager _libraryManager;
private string _introPath; private string _introPath;
/// <summary> /// <summary>
@ -25,13 +27,16 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param> /// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
/// <param name="serverConfiguration">Server configuration manager.</param> /// <param name="serverConfiguration">Server configuration manager.</param>
/// <param name="libraryManager">Library manager.</param>
public Plugin( public Plugin(
IApplicationPaths applicationPaths, IApplicationPaths applicationPaths,
IXmlSerializer xmlSerializer, IXmlSerializer xmlSerializer,
IServerConfigurationManager serverConfiguration) IServerConfigurationManager serverConfiguration,
ILibraryManager libraryManager)
: base(applicationPaths, xmlSerializer) : base(applicationPaths, xmlSerializer)
{ {
_xmlSerializer = xmlSerializer; _xmlSerializer = xmlSerializer;
_libraryManager = libraryManager;
// Create the base & cache directories (if needed). // Create the base & cache directories (if needed).
FingerprintCachePath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "cache"); FingerprintCachePath = Path.Join(applicationPaths.PluginConfigurationsPath, "intros", "cache");
@ -129,6 +134,16 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
} }
} }
/// <summary>
/// Gets the full path for an item.
/// </summary>
/// <param name="id">Item id.</param>
/// <returns>Full path to item.</returns>
internal string GetItemPath(Guid id)
{
return _libraryManager.GetItemById(id).Path;
}
/// <inheritdoc /> /// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages() public IEnumerable<PluginPageInfo> GetPages()
{ {

View File

@ -88,6 +88,8 @@ public class FingerprinterTask : IScheduledTask
_queueLogger = loggerFactory.CreateLogger<QueueManager>(); _queueLogger = loggerFactory.CreateLogger<QueueManager>();
_fingerprintCache = new Dictionary<Guid, ReadOnlyCollection<uint>>(); _fingerprintCache = new Dictionary<Guid, ReadOnlyCollection<uint>>();
EdlManager.Initialize(_logger);
} }
/// <summary> /// <summary>
@ -146,6 +148,7 @@ public class FingerprinterTask : IScheduledTask
Parallel.ForEach(queue, options, (season) => Parallel.ForEach(queue, options, (season) =>
{ {
var first = season.Value[0]; var first = season.Value[0];
var writeEdl = false;
try try
{ {
@ -153,6 +156,7 @@ public class FingerprinterTask : IScheduledTask
// (instead of just using the number of episodes in the current season). // (instead of just using the number of episodes in the current season).
var analyzed = AnalyzeSeason(season, cancellationToken); var analyzed = AnalyzeSeason(season, cancellationToken);
Interlocked.Add(ref totalProcessed, analyzed); Interlocked.Add(ref totalProcessed, analyzed);
writeEdl = analyzed > 0;
} }
catch (FingerprintException ex) 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); progress.Report((totalProcessed * 100) / Plugin.Instance!.TotalQueued);
}); });