diff --git a/.vscode/launch.json b/.vscode/launch.json index e64093b..b4b623f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,11 +7,11 @@ "name": "Launch", "request": "launch", "preLaunchTask": "build-and-copy", - "program": "${config:jellyfinDir}/bin/Debug/net7.0/jellyfin.dll", + "program": "${config:jellyfinDir}/bin/Debug/net8.0/jellyfin.dll", "args": [ - //"--nowebclient" - "--webdir", - "${config:jellyfinWebDir}/dist/" + //"--nowebclient" + "--webdir", + "${config:jellyfinWebDir}/dist/" ], "cwd": "${config:jellyfinDir}", } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e8676e2..6bae288 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -51,7 +51,7 @@ "args": [ "/I", "/y", - ".\\${config:pluginName}\\bin\\Debug\\net7.0\\publish", + ".\\${config:pluginName}\\bin\\Debug\\net8.0\\publish", "${config:jellyfinDataDirWin}\\plugins\\${config:pluginName}\\" ] @@ -65,7 +65,7 @@ "type": "shell", "command": "cp", "args": [ - "./${config:pluginName}/bin/Debug/net7.0/publish/*", + "./${config:pluginName}/bin/Debug/net8.0/publish/*", "${config:jellyfinDataDir}/plugins/${config:pluginName}/" ] diff --git a/CHANGELOG.md b/CHANGELOG.md index be5586d..3169cec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- + +## [0.3.0] - 2024-08-31 + +### Added + +- Added http api endpoints to view and create edl files (with Segment Editor) + +### Fixed + +- No longer write empty files +- Crash during segment sorting + +### Breaking + +- Requires now Jellyfin 10.10 unstable + +## [0.2.0] - 2023-11-03 + +- Binary update + ## [0.1.0] - 2023-05-08 - Initial Release diff --git a/Directory.Build.props b/Directory.Build.props index 059d3b2..a8b7672 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 0.2.0.0 - 0.2.0.0 - 0.2.0.0 + 0.3.0.0 + 0.3.0.0 + 0.3.0.0 diff --git a/Jellyfin.Plugin.Edl.Tests/Jellyfin.Plugin.Edl.Tests.csproj b/Jellyfin.Plugin.Edl.Tests/Jellyfin.Plugin.Edl.Tests.csproj index ce77fc3..68f93a4 100644 --- a/Jellyfin.Plugin.Edl.Tests/Jellyfin.Plugin.Edl.Tests.csproj +++ b/Jellyfin.Plugin.Edl.Tests/Jellyfin.Plugin.Edl.Tests.csproj @@ -1,7 +1,7 @@ - net7.0 + net8.0 enable false diff --git a/Jellyfin.Plugin.Edl.Tests/TestEdl.cs b/Jellyfin.Plugin.Edl.Tests/TestEdl.cs index 0f8f26c..9d0a86e 100644 --- a/Jellyfin.Plugin.Edl.Tests/TestEdl.cs +++ b/Jellyfin.Plugin.Edl.Tests/TestEdl.cs @@ -5,14 +5,13 @@ namespace Jellyfin.Plugin.Edl.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 \n")] - [InlineData(15, 16.7, EdlAction.Mute, "15 16.7 1 \n")] - [InlineData(420, 822, EdlAction.CommercialBreak, "420 822 3 \n")] - [InlineData(1, 255.3, EdlAction.SceneMarker, "1 255.3 2 \n")] - [InlineData(1.123456789, 5.654647987, EdlAction.CommercialBreak, "1.12 5.65 3 \n")] - public void TestEdlSerialization(double start, double end, EdlAction action, string expected) + [InlineData(53000000, 71000000, EdlAction.Cut, "5.3 7.1 0 \n")] + [InlineData(150000000, 167000000, EdlAction.Mute, "15 16.7 1 \n")] + [InlineData(4200000000, 8220000000, EdlAction.CommercialBreak, "420 822 3 \n")] + [InlineData(10000009, 2553000000, EdlAction.SceneMarker, "1 255.3 2 \n")] + [InlineData(11234568, 56546475, EdlAction.CommercialBreak, "1.123 5.655 3 \n")] + public void TestEdlSerialization(long start, long end, EdlAction action, string expected) { var actual = EdlManager.ToEdlString(start, end, action); diff --git a/Jellyfin.Plugin.Edl/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.Edl/Configuration/PluginConfiguration.cs index 69c7a6e..bf82047 100644 --- a/Jellyfin.Plugin.Edl/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.Edl/Configuration/PluginConfiguration.cs @@ -12,6 +12,7 @@ public class PluginConfiguration : BasePluginConfiguration /// public PluginConfiguration() { + UnknownEdlAction = EdlAction.None; IntroEdlAction = EdlAction.None; OutroEdlAction = EdlAction.None; PreviewEdlAction = EdlAction.None; @@ -19,10 +20,15 @@ public PluginConfiguration() CommercialEdlAction = EdlAction.CommercialBreak; } + /// + /// Gets or sets an Unknown Action option. + /// + public EdlAction UnknownEdlAction { get; set; } + /// /// Gets or sets an Intro Action option. /// - public EdlAction IntroEdlAction { get; set; } + public EdlAction IntroEdlAction { get; set; } = EdlAction.Cut; /// /// Gets or sets an Outro Action option. @@ -42,7 +48,7 @@ public PluginConfiguration() /// /// Gets or sets an Commercial Action option. /// - public EdlAction CommercialEdlAction { get; set; } + public EdlAction CommercialEdlAction { get; set; } = EdlAction.CommercialBreak; /// /// Gets or sets a value indicating whether to overwrite existing edl files. Which keeps the file in sync with media segment edits. @@ -53,4 +59,19 @@ public PluginConfiguration() /// Gets or sets the max degree of parallelism used when creating edl files. /// public int MaxParallelism { get; set; } = 2; + + /// + /// Gets or sets the comma separated list of library names to analyze. If empty, all libraries will be analyzed. + /// + public string SelectedLibraries { get; set; } = string.Empty; + + /// + /// Gets or sets the comma separated list of tv shows and seasons to skip the analyze. Format: "My Show;S01;S02, Another Show". + /// + public string SkippedTvShows { get; set; } = string.Empty; + + /// + /// Gets or sets the comma separated list of movies to skip the analyze.". + /// + public string SkippedMovies { get; set; } = string.Empty; } diff --git a/Jellyfin.Plugin.Edl/Configuration/configPage.html b/Jellyfin.Plugin.Edl/Configuration/configPage.html index 4589bc0..63ca449 100644 --- a/Jellyfin.Plugin.Edl/Configuration/configPage.html +++ b/Jellyfin.Plugin.Edl/Configuration/configPage.html @@ -114,6 +114,46 @@ alongside your media files. +
+ + + +
+ EDL Action for "Unknown" Media Segments
+ Which action to write to + MPlayer compatible EDL files + alongside your media files. +
+
+ +
+ + +
Enter the names of libraries it should create EDL files for, separated by commas. If this field is left blank, all libraries on the server containing movies or television episodes will be used.
+
+ +
+ + +
Enter the names of tv shows to skip the EDL creation, separated by commas. You can also skip seasons. Format: "My Show;S01;S02, Another Show, Third Show;S03;S05"
+
+ +
+ + +
Enter the names of Movies to skip the EDL creation, separated by commas. Format: "The Godfather, Spiderman, Matrix"
+
+
@@ -143,6 +183,7 @@ document.querySelector("#TemplateConfigPage").addEventListener("pageshow", function () { Dashboard.showLoadingMsg(); ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) { + document.querySelector("#UnknownEdlAction").value = config.UnknownEdlAction; document.querySelector("#IntroEdlAction").value = config.IntroEdlAction; document.querySelector("#OutroEdlAction").value = config.OutroEdlAction; document.querySelector("#PreviewEdlAction").value = config.PreviewEdlAction; @@ -150,6 +191,9 @@ document.querySelector("#CommercialEdlAction").value = config.CommercialEdlAction; document.querySelector("#OverwriteEdlFiles").checked = config.OverwriteEdlFiles; document.querySelector("#MaxParallelism").value = config.MaxParallelism; + document.querySelector("#SelectedLibraries").value = config.SelectedLibraries; + document.querySelector("#SkippedTvShows").value = config.SkippedTvShows; + document.querySelector("#SkippedMovies").value = config.SkippedMovies; Dashboard.hideLoadingMsg(); }); }); @@ -157,6 +201,7 @@ document.querySelector("#TemplateConfigForm").addEventListener("submit", function (e) { Dashboard.showLoadingMsg(); ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) { + config.UnknownEdlAction = document.querySelector("#UnknownEdlAction").value; config.IntroEdlAction = document.querySelector("#IntroEdlAction").value; config.OutroEdlAction = document.querySelector("#OutroEdlAction").value; config.PreviewEdlAction = document.querySelector("#PreviewEdlAction").value; @@ -164,6 +209,9 @@ config.CommercialEdlAction = document.querySelector("#CommercialEdlAction").value; config.OverwriteEdlFiles = document.querySelector("#OverwriteEdlFiles").checked; config.MaxParallelism = document.querySelector("#MaxParallelism").value; + config.SelectedLibraries = document.querySelector("#SelectedLibraries").value; + config.SkippedTvShows = document.querySelector("#SkippedTvShows").value; + config.SkippedMovies = document.querySelector("#SkippedMovies").value; ApiClient.updatePluginConfiguration(TemplateConfig.pluginUniqueId, config).then(function (result) { Dashboard.processPluginConfigurationUpdateResult(result); }); diff --git a/Jellyfin.Plugin.Edl/Controllers/PluginEdlController.cs b/Jellyfin.Plugin.Edl/Controllers/PluginEdlController.cs new file mode 100644 index 0000000..0b021e0 --- /dev/null +++ b/Jellyfin.Plugin.Edl/Controllers/PluginEdlController.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.EdlManager; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.MediaSegments; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.Edl.Controllers; + +/// +/// PluginEdl controller. +/// +[Authorize(Policy = "RequiresElevation")] +[ApiController] +[Produces(MediaTypeNames.Application.Json)] +[Route("PluginEdl")] +public class PluginEdlController : ControllerBase +{ + private readonly ILoggerFactory _loggerFactory; + private readonly ILibraryManager _libraryManager; + private readonly IMediaSegmentManager _mediaSegmentManager; + + /// + /// Initializes a new instance of the class. + /// + /// Logger factory. + /// LibraryManager. + /// MediaSegmentsManager. + public PluginEdlController( + ILoggerFactory loggerFactory, + ILibraryManager libraryManager, + IMediaSegmentManager mediaSegmentManager) + { + _loggerFactory = loggerFactory; + _libraryManager = libraryManager; + _mediaSegmentManager = mediaSegmentManager; + } + + /// + /// Plugin meta endpoint. + /// + /// The version info. + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public JsonResult GetPluginMetadata() + { + var json = new + { + version = Plugin.Instance!.Version.ToString(3), + }; + + return new JsonResult(json); + } + + /// + /// Get Edl data based on itemId. + /// + /// ItemId. + /// The edl data. + [HttpGet("Edl/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task GetEdlData( + [FromRoute, Required] Guid itemId) + { + var queueManager = new QueueManager(_loggerFactory.CreateLogger(), _libraryManager); + + var segmentsList = new List(); + // get ItemIds + var mediaItems = queueManager.GetMediaItemsById([itemId]); + // get MediaSegments from itemIds + foreach (var kvp in mediaItems) + { + foreach (var media in kvp.Value) + { + segmentsList.AddRange(await _mediaSegmentManager.GetSegmentsAsync(media.ItemId, null).ConfigureAwait(false)); + } + } + + var rawstring = EdlManager.ToEdl(segmentsList.AsReadOnly()); + + var json = new + { + itemId, + edl = rawstring + }; + + return new JsonResult(json); + } + + /// + /// Force edl recreation for itemIds. + /// + /// ItemIds. + /// Ok. + [HttpPost("Edl")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task GenerateData( + [FromBody, Required] Guid[] itemIds) + { + var baseEdlTask = new BaseEdlTask( + _loggerFactory.CreateLogger()); + + var queueManager = new QueueManager(_loggerFactory.CreateLogger(), _libraryManager); + + var segmentsList = new List(); + // get ItemIds + var mediaItems = queueManager.GetMediaItemsById(itemIds); + // get MediaSegments from itemIds + foreach (var kvp in mediaItems) + { + foreach (var media in kvp.Value) + { + segmentsList.AddRange(await _mediaSegmentManager.GetSegmentsAsync(media.ItemId, null).ConfigureAwait(false)); + } + } + + IProgress progress = new Progress(); + CancellationToken cancellationToken = CancellationToken.None; + + // write edl files + baseEdlTask.CreateEdls(progress, segmentsList.AsReadOnly(), true, cancellationToken); + + return new OkResult(); + } +} diff --git a/Jellyfin.Plugin.Edl/Data/QueuedMedia.cs b/Jellyfin.Plugin.Edl/Data/QueuedMedia.cs new file mode 100644 index 0000000..4d22008 --- /dev/null +++ b/Jellyfin.Plugin.Edl/Data/QueuedMedia.cs @@ -0,0 +1,44 @@ +using System; + +namespace Jellyfin.Plugin.Edl; + +/// +/// Media queued for analysis. +/// +public class QueuedMedia +{ + /// + /// Gets or sets the Series name. + /// + public string SeriesName { get; set; } = string.Empty; + + /// + /// Gets or sets the season number. + /// + public int SeasonNumber { get; set; } + + /// + /// Gets or sets the media id. + /// + public Guid ItemId { get; set; } + + /// + /// Gets or sets a value indicating whether this media is an episode, part of a tv show. + /// + public bool IsEpisode { get; set; } = true; + + /// + /// Gets or sets the full path to episode. + /// + public string Path { get; set; } = string.Empty; + + /// + /// Gets or sets the name of the media, episode or movie with source quality/name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the total duration of this media file (in seconds). + /// + public int Duration { get; set; } +} diff --git a/Jellyfin.Plugin.Edl/EdlManager.cs b/Jellyfin.Plugin.Edl/EdlManager.cs index 250b226..5c6c0d8 100644 --- a/Jellyfin.Plugin.Edl/EdlManager.cs +++ b/Jellyfin.Plugin.Edl/EdlManager.cs @@ -2,9 +2,10 @@ namespace Jellyfin.Plugin.Edl; using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.IO; -using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using MediaBrowser.Model.MediaSegments; using Microsoft.Extensions.Logging; /// @@ -40,6 +41,7 @@ public static void LogConfiguration() _logger.LogDebug("Outro EdlAction: {Action}", config.OutroEdlAction); _logger.LogDebug("Preview EdlAction: {Action}", config.PreviewEdlAction); _logger.LogDebug("Recap EdlAction: {Action}", config.RecapEdlAction); + _logger.LogDebug("Unknown EdlAction: {Action}", config.UnknownEdlAction); _logger.LogDebug("Commercial EdlAction: {Action}", config.CommercialEdlAction); _logger.LogDebug("Max Parallelism: {Action}", config.MaxParallelism); } @@ -48,14 +50,17 @@ public static void LogConfiguration() /// Update EDL file for the provided segments. /// /// Key value pair of segments dictionary. - public static void UpdateEDLFile(KeyValuePair> psegment) + /// Force the file overwrite. + public static void UpdateEDLFile(KeyValuePair> psegment, bool forceOverwrite) { - var overwrite = Plugin.Instance!.Configuration.OverwriteEdlFiles; + var overwrite = Plugin.Instance!.Configuration.OverwriteEdlFiles || forceOverwrite; var id = psegment.Key; var segments = psegment.Value; - // Test if there ara any segments - if (segments.Count > 0) + var edlContent = ToEdl(segments.AsReadOnly()); + + // Test if we generated data + if (!string.IsNullOrEmpty(edlContent)) { var filePath = Plugin.Instance!.GetItemPath(id); @@ -69,7 +74,6 @@ public static void UpdateEDLFile(KeyValuePair> psegment if (!fexists || (fexists && overwrite)) { var oldContent = string.Empty; - var edlContent = ToEdl(segments); var update = false; try @@ -98,6 +102,10 @@ public static void UpdateEDLFile(KeyValuePair> psegment } } } + else + { + _logger?.LogDebug("Skip id ({Id}) no edl data generated", id); + } } /// @@ -105,7 +113,7 @@ public static void UpdateEDLFile(KeyValuePair> psegment /// /// The Segments. /// String content of edl file. - private static string ToEdl(List segments) + public static string ToEdl(ReadOnlyCollection segments) { var fstring = string.Empty; foreach (var segment in segments) @@ -115,7 +123,7 @@ private static string ToEdl(List segments) // Skip None actions if (action != EdlAction.None) { - fstring += ToEdlString(segment.Start, segment.End, action); + fstring += ToEdlString(segment.StartTicks, segment.EndTicks, action); } } @@ -131,10 +139,10 @@ private static string ToEdl(List segments) /// End position. /// The Action. /// String content of edl file. - public static string ToEdlString(double start, double end, EdlAction action) + public static string ToEdlString(long start, long end, EdlAction action) { - var rstart = Math.Round(start, 2); - var rend = Math.Round(end, 2); + var rstart = Math.Round((double)start / 10_000_000, 3); + var rend = Math.Round((double)end / 10_000_000, 3); return string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0} {1} {2} \n", rstart, rend, (int)action); } @@ -148,6 +156,8 @@ private static EdlAction GetActionforType(MediaSegmentType type) { switch (type) { + case MediaSegmentType.Unknown: + return Plugin.Instance!.Configuration.UnknownEdlAction; case MediaSegmentType.Intro: return Plugin.Instance!.Configuration.IntroEdlAction; case MediaSegmentType.Outro: diff --git a/Jellyfin.Plugin.Edl/Jellyfin.Plugin.Edl.csproj b/Jellyfin.Plugin.Edl/Jellyfin.Plugin.Edl.csproj index 2c3e239..c1704a3 100644 --- a/Jellyfin.Plugin.Edl/Jellyfin.Plugin.Edl.csproj +++ b/Jellyfin.Plugin.Edl/Jellyfin.Plugin.Edl.csproj @@ -1,7 +1,7 @@ - net7.0 + net8.0 Jellyfin.Plugin.Edl true true @@ -11,8 +11,8 @@ diff --git a/Jellyfin.Plugin.Edl/Plugin.cs b/Jellyfin.Plugin.Edl/Plugin.cs index ab467a7..9935570 100644 --- a/Jellyfin.Plugin.Edl/Plugin.cs +++ b/Jellyfin.Plugin.Edl/Plugin.cs @@ -1,14 +1,11 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Globalization; -using Jellyfin.Data.Entities; using Jellyfin.Plugin.Edl.Configuration; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; -using MediaBrowser.Model.MediaSegments; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; @@ -20,7 +17,6 @@ namespace Jellyfin.Plugin.Edl; public class Plugin : BasePlugin, IHasWebPages { private ILibraryManager _libraryManager; - private IMediaSegmentsManager _mediaSegmentsManager; /// /// Initializes a new instance of the class. @@ -28,18 +24,15 @@ public class Plugin : BasePlugin, IHasWebPages /// Instance of the interface. /// Instance of the interface. /// Library manager. - /// Segments manager. public Plugin( IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, - ILibraryManager libraryManager, - IMediaSegmentsManager mediaSegmentsManager) + ILibraryManager libraryManager) : base(applicationPaths, xmlSerializer) { Instance = this; _libraryManager = libraryManager; - _mediaSegmentsManager = mediaSegmentsManager; } /// @@ -66,18 +59,11 @@ public IEnumerable GetPages() }; } - /// - /// Gets all media segments from db. - /// - /// All media segments. - public ReadOnlyCollection GetAllMediaSegments() - { - return _mediaSegmentsManager.GetAllMediaSegments().AsReadOnly(); - } - internal BaseItem GetItem(Guid id) { +#pragma warning disable CS8603 // Possible null reference return. return _libraryManager.GetItemById(id); +#pragma warning restore CS8603 // Possible null reference return. } /// diff --git a/Jellyfin.Plugin.Edl/QueueManager.cs b/Jellyfin.Plugin.Edl/QueueManager.cs new file mode 100644 index 0000000..f7b41cf --- /dev/null +++ b/Jellyfin.Plugin.Edl/QueueManager.cs @@ -0,0 +1,370 @@ +namespace Jellyfin.Plugin.Edl; + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.IO; +using System.Linq; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dto; +using Microsoft.Extensions.Logging; + +/// +/// Manages enqueuing library items for analysis. +/// +public class QueueManager +{ + private ILibraryManager _libraryManager; + private ILogger _logger; + private List selectedLibraries; + private Dictionary> skippedTvShows; + private List skippedMovies; + private Dictionary> _queuedMedia; + + /// + /// Initializes a new instance of the class. + /// + /// Logger. + /// Library manager. + public QueueManager(ILogger logger, ILibraryManager libraryManager) + { + _logger = logger; + _libraryManager = libraryManager; + + selectedLibraries = new(); + _queuedMedia = new(); + skippedTvShows = new(); + skippedMovies = new(); + } + + /// + /// Gets all media items on the server. + /// + /// Queued media items. + public ReadOnlyDictionary> GetMediaItems() + { + LoadAnalysisSettings(); + + // For all selected libraries, enqueue all contained episodes. + foreach (var folder in _libraryManager.GetVirtualFolders()) + { + // If libraries have been selected for analysis, ensure this library was selected. + if (selectedLibraries.Count > 0 && !selectedLibraries.Contains(folder.Name)) + { + _logger.LogDebug("Not analyzing library \"{Name}\": not selected by user", folder.Name); + continue; + } + + _logger.LogInformation( + "Running enqueue of items in library {Name} ({ItemId})", + folder.Name, + folder.ItemId); + + try + { + QueueLibraryContents(folder.ItemId); + } + catch (Exception ex) + { + _logger.LogError("Failed to enqueue items from library {Name}: {Exception}", folder.Name, ex); + } + } + + return new(_queuedMedia); + } + + /// + /// Gets media items based on given itemId. Skips all block lists. + /// + /// All item ids to lookup. + /// Queued media items. + public ReadOnlyDictionary> GetMediaItemsById(Guid[] itemIds) + { + foreach (var item in itemIds) + { + var bitem = _libraryManager.GetItemById(item); + if (bitem != null) + { + if (bitem is Episode episode) + { + QueueEpisode(episode); + } + + if (bitem is Movie movie) + { + foreach (var source in movie.GetMediaSources(false)) + { + QueueMovie(movie, source); + } + } + } + } + + return new(_queuedMedia); + } + + /// + /// Loads the list of libraries which have been selected to use or skipped. + /// + private void LoadAnalysisSettings() + { + var config = Plugin.Instance!.Configuration; + + // Get the list of library names which have been selected for analysis, ignoring whitespace and empty entries. + selectedLibraries = config.SelectedLibraries + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + + // Get the list movie names which should be skipped. + skippedMovies = config.SkippedMovies + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + + // Get the list of tvshow names and seasons which should be skipped for analysis. + var show = config.SkippedTvShows + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + + foreach (var s in show) + { + if (s.Contains(';', System.StringComparison.InvariantCulture)) + { + var rseasons = s.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var seasons = rseasons.Skip(1).ToArray(); + var name = rseasons.ElementAt(0); + var seasonNumbers = new List(); + + foreach (var season in seasons) + { + var nr = season.Substring(1); + + try + { + seasonNumbers.Add(int.Parse(nr, CultureInfo.InvariantCulture)); + } + catch (FormatException) + { + _logger.LogError("Skipping TV Shows: Failed to parse season number '{Nr}' for tv show: {Name}. Fix your config!", nr, name); + } + } + + skippedTvShows.Add(name, seasonNumbers); + } + else + { + skippedTvShows.Add(s, new List()); + } + } + + // If any libraries have been selected for analysis, log their names. + if (selectedLibraries.Count > 0) + { + _logger.LogInformation("Limiting analysis to the following libraries: {Selected}", selectedLibraries); + } + else + { + _logger.LogDebug("Not limiting analysis by library name"); + } + } + + private void QueueLibraryContents(string rawId) + { + _logger.LogDebug("Constructing anonymous internal query"); + + var includes = new BaseItemKind[] { BaseItemKind.Episode, BaseItemKind.Movie }; + + var query = new InternalItemsQuery() + { + // Order by series name, season, and then episode number so that status updates are logged in order + ParentId = Guid.Parse(rawId), + OrderBy = new[] + { + (ItemSortBy.SeriesSortName, SortOrder.Ascending), + (ItemSortBy.ParentIndexNumber, SortOrder.Ascending), + (ItemSortBy.IndexNumber, SortOrder.Ascending), + }, + IncludeItemTypes = includes, + Recursive = true, + IsVirtualItem = false + }; + + _logger.LogDebug("Getting items"); + + var items = _libraryManager.GetItemList(query, false); + + if (items is null) + { + _logger.LogError("Library query result is null"); + return; + } + + // Queue all media on the server. + _logger.LogDebug("Iterating through library items"); + + foreach (var item in items) + { + if (item is Episode episode) + { + if (SkipEpisode(episode)) + { + _logger.LogInformation("Skipping episode: '{EpisodeName}' of series: '{SeriesName} S{Season}'", episode.Name, episode.SeriesName, episode.AiredSeasonNumber); + continue; + } + + QueueEpisode(episode); + } + else if (item is Movie movie) + { + if (skippedMovies.Contains(movie.Name)) + { + _logger.LogInformation("Skipping Movie: '{Name}'", movie.Name); + continue; + } + + // Movie can have multiple MediaSources like 1080p and a 4k file, they have different ids + foreach (MediaSourceInfo source in movie.GetMediaSources(false)) + { + _logger.LogInformation("Adding movie: '{Name} ({Format})'", movie.Name, source.Name); + QueueMovie(movie, source); + } + } + else + { + _logger.LogDebug("Item {Name} is not an episode or movie", item.Name); + continue; + } + } + + _logger.LogDebug("Queued {Count} media items", items.Count); + } + + // Test if should skip the episode + private bool SkipEpisode(Episode episode) + { + if (skippedTvShows.TryGetValue(episode.SeriesName, out var seasons)) + { + return (episode.AiredSeasonNumber != null && seasons.Contains(episode.AiredSeasonNumber.GetValueOrDefault())) ? true : false; + } + + return false; + } + + private void QueueEpisode(Episode episode) + { + if (Plugin.Instance is null) + { + throw new InvalidOperationException("plugin instance was null"); + } + + if (string.IsNullOrEmpty(episode.Path)) + { + _logger.LogWarning( + "Not queuing episode \"{Name}\" from series \"{Series}\" ({Id}) as no path was provided by Jellyfin", + episode.Name, + episode.SeriesName, + episode.Id); + return; + } + + if (episode.RunTimeTicks is null) + { + _logger.LogWarning( + "Not queuing episode \"{Name}\" from series \"{Series}\" ({Id}) as no duration was provided by Jellyfin", + episode.Name, + episode.SeriesName, + episode.Id); + return; + } + + // Allocate a new list for each new season + _queuedMedia.TryAdd(episode.SeasonId, new List()); + + _queuedMedia[episode.SeasonId].Add(new QueuedMedia() + { + SeriesName = episode.SeriesName, + SeasonNumber = episode.AiredSeasonNumber ?? 0, + ItemId = episode.Id, + Name = episode.Name, + Path = episode.Path, + }); + } + + private void QueueMovie(Movie movie, MediaSourceInfo source) + { + if (Plugin.Instance is null) + { + throw new InvalidOperationException("plugin instance was null"); + } + + if (string.IsNullOrEmpty(source.Path)) + { + _logger.LogWarning( + "Not queuing movie '{Name} ({Source})' ({Id}) as no path was provided by Jellyfin", + movie.Name, + source.Name, + source.Id); + return; + } + + if (movie.RunTimeTicks is null) + { + _logger.LogWarning( + "Not queuing movie '{Name} ({Source})' ({Id}) as no duration was provided by Jellyfin", + movie.Name, + source.Name, + source.Id); + return; + } + + // Allocate a new list for each movie + _queuedMedia.TryAdd(Guid.Parse(source.Id), new List()); + + _queuedMedia[Guid.Parse(source.Id)].Add(new QueuedMedia() + { + SeriesName = movie.Name, + SeasonNumber = 0, + ItemId = Guid.Parse(source.Id), + Name = $"{movie.Name} ({source.Name})", + Path = source.Path, + IsEpisode = false, + }); + } + + /// + /// Verify that a collection of queued media items still exist in Jellyfin and in storage. + /// This is done to ensure that we don't use items that were deleted between the call to GetMediaItems() and popping them from the queue. + /// + /// Queued media items. + /// Media items that have been verified to exist in Jellyfin and in storage. + public ReadOnlyCollection + VerifyQueue(ReadOnlyCollection candidates) + { + var verified = new List(); + + foreach (var candidate in candidates) + { + try + { + if (File.Exists(candidate.Path)) + { + verified.Add(candidate); + } + } + catch (Exception ex) + { + _logger.LogDebug( + "Skipping queue of {Name} ({Id}): {Exception}", + candidate.Name, + candidate.ItemId, + ex); + } + } + + return verified.AsReadOnly(); + } +} diff --git a/Jellyfin.Plugin.Edl/SheduledTasks/BaseEdlTask.cs b/Jellyfin.Plugin.Edl/SheduledTasks/BaseEdlTask.cs index 3569ec0..90791d3 100644 --- a/Jellyfin.Plugin.Edl/SheduledTasks/BaseEdlTask.cs +++ b/Jellyfin.Plugin.Edl/SheduledTasks/BaseEdlTask.cs @@ -6,9 +6,7 @@ namespace Jellyfin.Plugin.Edl; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; -using MediaBrowser.Controller.Library; +using MediaBrowser.Model.MediaSegments; using Microsoft.Extensions.Logging; /// @@ -18,24 +16,14 @@ public class BaseEdlTask { private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory; - - private readonly ILibraryManager _libraryManager; - /// /// Initializes a new instance of the class. /// /// Task logger. - /// Logger factory. - /// Library manager. public BaseEdlTask( - ILogger logger, - ILoggerFactory loggerFactory, - ILibraryManager libraryManager) + ILogger logger) { _logger = logger; - _loggerFactory = loggerFactory; - _libraryManager = libraryManager; EdlManager.Initialize(_logger); } @@ -44,23 +32,26 @@ public BaseEdlTask( /// Create edls for all Segments on the server. /// /// Progress. + /// Media segments. + /// Force the file overwrite. /// Cancellation token. public void CreateEdls( IProgress progress, + ReadOnlyCollection segmentsQueue, + bool forceOverwrite, CancellationToken cancellationToken) { - var segmentsQueue = Plugin.Instance!.GetAllMediaSegments(); - var sortedSegments = new Dictionary>(); + var sortedSegments = new Dictionary>(); foreach (var segment in segmentsQueue) { - if (sortedSegments.TryGetValue(segment.ItemId, out var list)) + if (sortedSegments.TryGetValue(segment.ItemId, out List? list)) { - sortedSegments[segment.ItemId] = (List)list.Append(segment); + sortedSegments[segment.ItemId] = list.Append(segment).ToList(); } else { - sortedSegments.Add(segment.ItemId, new List { segment }); + sortedSegments.Add(segment.ItemId, new List { segment }); } } @@ -81,17 +72,10 @@ public void CreateEdls( return; } - EdlManager.UpdateEDLFile(segment); + EdlManager.UpdateEDLFile(segment, forceOverwrite); Interlocked.Add(ref totalProcessed, 1); progress.Report((totalProcessed * 100) / totalQueued); }); - - if (Plugin.Instance!.Configuration.OverwriteEdlFiles) - { - _logger.LogInformation("Turning EDL file regeneration flag off"); - Plugin.Instance!.Configuration.OverwriteEdlFiles = false; - Plugin.Instance!.SaveConfiguration(); - } } } diff --git a/Jellyfin.Plugin.Edl/SheduledTasks/CreateEdlTask.cs b/Jellyfin.Plugin.Edl/SheduledTasks/CreateEdlTask.cs index 0ed86cf..c333521 100644 --- a/Jellyfin.Plugin.Edl/SheduledTasks/CreateEdlTask.cs +++ b/Jellyfin.Plugin.Edl/SheduledTasks/CreateEdlTask.cs @@ -3,7 +3,9 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.Edl; +using MediaBrowser.Controller; using MediaBrowser.Controller.Library; +using MediaBrowser.Model.MediaSegments; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -18,17 +20,22 @@ public class CreateEdlTask : IScheduledTask private readonly ILibraryManager _libraryManager; + private readonly IMediaSegmentManager _mediaSegmentManager; + /// /// Initializes a new instance of the class. /// /// Logger factory. /// Library manager. + /// MediaSegment manager. public CreateEdlTask( ILoggerFactory loggerFactory, - ILibraryManager libraryManager) + ILibraryManager libraryManager, + IMediaSegmentManager mediaSegmentManager) { _loggerFactory = loggerFactory; _libraryManager = libraryManager; + _mediaSegmentManager = mediaSegmentManager; } /// @@ -57,7 +64,7 @@ public CreateEdlTask( /// Task progress. /// Cancellation token. /// Task. - public Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) { if (_libraryManager is null) { @@ -65,13 +72,26 @@ public Task ExecuteAsync(IProgress progress, CancellationToken cancellat } var baseEdlTask = new BaseEdlTask( - _loggerFactory.CreateLogger(), - _loggerFactory, - _libraryManager); + _loggerFactory.CreateLogger()); + + var queueManager = new QueueManager(_loggerFactory.CreateLogger(), _libraryManager); + + var segmentsList = new List(); + // get ItemIds + var mediaItems = queueManager.GetMediaItems(); + // get MediaSegments from itemIds + foreach (var kvp in mediaItems) + { + foreach (var media in kvp.Value) + { + segmentsList.AddRange(await _mediaSegmentManager.GetSegmentsAsync(media.ItemId, null).ConfigureAwait(false)); + } + } - baseEdlTask.CreateEdls(progress, cancellationToken); + // write edl files + baseEdlTask.CreateEdls(progress, segmentsList.AsReadOnly(), false, cancellationToken); - return Task.CompletedTask; + return; } /// diff --git a/README.md b/README.md index 0c8b00a..a423713 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # Jellyfin Plugin EDL -Jellyfin .edl file creation plugin for Kodi and other compatible players. See [Kodi Wiki](https://kodi.wiki/view/Edit_decision_list). +Jellyfin .edl file creation plugin for Kodi and other compatible players. See [Kodi Wiki](https://kodi.wiki/view/Edit_decision_list). Converts MediaSegments based on plugin settings. ## Requirements -- Jellyfin Server with MediaSegment API! - - Please read these [instructions](https://github.com/endrl/segment-editor#installation) +- ⚠️ Jellyfin 10.10 unstable - A writeable media library! You can't use this plugin with read only media libraries! ## Installation instructions @@ -14,10 +13,7 @@ Jellyfin .edl file creation plugin for Kodi and other compatible players. See [K 2. Install the EDL Creator plugin from the General section 3. Restart Jellyfin 4. Go to Dashboard -> Scheduled Tasks -> Create EDL and click the play button - -## Issues - -- MediaSegment.ItemId is not linked to Library ItemId. Crash(?) may be possible when you look for a non existing ItemId. (Needs upstream evaluation) +5. There is no Task Timer configured, create one if you want to scan daily ### Debug Logging diff --git a/build.yaml b/build.yaml index d7a62f1..e0ace29 100644 --- a/build.yaml +++ b/build.yaml @@ -1,9 +1,9 @@ --- name: "EDL Creator" guid: "6B0E323A-4AEE-4B10-813F-1E060488AE90" -version: "0.1.0.0" -targetAbi: "10.9.0.0" -framework: "net7.0" +version: "0.3.0.0" +targetAbi: "10.10.0.0" +framework: "net8.0" overview: "Create .edl files from Media Segments" description: > These files can be used by Kodi and other players