diff --git a/Jellyfin.Plugin.SmartPlaylist/Api/SmartCollectionController.cs b/Jellyfin.Plugin.SmartPlaylist/Api/SmartCollectionController.cs new file mode 100644 index 0000000..da0f62d --- /dev/null +++ b/Jellyfin.Plugin.SmartPlaylist/Api/SmartCollectionController.cs @@ -0,0 +1,62 @@ +using System.Net.Mime; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Providers; +using System.ComponentModel.DataAnnotations; +using MediaBrowser.Controller.Collections; +using MediaBrowser.Common.Api; + + +namespace Jellyfin.Plugin.SmartPlaylist.Api { + [ApiController] + [Authorize(Policy = Policies.RequiresElevation)] + [Route("SmartCollection")] + [Produces(MediaTypeNames.Application.Json)] + public class SmartCollectionController : ControllerBase { + private readonly ILogger _logger; + 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 SmartCollectionController( + ILogger logger, + ILibraryManager libraryManager, + IUserManager userManager, + IProviderManager providerManager, + IFileSystem fileSystem, + ICollectionManager collectionManager, + IServerApplicationPaths serverApplicationPaths + ) { + _logger = logger; + _libraryManager = libraryManager; + _userManager = userManager; + _providerManager = providerManager; + _fileSystem = fileSystem; + _collectionManager = collectionManager; + + _store = new Store(new SmartFileSystem(serverApplicationPaths)); + } + + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> GetCollections() { + return Ok(await _store.GetAllSmartCollectionsAsync()); + } + + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + public async Task SetCollection([FromBody, Required] SmartCollectionDto smartCollection) { + await _store.SaveSmartCollectionAsync(smartCollection); + return Created(); + } + } +} diff --git a/Jellyfin.Plugin.SmartPlaylist/Api/SmartPlaylistController.cs b/Jellyfin.Plugin.SmartPlaylist/Api/SmartPlaylistController.cs new file mode 100644 index 0000000..0e722cf --- /dev/null +++ b/Jellyfin.Plugin.SmartPlaylist/Api/SmartPlaylistController.cs @@ -0,0 +1,61 @@ +using System.Net.Mime; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Playlists; +using MediaBrowser.Controller.Providers; +using System.ComponentModel.DataAnnotations; +using MediaBrowser.Common.Api; + +namespace Jellyfin.Plugin.SmartPlaylist.Api { + [ApiController] + [Authorize(Policy = Policies.RequiresElevation)] + [Route("SmartPlaylist")] + [Produces(MediaTypeNames.Application.Json)] + public class SmartPlaylistController : ControllerBase { + private readonly ILogger _logger; + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IProviderManager _providerManager; + private readonly IFileSystem _fileSystem; + private readonly IPlaylistManager _playlistManager; + + private readonly IStore _store; + + public SmartPlaylistController( + ILogger logger, + ILibraryManager libraryManager, + IUserManager userManager, + IProviderManager providerManager, + IFileSystem fileSystem, + IPlaylistManager playlistManager, + IServerApplicationPaths serverApplicationPaths + ) { + _logger = logger; + _libraryManager = libraryManager; + _userManager = userManager; + _providerManager = providerManager; + _fileSystem = fileSystem; + _playlistManager = playlistManager; + + _store = new Store(new SmartFileSystem(serverApplicationPaths)); + } + + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> GetPlaylists() { + return Ok(await _store.GetAllSmartPlaylistsAsync()); + } + + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + public async Task SetPlaylist([FromBody, Required] SmartPlaylistDto smartPlaylist) { + await _store.SaveSmartPlaylistAsync(smartPlaylist); + return Created(); + } + } +} diff --git a/Jellyfin.Plugin.SmartPlaylist/PluginConfiguration.cs b/Jellyfin.Plugin.SmartPlaylist/Configuration/PluginConfiguration.cs similarity index 67% rename from Jellyfin.Plugin.SmartPlaylist/PluginConfiguration.cs rename to Jellyfin.Plugin.SmartPlaylist/Configuration/PluginConfiguration.cs index d8eab59..aa35a00 100644 --- a/Jellyfin.Plugin.SmartPlaylist/PluginConfiguration.cs +++ b/Jellyfin.Plugin.SmartPlaylist/Configuration/PluginConfiguration.cs @@ -1,5 +1,4 @@ using MediaBrowser.Model.Plugins; -using MediaBrowser.Controller; namespace Jellyfin.Plugin.SmartPlaylist { public class PluginConfiguration : BasePluginConfiguration { @@ -43,30 +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)); } - private Store store { get; set; } public string InitialProgram { get; set; } - public SmartPlaylistDto[] Playlists { - get { - return store.GetAllSmartPlaylistsAsync().GetAwaiter().GetResult(); - } - set { - var existing = store.GetAllSmartPlaylistsAsync().GetAwaiter().GetResult().Select(x => x.Id).ToList(); - foreach (var p in value) { - existing.Remove(p.Id); - store.SaveSmartPlaylistAsync(p).GetAwaiter().GetResult(); - } - foreach (var p in existing) { - store.DeleteSmartPlaylistById(p); - } - } - } - public object[][] Users { - get { - return Plugin.Instance.UserManager.Users.Select(x => new object[]{x.Id, x.Username}).ToArray(); - } - set { } - } } } diff --git a/Jellyfin.Plugin.SmartPlaylist/Configuration/configPage.html b/Jellyfin.Plugin.SmartPlaylist/Configuration/configPage.html new file mode 100644 index 0000000..c12584f --- /dev/null +++ b/Jellyfin.Plugin.SmartPlaylist/Configuration/configPage.html @@ -0,0 +1,32 @@ + + + + + SmartPlaylist + + +
+
+
+
+
+ +
A program which can set up the environment
+ +
+
+ +
+
+
+
+ +
+ + diff --git a/Jellyfin.Plugin.SmartPlaylist/Configuration/configPage.js b/Jellyfin.Plugin.SmartPlaylist/Configuration/configPage.js new file mode 100644 index 0000000..70fb8be --- /dev/null +++ b/Jellyfin.Plugin.SmartPlaylist/Configuration/configPage.js @@ -0,0 +1,23 @@ +var SmartPlaylistConfig = { + pluginUniqueId: 'dd2326e3-4d3e-4bfc-80e6-28502c1131df' +}; + +document.querySelector('#SmartPlaylistConfigPage') + .addEventListener('pageshow', function() { + Dashboard.showLoadingMsg(); + ApiClient.getPluginConfiguration(SmartPlaylistConfig.pluginUniqueId).then(function (config) { + document.querySelector('#InitialProgram').value = config.InitialProgram; + Dashboard.hideLoadingMsg(); + }); + }); + +document.querySelector('#SmartPlaylistConfigForm') + .addEventListener('submit', function(e) { + Dashboard.showLoadingMsg(); + ApiClient.getPluginConfiguration(SmartPlaylistConfig.pluginUniqueId).then(function (config) { + config.InitialProgram = document.querySelector('#InitialProgram').value; + }); + e.preventDefault(); + return false; +}); + diff --git a/Jellyfin.Plugin.SmartPlaylist/Lisp/Interpreter.cs b/Jellyfin.Plugin.SmartPlaylist/Lisp/Interpreter.cs index 7027c09..c713d79 100644 --- a/Jellyfin.Plugin.SmartPlaylist/Lisp/Interpreter.cs +++ b/Jellyfin.Plugin.SmartPlaylist/Lisp/Interpreter.cs @@ -279,7 +279,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp { IList r = new List(); MethodInfo? mi = o.Value().GetType().GetMethod(s.Value(), l_types); if (mi == null) { - throw new ApplicationException($"{o.Value()} has no method {s.Value()}"); + throw new ApplicationException($"{o.Value()} ({o.Value().GetType()}) has no method {s.Value()}"); } return Object.FromBase(mi.Invoke(o.Value(), l_)); diff --git a/Jellyfin.Plugin.SmartPlaylist/Pages/smartCollections.html b/Jellyfin.Plugin.SmartPlaylist/Pages/smartCollections.html new file mode 100644 index 0000000..15c50c6 --- /dev/null +++ b/Jellyfin.Plugin.SmartPlaylist/Pages/smartCollections.html @@ -0,0 +1,51 @@ + + + + + SmartCollection + + +
+
+
+
+
+ + +
+
+ + +
+
+ +
A program which should return t or nil to include or exclude the provided item.
+ +
+
+ +
A program which should return a list of items to include in the collection, sorted however you like.
+ +
+
+ +
Is the collection enabled.
+ +
+
+ +
+
+
+
+ +
+ + diff --git a/Jellyfin.Plugin.SmartPlaylist/Pages/smartCollections.js b/Jellyfin.Plugin.SmartPlaylist/Pages/smartCollections.js new file mode 100644 index 0000000..db0ec34 --- /dev/null +++ b/Jellyfin.Plugin.SmartPlaylist/Pages/smartCollections.js @@ -0,0 +1,116 @@ + +var COLLECTIONS = [ + { + Id: 'My New Smartcollection', + Name: 'My New Smartcollection', + Program: 'nil', + SortProgram: '(begin *items*)', + CollectionId: '00000000000000000000000000000000', + Enabled: true, + } +]; + +function fillForm(collection) { + const editName = document.querySelector('#SmartcollectionEditName'); + const editProgram = document.querySelector('#SmartcollectionEditProgram'); + const editSortProgram = document.querySelector('#SmartcollectionEditSortProgram'); + const editEnabled = document.querySelector('#SmartcollectionEditEnabled'); + editName.value = collection.Name; + editProgram.value = collection.Program; + editSortProgram.value = collection.SortProgram; + editEnabled.checked = collection.Enabled; +} + +function fillCollectionSelect(collections) { + const selection = document.querySelector('#SmartcollectionSelection'); + selection.innerHTML = ''; + var o = document.createElement('option'); + o.value = null; + o.innerHTML = 'Create new collection ...'; + selection.appendChild(o); + for (const i of collections.slice(1)) { + var o = document.createElement('option'); + o.value = i.Id; + o.innerHTML = i.Name; + selection.appendChild(o); + } +} + +function jsonFromForm(collectionId) { + const editName = document.querySelector('#SmartcollectionEditName'); + const editProgram = document.querySelector('#SmartcollectionEditProgram'); + const editSortProgram = document.querySelector('#SmartcollectionEditSortProgram'); + const editEnabled = document.querySelector('#SmartcollectionEditEnabled'); + if (collectionId === null) { + collectionId = '00000000000000000000000000000000' + } + return { + Id: editName.value, + Name: editName.value, + Program: editProgram.value, + SortProgram: editSortProgram.value, + CollectionId: collectionId, + Enabled: editEnabled.checked, + }; +} + +ApiClient.getSmartCollections = function () { + const url = ApiClient.getUrl('SmartCollection'); + return this.ajax({ + type: 'GET', + url: url, + dataType: 'json', + }); +} + +ApiClient.setSmartCollection = function (c) { + const url = ApiClient.getUrl('SmartCollection'); + return this.ajax({ + type: 'POST', + url: url, + dataType: 'json', + contentType: 'application/json; charset=UTF-8', + data: JSON.stringify(c), + }); +} +function initial_load(selectedId) { + Dashboard.showLoadingMsg(); + ApiClient.getSmartCollections().then(function (collections) { + COLLECTIONS = [COLLECTIONS[0]].concat(collections); + const selection = document.querySelector('#SmartcollectionSelection'); + fillCollectionSelect(COLLECTIONS); + if (selectedId === null) { + selection.selectedIndex = 0; + } else { + selection.selectedIndex = COLLECTIONS.map(x => x.Id).indexOf(selectedId); + } + fillForm(COLLECTIONS[selection.selectedIndex]); + Dashboard.hideLoadingMsg(); + }); +} +document.querySelector('#SmartCollectionConfigPage') + .addEventListener('viewshow', function() { + initial_load(null); + }); + +document.querySelector('#SmartCollectionConfigPage') + .addEventListener('pageshow', function() { + initial_load(null); + }); + +document.querySelector('#SmartcollectionSelection') + .addEventListener('change', function() { + const selection = document.querySelector('#SmartcollectionSelection'); + fillForm(COLLECTIONS[selection.selectedIndex]); + }); + +document.querySelector('#SmartCollectionConfigForm') + .addEventListener('submit', function (e) { + Dashboard.showLoadingMsg(); + const selection = document.querySelector('#SmartcollectionSelection'); + const selectedId = COLLECTIONS[selection.selectedIndex].Id; + ApiClient.setSmartCollection(jsonFromForm(COLLECTIONS[selection.selectedIndex].CollectionId)).then(function () { + initial_load(selectedId); + }); + e.preventDefault(); + }); diff --git a/Jellyfin.Plugin.SmartPlaylist/Pages/smartPlaylists.html b/Jellyfin.Plugin.SmartPlaylist/Pages/smartPlaylists.html new file mode 100644 index 0000000..5720218 --- /dev/null +++ b/Jellyfin.Plugin.SmartPlaylist/Pages/smartPlaylists.html @@ -0,0 +1,57 @@ + + + + + SmartPlaylist + + +
+
+
+
+
+ + +
+
+ + +
+
+ +
A program which should return t or nil to include or exclude the provided item.
+ +
+
+ +
A program which should return a list of items to include in the playlist, sorted however you like.
+ +
+
+ +
Which users should get access to the playlist.
+ +
+
+ +
Is the playlist enabled.
+ +
+
+ +
+
+
+
+ +
+ + diff --git a/Jellyfin.Plugin.SmartPlaylist/Pages/smartPlaylists.js b/Jellyfin.Plugin.SmartPlaylist/Pages/smartPlaylists.js new file mode 100644 index 0000000..0eda8ed --- /dev/null +++ b/Jellyfin.Plugin.SmartPlaylist/Pages/smartPlaylists.js @@ -0,0 +1,143 @@ + +var PLAYLISTS = [ + { + Id: 'My New Smartplaylist', + Name: 'My New Smartplaylist', + Program: '(is-favourite)', + SortProgram: '(begin *items*)', + Playlists: [], + Enabled: true, + } +]; +var USERS = []; + +function fillForm(playlist, users) { + const editName = document.querySelector('#SmartplaylistEditName'); + const editProgram = document.querySelector('#SmartplaylistEditProgram'); + const editSortProgram = document.querySelector('#SmartplaylistEditSortProgram'); + const editUsers = document.querySelector('#SmartplaylistEditUsers'); + const editEnabled = document.querySelector('#SmartplaylistEditEnabled'); + editName.value = playlist.Name; + editProgram.value = playlist.Program; + editSortProgram.value = playlist.SortProgram; + editUsers.innerHTML = ''; + + var added_users = [] + for (const p of playlist.Playlists) { + var o = document.createElement('option'); + o.value = btoa(JSON.stringify(p)); + o.innerHTML = users.filter(u => u.Id == p.UserId).map(u => u.Name)[0]; + o.setAttribute('selected', 'selected'); + editUsers.appendChild(o); + added_users.push(p.UserId); + } + for (const u of users) { + if (added_users.includes(u.Id)) { + continue; + } + var o = document.createElement('option'); + o.value = btoa(JSON.stringify({ + 'PlaylistId': '00000000000000000000000000000000', + 'UserId': u.Id, + })); + o.innerHTML = u.Name; + editUsers.appendChild(o); + } + editEnabled.checked = playlist.Enabled; +} + +function fillPlaylistSelect(playlists) { + const selection = document.querySelector('#SmartplaylistSelection'); + selection.innerHTML = ''; + var o = document.createElement('option'); + o.value = null; + o.innerHTML = 'Create new playlist ...'; + selection.appendChild(o); + for (const i of playlists.slice(1)) { + var o = document.createElement('option'); + o.value = i.Id; + o.innerHTML = i.Name; + selection.appendChild(o); + } +} + +function jsonFromForm() { + const editName = document.querySelector('#SmartplaylistEditName'); + const editProgram = document.querySelector('#SmartplaylistEditProgram'); + const editSortProgram = document.querySelector('#SmartplaylistEditSortProgram'); + const editUsers = document.querySelector('#SmartplaylistEditUsers'); + const editEnabled = document.querySelector('#SmartplaylistEditEnabled'); + return { + Id: editName.value, + Name: editName.value, + Program: editProgram.value, + SortProgram: editSortProgram.value, + Playlists: Array.from(editUsers.options).filter((x) => x.selected).map(x => JSON.parse(atob(x.value))), + Enabled: editEnabled.checked, + }; +} + +ApiClient.getSmartPlaylists = function () { + const url = ApiClient.getUrl('SmartPlaylist'); + return this.ajax({ + type: 'GET', + url: url, + dataType: 'json', + }); +} + +ApiClient.setSmartPlaylist = function (p) { + const url = ApiClient.getUrl('SmartPlaylist'); + return this.ajax({ + type: 'POST', + url: url, + dataType: 'json', + contentType: 'application/json; charset=UTF-8', + data: JSON.stringify(p), + }); +} + +function initial_load(selectedId) { + Dashboard.showLoadingMsg(); + ApiClient.getSmartPlaylists().then(function (playlists) { + PLAYLISTS = [PLAYLISTS[0]].concat(playlists); + ApiClient.getUsers().then(function (users) { + USERS = users; + const selection = document.querySelector('#SmartplaylistSelection'); + fillPlaylistSelect(PLAYLISTS); + if (selectedId === null) { + selection.selectedIndex = 0; + } else { + selection.selectedIndex = PLAYLISTS.map(x => x.Id).indexOf(selectedId); + } + fillForm(PLAYLISTS[selection.selectedIndex], USERS); + Dashboard.hideLoadingMsg(); + }); + }); +} + +document.querySelector('#SmartPlaylistConfigPage') + .addEventListener('viewshow', function() { + initial_load(null); + }); +document.querySelector('#SmartPlaylistConfigPage') + .addEventListener('pageshow', function() { + initial_load(null); + }); + +document.querySelector('#SmartplaylistSelection') + .addEventListener('change', function() { + const selection = document.querySelector('#SmartplaylistSelection'); + fillForm(PLAYLISTS[selection.selectedIndex], USERS); + }); + +document.querySelector('#SmartPlaylistConfigForm') + .addEventListener('submit', function (e) { + Dashboard.showLoadingMsg(); + const selection = document.querySelector('#SmartplaylistSelection'); + const selectedId = PLAYLISTS[selection.selectedIndex].Id; + ApiClient.setSmartPlaylist(jsonFromForm()).then(function () { + initial_load(selectedId); + }); + e.preventDefault(); + }); diff --git a/Jellyfin.Plugin.SmartPlaylist/Plugin.cs b/Jellyfin.Plugin.SmartPlaylist/Plugin.cs index 72d7763..0d962f6 100644 --- a/Jellyfin.Plugin.SmartPlaylist/Plugin.cs +++ b/Jellyfin.Plugin.SmartPlaylist/Plugin.cs @@ -27,8 +27,32 @@ namespace Jellyfin.Plugin.SmartPlaylist { return new[] { new PluginPageInfo { Name = this.Name, - EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.configPage.html", GetType().Namespace) - } + EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace), + }, + new PluginPageInfo { + Name = "configPage.js", + EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.js", GetType().Namespace), + }, + new PluginPageInfo { + Name = "Smart Playlists", + DisplayName = "Smart Playlists", + EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.smartPlaylists.html", GetType().Namespace), + EnableInMainMenu = true, + }, + new PluginPageInfo { + Name = "smartPlaylists.js", + EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.smartPlaylists.js", GetType().Namespace), + }, + new PluginPageInfo { + Name = "Smart Collections", + DisplayName = "Smart Collections", + EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.smartCollections.html", GetType().Namespace), + EnableInMainMenu = true, + }, + new PluginPageInfo { + Name = "smartCollections.js", + EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.smartCollections.js", GetType().Namespace), + }, }; } } 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..d450d5b --- /dev/null +++ b/Jellyfin.Plugin.SmartPlaylist/ScheduledTasks/GenerateCollection.cs @@ -0,0 +1,108 @@ +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 MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; + + +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.LinkedChildren.Select(x => x.ItemId).Where(x => x != null).Select(x => x.Value); + await _collectionManager.RemoveFromCollectionAsync(collectionId, existingItems); + } + } +} diff --git a/Jellyfin.Plugin.SmartPlaylist/ScheduledTasks/GeneratePlaylist.cs b/Jellyfin.Plugin.SmartPlaylist/ScheduledTasks/GeneratePlaylist.cs index ec6251f..6029ac9 100644 --- a/Jellyfin.Plugin.SmartPlaylist/ScheduledTasks/GeneratePlaylist.cs +++ b/Jellyfin.Plugin.SmartPlaylist/ScheduledTasks/GeneratePlaylist.cs @@ -7,27 +7,17 @@ using MediaBrowser.Controller.Playlists; 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.Model.Playlists; using Jellyfin.Plugin.SmartPlaylist.Lisp; using Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler; -using Lisp_Object = Jellyfin.Plugin.SmartPlaylist.Lisp.Object; -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 +34,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,62 +70,35 @@ 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 Executor executor = SetupExecutor(); - executor.environment.Set("*user*", Lisp_Object.FromBase(user)); + executor.environment.Set("*user*", Lisp.Object.FromBase(user)); foreach (var i in items) { - executor.environment.Set("*item*", Lisp_Object.FromBase(i)); + 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())) { + if ((r is not Lisp.Boolean r_bool) || (r_bool.Value())) { _logger.LogDebug("Added '{0}' to Smart Playlist {1}", i, smartPlaylist.Name); results.Add(i); } } executor = SetupExecutor(); - executor.environment.Set("*user*", Lisp_Object.FromBase(user)); - executor.environment.Set("*items*", Lisp_Object.FromBase(results)); + executor.environment.Set("*user*", Lisp.Object.FromBase(user)); + executor.environment.Set("*items*", Lisp.Object.FromBase(results)); results = new List(); var sort_result = executor.eval(smartPlaylist.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) { + 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 if (sort_result == Lisp.Boolean.FALSE) { } else { throw new ApplicationException($"Did not return a list of items, returned {sort_result}"); } @@ -162,6 +124,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; diff --git a/Jellyfin.Plugin.SmartPlaylist/configPage.html b/Jellyfin.Plugin.SmartPlaylist/configPage.html deleted file mode 100644 index cd597d2..0000000 --- a/Jellyfin.Plugin.SmartPlaylist/configPage.html +++ /dev/null @@ -1,201 +0,0 @@ - - - - - SmartPlaylist - - -
-
-
-
-
- -
A program which can set up the environment
- -
-
- - -
-
- - -
-
- -
A program which should return t or nil to include or exclude the provided item.
- -
-
- -
A program which should return a list of items to include in the playlist, sorted however you like.
- -
-
- -
Which users should get access to the playlist.
- -
-
- -
Is the playlist enabled.
- -
-
- -
-
-
-
- - -
- - diff --git a/Jellyfin.Plugin.SmartPlaylist/jellyfin-smart-playlist.csproj b/Jellyfin.Plugin.SmartPlaylist/jellyfin-smart-playlist.csproj index f33e9c9..732ae1a 100644 --- a/Jellyfin.Plugin.SmartPlaylist/jellyfin-smart-playlist.csproj +++ b/Jellyfin.Plugin.SmartPlaylist/jellyfin-smart-playlist.csproj @@ -15,8 +15,18 @@ - - + + + + + + + + + + + +