feat: add smart collections to backend.

This commit is contained in:
redxef 2025-01-19 15:30:32 +01:00
parent dcdee2403c
commit f5448e8a51
Signed by: redxef
GPG key ID: 7DAC3AA211CBD921
8 changed files with 326 additions and 57 deletions

View file

@ -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();

View file

@ -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<Guid> FilterCollectionItems(IEnumerable<BaseItem> items, User? user, string name, string program, string sortProgram) {
List<BaseItem> results = new List<BaseItem>();
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<BaseItem>();
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);
}
}
}

View file

@ -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<Plugin> 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<TaskTriggerInfo> GetDefaultTriggers() {
return new[] {
new TaskTriggerInfo {
IntervalTicks = TimeSpan.FromHours(24).Ticks,
Type = TaskTriggerInfo.TriggerInterval,
}
};
}
private async Task<CollectionId> CreateNewCollection(string name) {
_logger.LogDebug("Creating collection '{0}'", name);
return (await _collectionManager.CreateCollectionAsync(
new CollectionCreationOptions {
Name = name,
}
)).Id;
}
private IEnumerable<BaseItem> GetAllMedia() {
var req = new InternalItemsQuery() {
IncludeItemTypes = AvailableFilterItems,
Recursive = true,
};
return _libraryManager.GetItemsResult(req).Items;
}
public async Task ExecuteAsync(IProgress<double> 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));
}
}
}

View file

@ -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<Guid> FilterPlaylistItems(IEnumerable<BaseItem> items, User user, SmartPlaylistDto smartPlaylist) {
List<BaseItem> results = new List<BaseItem>();
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;

View file

@ -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; }
}
}

View file

@ -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();
}
}
}

View file

@ -7,6 +7,12 @@ namespace Jellyfin.Plugin.SmartPlaylist {
Task SaveSmartPlaylistAsync(SmartPlaylistDto smartPlaylist);
void DeleteSmartPlaylistById(SmartPlaylistId smartPlaylistId);
void DeleteSmartPlaylist(SmartPlaylistDto smartPlaylist);
Task<SmartCollectionDto> GetSmartCollectionAsync(SmartCollectionId smartCollectionId);
Task<SmartCollectionDto[]> 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<SmartCollectionDto> LoadCollectionAsync(string filename) {
var r = await File.ReadAllTextAsync(filename);
if (r.Equals("")) {
r = "{}";
}
var dto = new DeserializerBuilder().Build().Deserialize<SmartCollectionDto>(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<SmartCollectionDto> GetSmartCollectionAsync(SmartCollectionId smartCollectionId) {
string filename = _fileSystem.FindSmartCollectionFilePath(smartCollectionId);
return await LoadCollectionAsync(filename).ConfigureAwait(false);
}
public async Task<SmartCollectionDto[]> 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);
}
}
}

View file

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