Merge branch 'edl'

This commit is contained in:
ConfusedPolarBear 2022-07-08 01:02:50 -05:00
commit 0d69c3a9d5
10 changed files with 330 additions and 5 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

@ -31,6 +31,20 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary> /// </summary>
public string SelectedLibraries { get; set; } = string.Empty; public string SelectedLibraries { get; set; } = string.Empty;
// ===== EDL handling =====
/// <summary>
/// Gets or sets a value indicating the action to write to created EDL files.
/// </summary>
public EdlAction EdlAction { get; set; } = EdlAction.None;
/// <summary>
/// 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.
/// </summary>
public bool RegenerateEdlFiles { get; set; } = false;
// ===== Custom analysis settings ===== // ===== Custom analysis settings =====
/// <summary> /// <summary>

View File

@ -49,6 +49,58 @@
</div> </div>
</div> </div>
<details>
<summary>EDL file generation</summary>
<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 />
If this value is changed after EDL files are generated, you must check the "Regenerate EDL files" checkbox below.
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="RegenerateEdl" type="checkbox" is="emby-checkbox" />
<span>Regenerate EDL files during next scan</span>
</label>
<div class="fieldDescription">
If checked, the plugin will <strong>overwrite all EDL files</strong> associated with your episodes with the currently discovered introduction timestamps and EDL action.
</div>
</div>
</details>
<details> <details>
<summary>Modify introduction requirements</summary> <summary>Modify introduction requirements</summary>
@ -619,6 +671,9 @@
document.querySelector('#AnalysisLengthLimit').value = config.AnalysisLengthLimit; document.querySelector('#AnalysisLengthLimit').value = config.AnalysisLengthLimit;
document.querySelector('#MinimumDuration').value = config.MinimumIntroDuration; 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('#CacheFingerprints').checked = config.CacheFingerprints;
document.querySelector('#ShowPromptAdjustment').value = config.ShowPromptAdjustment; document.querySelector('#ShowPromptAdjustment').value = config.ShowPromptAdjustment;
document.querySelector('#HidePromptAdjustment').value = config.HidePromptAdjustment; document.querySelector('#HidePromptAdjustment').value = config.HidePromptAdjustment;
@ -639,13 +694,17 @@
config.AnalysisLengthLimit = document.querySelector('#AnalysisLengthLimit').value; config.AnalysisLengthLimit = document.querySelector('#AnalysisLengthLimit').value;
config.MinimumIntroDuration = document.querySelector('#MinimumDuration').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.CacheFingerprints = document.querySelector('#CacheFingerprints').checked;
config.ShowPromptAdjustment = document.querySelector("#ShowPromptAdjustment").value; config.ShowPromptAdjustment = document.querySelector("#ShowPromptAdjustment").value;
config.HidePromptAdjustment = document.querySelector("#HidePromptAdjustment").value; config.HidePromptAdjustment = document.querySelector("#HidePromptAdjustment").value;
ApiClient.updatePluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b", config).then(function (result) { ApiClient.updatePluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b", config)
Dashboard.processPluginConfigurationUpdateResult(result); .then(function (result) {
}); Dashboard.processPluginConfigurationUpdateResult(result);
});
}); });
e.preventDefault(); e.preventDefault();

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,100 @@
namespace ConfusedPolarBear.Plugin.IntroSkipper;
using System;
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>
/// Logs the configuration that will be used during EDL file creation.
/// </summary>
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);
}
/// <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 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));
}
}
/// <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");
@ -134,6 +139,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

@ -93,6 +93,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>
@ -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."); "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. // Include the previously processed episodes in the percentage reported to the UI.
var totalProcessed = CountProcessedEpisodes(); var totalProcessed = CountProcessedEpisodes();
var options = new ParallelOptions() var options = new ParallelOptions()
@ -153,10 +158,12 @@ public class FingerprinterTask : IScheduledTask
minimumIntroDuration = Plugin.Instance!.Configuration.MinimumIntroDuration; minimumIntroDuration = Plugin.Instance!.Configuration.MinimumIntroDuration;
// Analyze all episodes in the queue using the degrees of parallelism the user specified.
Parallel.ForEach(queue, options, (season) => Parallel.ForEach(queue, options, (season) =>
{ {
var workerStart = DateTime.Now; var workerStart = DateTime.Now;
var first = season.Value[0]; var first = season.Value[0];
var writeEdl = false;
try try
{ {
@ -164,6 +171,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 || Plugin.Instance!.Configuration.RegenerateEdlFiles;
} }
catch (FingerprintException ex) 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); progress.Report((totalProcessed * 100) / Plugin.Instance!.TotalQueued);
analysisStatistics.TotalCPUTime.AddDuration(workerStart); analysisStatistics.TotalCPUTime.AddDuration(workerStart);
Plugin.Instance!.AnalysisStatistics = analysisStatistics; Plugin.Instance!.AnalysisStatistics = analysisStatistics;
}); });
// Update analysis statistics
analysisStatistics.TotalTaskTime.AddDuration(taskStart); analysisStatistics.TotalTaskTime.AddDuration(taskStart);
Plugin.Instance!.AnalysisStatistics = analysisStatistics; 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; return Task.CompletedTask;
} }

16
docs/edl.md Normal file
View File

@ -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.