diff --git a/Jellyfin.Plugin.SmartPlaylist/PluginConfiguration.cs b/Jellyfin.Plugin.SmartPlaylist/PluginConfiguration.cs index d8eab59..b3a5cfc 100644 --- a/Jellyfin.Plugin.SmartPlaylist/PluginConfiguration.cs +++ b/Jellyfin.Plugin.SmartPlaylist/PluginConfiguration.cs @@ -1,5 +1,4 @@ using MediaBrowser.Model.Plugins; -using MediaBrowser.Controller; namespace Jellyfin.Plugin.SmartPlaylist { public class PluginConfiguration : BasePluginConfiguration { @@ -43,7 +42,7 @@ namespace Jellyfin.Plugin.SmartPlaylist { (define find-parent (lambda (typename) (invoke-generic *item* "FindParent" nil (list typename)))) (define find-artist (lambda nil (find-parent "MediaBrowser.Controller.Entities.Audio.MusicArtist, MediaBrowser.Controller")))) """; - store = new Store(new SmartPlaylistFileSystem(Plugin.Instance.ServerApplicationPaths)); + store = new Store(new SmartFileSystem(Plugin.Instance.ServerApplicationPaths)); } private Store store { get; set; } public string InitialProgram { get; set; } @@ -62,6 +61,21 @@ namespace Jellyfin.Plugin.SmartPlaylist { } } } + public SmartCollectionDto[] Collections { + get { + return store.GetAllSmartCollectionsAsync().GetAwaiter().GetResult(); + } + set { + var existing = store.GetAllSmartCollectionsAsync().GetAwaiter().GetResult().Select(x => x.Id).ToList(); + foreach (var p in value) { + existing.Remove(p.Id); + store.SaveSmartCollectionAsync(p).GetAwaiter().GetResult(); + } + foreach (var p in existing) { + store.DeleteSmartCollectionById(p); + } + } + } public object[][] Users { get { return Plugin.Instance.UserManager.Users.Select(x => new object[]{x.Id, x.Username}).ToArray(); diff --git a/Jellyfin.Plugin.SmartPlaylist/ScheduledTasks/Common.cs b/Jellyfin.Plugin.SmartPlaylist/ScheduledTasks/Common.cs new file mode 100644 index 0000000..b5adb11 --- /dev/null +++ b/Jellyfin.Plugin.SmartPlaylist/ScheduledTasks/Common.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.Logging; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; + +using Jellyfin.Plugin.SmartPlaylist.Lisp; +using Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler; + +namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks { + + public class Common { + public static readonly BaseItemKind[] AvailableFilterItems = { + BaseItemKind.Audio, + BaseItemKind.MusicAlbum, + BaseItemKind.Playlist, + }; + + + public readonly ILogger _logger; + public Common(ILogger logger) { + _logger = logger; + } + public Executor SetupExecutor() { + var env = new DefaultEnvironment(); + var executor = new Executor(env); + executor.builtins["logd"] = (x) => { + _logger.LogDebug(((Lisp.String)x.First()).Value(), x.Skip(1).ToArray()); + return Lisp.Boolean.TRUE; + }; + executor.builtins["logi"] = (x) => { + _logger.LogInformation(((Lisp.String)x.First()).Value(), x.Skip(1).ToArray()); + return Lisp.Boolean.TRUE; + }; + executor.builtins["logw"] = (x) => { + _logger.LogWarning(((Lisp.String)x.First()).Value(), x.Skip(1).ToArray()); + return Lisp.Boolean.TRUE; + }; + executor.builtins["loge"] = (x) => { + _logger.LogError(((Lisp.String)x.First()).Value(), x.Skip(1).ToArray()); + return Lisp.Boolean.TRUE; + }; + if (Plugin.Instance is not null) { + executor.eval(Plugin.Instance.Configuration.InitialProgram); + } else { + throw new ApplicationException("Plugin Instance is not yet initialized"); + } + return executor; + } + public IEnumerable FilterCollectionItems(IEnumerable items, User? user, string name, string program, string sortProgram) { + List results = new List(); + Expression expression = new Parser(StringTokenStream.generate(program)).parse(); // parse here, so that we don't repeat the work for each item + Executor executor = SetupExecutor(); + + executor.environment.Set("*user*", Lisp.Object.FromBase(user)); + foreach (var i in items) { + executor.environment.Set("*item*", Lisp.Object.FromBase(i)); + var r = executor.eval(expression); + _logger.LogTrace("Item {0} evaluated to {1}", i, r.ToString()); + if ((r is not Lisp.Boolean r_bool) || (r_bool.Value())) { + _logger.LogDebug("Added '{0}' to Smart Collection {1}", i, name); + results.Add(i); + } + } + executor = SetupExecutor(); + executor.environment.Set("*user*", Lisp.Object.FromBase(user)); + executor.environment.Set("*items*", Lisp.Object.FromBase(results)); + results = new List(); + var sort_result = executor.eval(sortProgram); + if (sort_result is Cons sorted_items) { + foreach (var i in sorted_items.ToList()) { + if (i is Lisp.Object iObject && iObject.Value() is BaseItem iBaseItem) { + results.Add(iBaseItem); + continue; + } + throw new ApplicationException($"Returned sorted list does contain unexpected items, got {i}"); + } + } else if (sort_result == Lisp.Boolean.FALSE) { + } else { + throw new ApplicationException($"Did not return a list of items, returned {sort_result}"); + } + return results.Select(x => x.Id); + } + } +} diff --git a/Jellyfin.Plugin.SmartPlaylist/ScheduledTasks/GenerateCollection.cs b/Jellyfin.Plugin.SmartPlaylist/ScheduledTasks/GenerateCollection.cs new file mode 100644 index 0000000..480de70 --- /dev/null +++ b/Jellyfin.Plugin.SmartPlaylist/ScheduledTasks/GenerateCollection.cs @@ -0,0 +1,113 @@ +using System.Globalization; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Collections; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using MediaBrowser.Model.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Model.Collections; + + +namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks { + public class GenerateCollection: Common, IScheduledTask { + + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IProviderManager _providerManager; + private readonly IFileSystem _fileSystem; + private readonly ICollectionManager _collectionManager; + + private readonly IStore _store; + + public GenerateCollection( + ILogger logger, + ILibraryManager libraryManager, + IUserManager userManager, + IProviderManager providerManager, + IFileSystem fileSystem, + ICollectionManager collectionManager, + IServerApplicationPaths serverApplicationPaths + ) : base(logger) { + _libraryManager = libraryManager; + _userManager = userManager; + _providerManager = providerManager; + _fileSystem = fileSystem; + _collectionManager = collectionManager; + + _store = new Store(new SmartFileSystem(serverApplicationPaths)); + } + + public string Category => "Library"; + public string Name => "(re)generate Smart Collections"; + public string Description => "Generate or regenerate all Smart Collections"; + public string Key => nameof(GenerateCollection); + + public IEnumerable GetDefaultTriggers() { + return new[] { + new TaskTriggerInfo { + IntervalTicks = TimeSpan.FromHours(24).Ticks, + Type = TaskTriggerInfo.TriggerInterval, + } + }; + } + + private async Task CreateNewCollection(string name) { + _logger.LogDebug("Creating collection '{0}'", name); + return (await _collectionManager.CreateCollectionAsync( + new CollectionCreationOptions { + Name = name, + } + )).Id; + } + + private IEnumerable GetAllMedia() { + var req = new InternalItemsQuery() { + IncludeItemTypes = AvailableFilterItems, + Recursive = true, + }; + return _libraryManager.GetItemsResult(req).Items; + } + + public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) { + _logger.LogInformation("Started regenerate Smart Collections"); + _logger.LogDebug("Loaded Assemblies:"); + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) { + _logger.LogDebug("- {0}", asm); + } + var i = 0; + var smartCollections = await _store.GetAllSmartCollectionsAsync(); + foreach (SmartCollectionDto dto in smartCollections) { + if (!dto.Enabled) { + progress.Report(100 * ((double)i)/smartCollections.Count()); + i += 1; + continue; + } + if (dto.CollectionId == Guid.Empty) { + dto.CollectionId = await CreateNewCollection(dto.Name); + _store.DeleteSmartCollection(dto); // delete in case the file was not the canonical one. + await _store.SaveSmartCollectionAsync(dto); + } + var insertItems = FilterCollectionItems(GetAllMedia(), null, dto.Name, dto.Program, dto.SortProgram).ToArray(); + await ClearCollection(dto.CollectionId); + await _collectionManager.AddToCollectionAsync(dto.CollectionId, insertItems); + i += 1; + progress.Report(100 * ((double)i)/smartCollections.Count()); + } + } + + private async Task ClearCollection(CollectionId collectionId) { + // fuck if I know + if (_libraryManager.GetItemById(collectionId) is not BoxSet collection) { + throw new ArgumentException(""); + } + var existingItems = collection.Children; + await _collectionManager.RemoveFromCollectionAsync(collectionId, existingItems.Select(x => x.Id)); + } + } +} diff --git a/Jellyfin.Plugin.SmartPlaylist/ScheduledTasks/GeneratePlaylist.cs b/Jellyfin.Plugin.SmartPlaylist/ScheduledTasks/GeneratePlaylist.cs index ec6251f..ed79e98 100644 --- a/Jellyfin.Plugin.SmartPlaylist/ScheduledTasks/GeneratePlaylist.cs +++ b/Jellyfin.Plugin.SmartPlaylist/ScheduledTasks/GeneratePlaylist.cs @@ -19,15 +19,8 @@ using Lisp_Boolean = Jellyfin.Plugin.SmartPlaylist.Lisp.Boolean; namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks { - public class GeneratePlaylist : IScheduledTask { + public class GeneratePlaylist : Common, IScheduledTask { - public static readonly BaseItemKind[] AvailableFilterItems = { - BaseItemKind.Audio, - BaseItemKind.MusicAlbum, - BaseItemKind.Playlist, - }; - - private readonly ILogger _logger; private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; private readonly IProviderManager _providerManager; @@ -44,15 +37,14 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks { IFileSystem fileSystem, IPlaylistManager playlistManager, IServerApplicationPaths serverApplicationPaths - ) { - _logger = logger; + ) : base(logger) { _libraryManager = libraryManager; _userManager = userManager; _providerManager = providerManager; _fileSystem = fileSystem; _playlistManager = playlistManager; - _store = new Store(new SmartPlaylistFileSystem(serverApplicationPaths)); + _store = new Store(new SmartFileSystem(serverApplicationPaths)); } public string Category => "Library"; @@ -81,33 +73,6 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks { return playlistGuid; } - private Executor SetupExecutor() { - var env = new DefaultEnvironment(); - var executor = new Executor(env); - executor.builtins["logd"] = (x) => { - _logger.LogDebug(((Lisp.String)x.First()).Value(), x.Skip(1).ToArray()); - return Lisp_Boolean.TRUE; - }; - executor.builtins["logi"] = (x) => { - _logger.LogInformation(((Lisp.String)x.First()).Value(), x.Skip(1).ToArray()); - return Lisp_Boolean.TRUE; - }; - executor.builtins["logw"] = (x) => { - _logger.LogWarning(((Lisp.String)x.First()).Value(), x.Skip(1).ToArray()); - return Lisp_Boolean.TRUE; - }; - executor.builtins["loge"] = (x) => { - _logger.LogError(((Lisp.String)x.First()).Value(), x.Skip(1).ToArray()); - return Lisp_Boolean.TRUE; - }; - if (Plugin.Instance is not null) { - executor.eval(Plugin.Instance.Configuration.InitialProgram); - } else { - throw new ApplicationException("Plugin Instance is not yet initialized"); - } - return executor; - } - private IEnumerable FilterPlaylistItems(IEnumerable items, User user, SmartPlaylistDto smartPlaylist) { List results = new List(); Expression expression = new Parser(StringTokenStream.generate(smartPlaylist.Program)).parse(); // parse here, so that we don't repeat the work for each item @@ -162,6 +127,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks { foreach (SmartPlaylistDto dto in all_playlists) { if (!dto.Enabled) { i += 1; + progress.Report(100 * ((double)i)/all_playlists.Count()); continue; } var changedDto = false; diff --git a/Jellyfin.Plugin.SmartPlaylist/SmartPlaylistDto.cs b/Jellyfin.Plugin.SmartPlaylist/SmartContainerDto.cs similarity index 91% rename from Jellyfin.Plugin.SmartPlaylist/SmartPlaylistDto.cs rename to Jellyfin.Plugin.SmartPlaylist/SmartContainerDto.cs index 5a2f41b..a16d9b0 100644 --- a/Jellyfin.Plugin.SmartPlaylist/SmartPlaylistDto.cs +++ b/Jellyfin.Plugin.SmartPlaylist/SmartContainerDto.cs @@ -108,4 +108,15 @@ namespace Jellyfin.Plugin.SmartPlaylist { info.AddValue("Enabled", Enabled); } } + + [Serializable] + public class SmartCollectionDto { + public SmartCollectionId Id { get; set; } + public CollectionId CollectionId { get; set; } + public string Name { get; set; } + public string Program { get; set; } + public string SortProgram { get; set; } + public string? Filename { get; set; } + public bool Enabled { get; set; } + } } diff --git a/Jellyfin.Plugin.SmartPlaylist/SmartPlaylistFileSystem.cs b/Jellyfin.Plugin.SmartPlaylist/SmartPlaylistFileSystem.cs index 77cf9b4..8514abb 100644 --- a/Jellyfin.Plugin.SmartPlaylist/SmartPlaylistFileSystem.cs +++ b/Jellyfin.Plugin.SmartPlaylist/SmartPlaylistFileSystem.cs @@ -3,34 +3,61 @@ using MediaBrowser.Controller; namespace Jellyfin.Plugin.SmartPlaylist { public interface ISmartPlaylistFileSystem { - public string StoragePath { get; } - public string GetSmartPlaylistFilePath(SmartPlaylistId smartPlaylistId); - public string FindSmartPlaylistFilePath(SmartPlaylistId smartPlaylistId); + public string PlaylistStoragePath { get; } + public string CollectionStoragePath { get; } + + public string GetSmartPlaylistFilePath(string id); + public string FindSmartPlaylistFilePath(string id); public string[] FindAllSmartPlaylistFilePaths(); + + public string GetSmartCollectionFilePath(string id); + public string FindSmartCollectionFilePath(string id); + public string[] FindAllSmartCollectionFilePaths(); } - public class SmartPlaylistFileSystem : ISmartPlaylistFileSystem { - public SmartPlaylistFileSystem(IServerApplicationPaths serverApplicationPaths) { - StoragePath = Path.Combine(serverApplicationPaths.DataPath, "smartplaylists"); - if (!Directory.Exists(StoragePath)) { Directory.CreateDirectory(StoragePath); } + public class SmartFileSystem : ISmartPlaylistFileSystem { + public SmartFileSystem(IServerApplicationPaths serverApplicationPaths) { + PlaylistStoragePath = Path.Combine(serverApplicationPaths.DataPath, "smartplaylists"); + CollectionStoragePath = Path.Combine(serverApplicationPaths.DataPath, "smartcollections"); + if (!Directory.Exists(PlaylistStoragePath)) { Directory.CreateDirectory(PlaylistStoragePath); } + if (!Directory.Exists(CollectionStoragePath)) { Directory.CreateDirectory(CollectionStoragePath); } } - public string StoragePath { get; } - public string GetSmartPlaylistFilePath(SmartPlaylistId smartPlaylistId) { - return Path.Combine(StoragePath, $"{smartPlaylistId}.yaml"); + public string PlaylistStoragePath { get; } + public string CollectionStoragePath { get; } + public string GetSmartPlaylistFilePath(string id) { + return Path.Combine(PlaylistStoragePath, $"{id}.yaml"); } - public string FindSmartPlaylistFilePath(SmartPlaylistId smartPlaylistId) { - return Directory.GetFiles(StoragePath, $"{smartPlaylistId}.yaml", SearchOption.AllDirectories).Concat( - Directory.GetFiles(StoragePath, $"{smartPlaylistId}.yml", SearchOption.AllDirectories) + public string FindSmartPlaylistFilePath(string id) { + return Directory.GetFiles(PlaylistStoragePath, $"{id}.yaml", SearchOption.AllDirectories).Concat( + Directory.GetFiles(PlaylistStoragePath, $"{id}.yml", SearchOption.AllDirectories) ).Concat( - Directory.GetFiles(StoragePath, $"{smartPlaylistId}.json", SearchOption.AllDirectories) + Directory.GetFiles(PlaylistStoragePath, $"{id}.json", SearchOption.AllDirectories) ).First(); } public string[] FindAllSmartPlaylistFilePaths() { - return Directory.GetFiles(StoragePath, "*.yaml", SearchOption.AllDirectories).Concat( - Directory.GetFiles(StoragePath, "*.yml", SearchOption.AllDirectories) + return Directory.GetFiles(PlaylistStoragePath, "*.yaml", SearchOption.AllDirectories).Concat( + Directory.GetFiles(PlaylistStoragePath, "*.yml", SearchOption.AllDirectories) ).Concat( - Directory.GetFiles(StoragePath, "*.json", SearchOption.AllDirectories) + Directory.GetFiles(PlaylistStoragePath, "*.json", SearchOption.AllDirectories) ).ToArray(); } + public string GetSmartCollectionFilePath(string id) { + return Path.Combine(CollectionStoragePath, $"{id}.yaml"); + } + public string FindSmartCollectionFilePath(string id) { + return Directory.GetFiles(CollectionStoragePath, $"{id}.yaml", SearchOption.AllDirectories).Concat( + Directory.GetFiles(CollectionStoragePath, $"{id}.yml", SearchOption.AllDirectories) + ).Concat( + Directory.GetFiles(CollectionStoragePath, $"{id}.json", SearchOption.AllDirectories) + ).First(); + } + public string[] FindAllSmartCollectionFilePaths() { + return Directory.GetFiles(CollectionStoragePath, "*.yaml", SearchOption.AllDirectories).Concat( + Directory.GetFiles(CollectionStoragePath, "*.yml", SearchOption.AllDirectories) + ).Concat( + Directory.GetFiles(CollectionStoragePath, "*.json", SearchOption.AllDirectories) + ).ToArray(); + } + } } diff --git a/Jellyfin.Plugin.SmartPlaylist/Store.cs b/Jellyfin.Plugin.SmartPlaylist/Store.cs index 4ec0d86..cecd6ef 100644 --- a/Jellyfin.Plugin.SmartPlaylist/Store.cs +++ b/Jellyfin.Plugin.SmartPlaylist/Store.cs @@ -7,6 +7,12 @@ namespace Jellyfin.Plugin.SmartPlaylist { Task SaveSmartPlaylistAsync(SmartPlaylistDto smartPlaylist); void DeleteSmartPlaylistById(SmartPlaylistId smartPlaylistId); void DeleteSmartPlaylist(SmartPlaylistDto smartPlaylist); + + Task GetSmartCollectionAsync(SmartCollectionId smartCollectionId); + Task GetAllSmartCollectionsAsync(); + Task SaveSmartCollectionAsync(SmartCollectionDto smartCollection); + void DeleteSmartCollectionById(SmartCollectionId smartCollectionId); + void DeleteSmartCollection(SmartCollectionDto smartCollection); } public class Store : IStore { @@ -59,5 +65,51 @@ namespace Jellyfin.Plugin.SmartPlaylist { if (File.Exists(smartPlaylist.Filename)) { File.Delete(smartPlaylist.Filename); } DeleteSmartPlaylistById(smartPlaylist.Id); } + + private async Task LoadCollectionAsync(string filename) { + var r = await File.ReadAllTextAsync(filename); + if (r.Equals("")) { + r = "{}"; + } + var dto = new DeserializerBuilder().Build().Deserialize(r); + if (dto == null) + { + throw new ApplicationException(""); + } + if (dto.Id != Path.GetFileNameWithoutExtension(filename)) { + dto.Id = Path.GetFileNameWithoutExtension(filename); + } + if (dto.Name != Path.GetFileNameWithoutExtension(filename)) { + dto.Name = Path.GetFileNameWithoutExtension(filename); + } + if (dto.Filename != filename) { + dto.Filename = filename; + } + return dto; + } + public async Task GetSmartCollectionAsync(SmartCollectionId smartCollectionId) { + string filename = _fileSystem.FindSmartCollectionFilePath(smartCollectionId); + return await LoadCollectionAsync(filename).ConfigureAwait(false); + } + public async Task GetAllSmartCollectionsAsync() { + var t = _fileSystem.FindAllSmartCollectionFilePaths().Select(LoadCollectionAsync).ToArray(); + await Task.WhenAll(t).ConfigureAwait(false); + return t.Where(x => x != null).Select(x => x.Result).ToArray(); + } + public async Task SaveSmartCollectionAsync(SmartCollectionDto smartCollection) { + string filename = _fileSystem.GetSmartCollectionFilePath(smartCollection.Id); + var text = new SerializerBuilder().Build().Serialize(smartCollection); + await File.WriteAllTextAsync(filename, text); + } + public void DeleteSmartCollectionById(SmartCollectionId smartCollectionId) { + try { + string filename = _fileSystem.FindSmartCollectionFilePath(smartCollectionId); + if (File.Exists(filename)) { File.Delete(filename); } + } catch (System.InvalidOperationException) {} + } + public void DeleteSmartCollection(SmartCollectionDto smartCollection) { + if (File.Exists(smartCollection.Filename)) { File.Delete(smartCollection.Filename); } + DeleteSmartCollectionById(smartCollection.Id); + } } } diff --git a/Jellyfin.Plugin.SmartPlaylist/Types.cs b/Jellyfin.Plugin.SmartPlaylist/Types.cs index 4e773e6..23b818a 100644 --- a/Jellyfin.Plugin.SmartPlaylist/Types.cs +++ b/Jellyfin.Plugin.SmartPlaylist/Types.cs @@ -2,4 +2,6 @@ global using System; global using UserId = System.Guid; global using PlaylistId = System.Guid; +global using CollectionId = System.Guid; global using SmartPlaylistId = string; +global using SmartCollectionId = string;