Add initial support for writing EDL files
This commit is contained in:
parent
cf1dc66970
commit
9dbe684790
@ -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
|
||||||
|
|
||||||
|
44
ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs
Normal file
44
ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestEdl.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
37
ConfusedPolarBear.Plugin.IntroSkipper/Data/EdlAction.cs
Normal file
37
ConfusedPolarBear.Plugin.IntroSkipper/Data/EdlAction.cs
Normal 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,
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
59
ConfusedPolarBear.Plugin.IntroSkipper/EdlManager.cs
Normal file
59
ConfusedPolarBear.Plugin.IntroSkipper/EdlManager.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
@ -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()
|
||||||
{
|
{
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user