Merge branch 'edl'
This commit is contained in:
commit
0d69c3a9d5
@ -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
|
||||
|
||||
|
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));
|
||||
}
|
||||
}
|
@ -31,6 +31,20 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// </summary>
|
||||
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 =====
|
||||
|
||||
/// <summary>
|
||||
|
@ -49,6 +49,58 @@
|
||||
</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>
|
||||
<summary>Modify introduction requirements</summary>
|
||||
|
||||
@ -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,11 +694,15 @@
|
||||
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) {
|
||||
ApiClient.updatePluginConfiguration("c83d86bb-a1e0-4c35-a113-e2101cf4ee6b", config)
|
||||
.then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
});
|
||||
});
|
||||
|
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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
100
ConfusedPolarBear.Plugin.IntroSkipper/EdlManager.cs
Normal file
100
ConfusedPolarBear.Plugin.IntroSkipper/EdlManager.cs
Normal 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");
|
||||
}
|
||||
}
|
@ -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<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
private IXmlSerializer _xmlSerializer;
|
||||
private ILibraryManager _libraryManager;
|
||||
private string _introPath;
|
||||
|
||||
/// <summary>
|
||||
@ -25,13 +27,16 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
|
||||
/// <param name="serverConfiguration">Server configuration manager.</param>
|
||||
/// <param name="libraryManager">Library manager.</param>
|
||||
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<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 />
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
|
@ -93,6 +93,8 @@ public class FingerprinterTask : IScheduledTask
|
||||
_queueLogger = loggerFactory.CreateLogger<QueueManager>();
|
||||
|
||||
_fingerprintCache = new Dictionary<Guid, ReadOnlyCollection<uint>>();
|
||||
|
||||
EdlManager.Initialize(_logger);
|
||||
}
|
||||
|
||||
/// <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.");
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
16
docs/edl.md
Normal file
16
docs/edl.md
Normal 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.
|
Loading…
x
Reference in New Issue
Block a user