Compare commits
37 commits
Author | SHA1 | Date | |
---|---|---|---|
5944686d1d | |||
7a590ade7e | |||
f3a6b4a225 | |||
2624b83a79 | |||
1d472ed61f | |||
9bb68f6fac | |||
a00b01c577 | |||
b35df95d86 | |||
54a50ef04b | |||
b0593585df | |||
4db98eb1c8 | |||
80803cd048 | |||
571895bef8 | |||
2c6972cf86 | |||
feaf1b0e31 | |||
64ae51ba71 | |||
d8145565cd | |||
5689d0424c | |||
eef2f32e14 | |||
2f07efd215 | |||
1aeb4d3cff | |||
fef10b5736 | |||
49bacbffde | |||
49298a3ca2 | |||
aea313a813 | |||
a569d863ae | |||
001aad5ed9 | |||
614c0a0cb1 | |||
9ca1712b5d | |||
aa6fed146d | |||
6ac75835f0 | |||
f25eafd186 | |||
f5448e8a51 | |||
dcdee2403c | |||
d2a10a967e | |||
457755d743 | |||
4bc3b463cb |
31 changed files with 1384 additions and 399 deletions
132
Jellyfin.Plugin.SmartPlaylist/Api/LispPlaygroundController.cs
Normal file
132
Jellyfin.Plugin.SmartPlaylist/Api/LispPlaygroundController.cs
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
using System.Net.Mime;
|
||||||
|
using System.Text;
|
||||||
|
using System.Web;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using MediaBrowser.Common.Api;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
|
||||||
|
using YamlDotNet.Serialization;
|
||||||
|
|
||||||
|
using Jellyfin.Plugin.SmartPlaylist.Lisp;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.SmartPlaylist.Api {
|
||||||
|
|
||||||
|
[Serializable]
|
||||||
|
public class ProgramInputDto {
|
||||||
|
public string InputLinks { get; set; }
|
||||||
|
public string Program { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Serializable]
|
||||||
|
public class ProgramOutputDto {
|
||||||
|
public string Output { get; set; }
|
||||||
|
public string FinalExpression { get; set; }
|
||||||
|
public string Traceback { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
|
[Route("LispPlayground")]
|
||||||
|
[Produces(MediaTypeNames.Application.Json)]
|
||||||
|
public class LispPlaygroundController : ControllerBase {
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
|
||||||
|
public LispPlaygroundController(
|
||||||
|
ILogger<LispPlaygroundController> logger,
|
||||||
|
ILibraryManager libraryManager
|
||||||
|
) {
|
||||||
|
_logger = logger;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Executor SetupExecutor(StringBuilder sb, IList<string> inputs) {
|
||||||
|
var env = new DefaultEnvironment();
|
||||||
|
var executor = new Executor(env);
|
||||||
|
|
||||||
|
env["*items*"] = Lisp.Cons.FromList(inputs.Select(x => _libraryManager.GetItemById(x)).Select(x => Lisp.Object.FromBase(x)).ToList());
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
executor.builtins["print"] = (x) => {
|
||||||
|
sb.Append(string.Join(" ", x.Select((i) => {
|
||||||
|
if (i is Lisp.String i_s) {
|
||||||
|
return i_s.Value();
|
||||||
|
}
|
||||||
|
return i.ToString();
|
||||||
|
})));
|
||||||
|
return Lisp.Boolean.TRUE;
|
||||||
|
};
|
||||||
|
executor.builtins["println"] = (x) => {
|
||||||
|
sb.Append(string.Join(" ", x.Select((i) => {
|
||||||
|
if (i is Lisp.String i_s) {
|
||||||
|
return i_s.Value();
|
||||||
|
}
|
||||||
|
return i.ToString();
|
||||||
|
})));
|
||||||
|
sb.Append("\n");
|
||||||
|
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 List<string> extractItemIds(string s) {
|
||||||
|
List<string> r = new List<string>();
|
||||||
|
foreach (string line in s.Split("\n".ToCharArray(), StringSplitOptions.RemoveEmptyEntries)) {
|
||||||
|
var uri = new Uri(line);
|
||||||
|
uri = new Uri("http://some-domain.tld" + uri.Fragment.Substring(1));
|
||||||
|
var id = HttpUtility.ParseQueryString(uri.Query).Get("id");
|
||||||
|
if (id == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
r.Add(id);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ProgramOutputDto>> SetPlaylist() {
|
||||||
|
try {
|
||||||
|
string input;
|
||||||
|
using (StreamReader reader = new StreamReader(Request.Body)) {
|
||||||
|
input = await reader.ReadToEndAsync();
|
||||||
|
}
|
||||||
|
var dto = new DeserializerBuilder().Build().Deserialize<ProgramInputDto>(input);
|
||||||
|
StringBuilder output = new StringBuilder();
|
||||||
|
var e = SetupExecutor(output, extractItemIds(dto.InputLinks));
|
||||||
|
var r = e.eval(dto.Program).ToString();
|
||||||
|
return Ok(new ProgramOutputDto() {
|
||||||
|
FinalExpression = r,
|
||||||
|
Output = output.ToString(),
|
||||||
|
});
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return Ok(new ProgramOutputDto() {
|
||||||
|
Traceback = ex.ToString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
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<SmartCollectionController> 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<ActionResult<IReadOnlyList<SmartCollectionDto>>> GetCollections() {
|
||||||
|
return Ok(await _store.GetAllSmartCollectionsAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||||
|
public async Task<ActionResult> SetCollection([FromBody, Required] SmartCollectionDto smartCollection) {
|
||||||
|
await _store.SaveSmartCollectionAsync(smartCollection);
|
||||||
|
return Created();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete]
|
||||||
|
[Route("{smartCollectionId}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
public async Task<ActionResult> DeleteCollection([FromRoute, Required] SmartCollectionId smartCollectionId) {
|
||||||
|
_store.DeleteSmartCollectionById(smartCollectionId);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
69
Jellyfin.Plugin.SmartPlaylist/Api/SmartPlaylistController.cs
Normal file
69
Jellyfin.Plugin.SmartPlaylist/Api/SmartPlaylistController.cs
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
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<SmartPlaylistController> 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<ActionResult<IReadOnlyList<SmartPlaylistDto>>> GetPlaylists() {
|
||||||
|
return Ok(await _store.GetAllSmartPlaylistsAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||||
|
public async Task<ActionResult> SetPlaylist([FromBody, Required] SmartPlaylistDto smartPlaylist) {
|
||||||
|
await _store.SaveSmartPlaylistAsync(smartPlaylist);
|
||||||
|
return Created();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete]
|
||||||
|
[Route("{smartPlaylistId}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
public async Task<ActionResult> DeletePlaylist([FromRoute, Required] SmartPlaylistId smartPlaylistId) {
|
||||||
|
_store.DeleteSmartPlaylistById(smartPlaylistId);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,4 @@
|
||||||
using MediaBrowser.Model.Plugins;
|
using MediaBrowser.Model.Plugins;
|
||||||
using MediaBrowser.Controller;
|
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.SmartPlaylist {
|
namespace Jellyfin.Plugin.SmartPlaylist {
|
||||||
public class PluginConfiguration : BasePluginConfiguration {
|
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-parent (lambda (typename) (invoke-generic *item* "FindParent" nil (list typename))))
|
||||||
(define find-artist (lambda nil (find-parent "MediaBrowser.Controller.Entities.Audio.MusicArtist, MediaBrowser.Controller"))))
|
(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 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 { }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
32
Jellyfin.Plugin.SmartPlaylist/Configuration/configPage.html
Normal file
32
Jellyfin.Plugin.SmartPlaylist/Configuration/configPage.html
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>SmartPlaylist</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="SmartPlaylistConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox" data-controller="__plugin/configPage.js">
|
||||||
|
<div data-role="content">
|
||||||
|
<div class="content-primary">
|
||||||
|
<form id="SmartPlaylistConfigForm">
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="InitialProgram">Initial Program</label>
|
||||||
|
<div class="fieldDescription">A program which can set up the environment</div>
|
||||||
|
<textarea id="InitialProgram" class="emby-input smartplaylist-monospace" name="InitialProgram" rows="16" cols="120"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||||
|
<span>Save</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.smartplaylist-monospace {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
26
Jellyfin.Plugin.SmartPlaylist/Configuration/configPage.js
Normal file
26
Jellyfin.Plugin.SmartPlaylist/Configuration/configPage.js
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
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;
|
||||||
|
ApiClient.updatePluginConfiguration(SmartPlaylistConfig.pluginUniqueId, config).then(function (result) {
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
|
@ -3,7 +3,7 @@ using Jellyfin.Plugin.SmartPlaylist.Lisp;
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
|
namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
|
||||||
public class Parser {
|
public class Parser {
|
||||||
private StringTokenStream _sts;
|
internal StringTokenStream _sts;
|
||||||
public Parser(StringTokenStream tokens) {
|
public Parser(StringTokenStream tokens) {
|
||||||
_sts = tokens;
|
_sts = tokens;
|
||||||
}
|
}
|
||||||
|
@ -29,13 +29,18 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
|
||||||
Debug.Assert(start.value == end.value);
|
Debug.Assert(start.value == end.value);
|
||||||
Debug.Assert("\"".Contains(start.value));
|
Debug.Assert("\"".Contains(start.value));
|
||||||
string r = "";
|
string r = "";
|
||||||
|
bool exit_ok = false;
|
||||||
while (_sts.Available() > 0) {
|
while (_sts.Available() > 0) {
|
||||||
Token<string> t = _sts.Get();
|
Token<string> t = _sts.Get();
|
||||||
if (t.value == end.value) {
|
if (t.value == end.value) {
|
||||||
|
exit_ok = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
r += t.value;
|
r += t.value;
|
||||||
}
|
}
|
||||||
|
if (!exit_ok) {
|
||||||
|
throw new ApplicationException($"Failed to parse string, are you missing the closing quotes? String is: {r}");
|
||||||
|
}
|
||||||
_sts.Commit();
|
_sts.Commit();
|
||||||
return new String(r);
|
return new String(r);
|
||||||
}
|
}
|
||||||
|
@ -69,11 +74,16 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
|
||||||
if (";".Contains(start.value)) {
|
if (";".Contains(start.value)) {
|
||||||
return parse_comment(start, end);
|
return parse_comment(start, end);
|
||||||
}
|
}
|
||||||
Debug.Assert(end != null);
|
if (end == null) {
|
||||||
|
throw new ApplicationException($"Don't know how to parse grouping starting with token '{start.value}'");
|
||||||
|
}
|
||||||
|
|
||||||
IList<Expression> expressions = new List<Expression>();
|
IList<Expression> expressions = new List<Expression>();
|
||||||
|
bool exit_ok = false;
|
||||||
while (_sts.Available() > 0) {
|
while (_sts.Available() > 0) {
|
||||||
Token<string> t = _sts.Get();
|
Token<string> t = _sts.Get();
|
||||||
if (t.value == end.value) {
|
if (t.value == end.value) {
|
||||||
|
exit_ok = true;
|
||||||
_sts.Commit();
|
_sts.Commit();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -87,7 +97,11 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
|
||||||
_sts.Rewind(1);
|
_sts.Rewind(1);
|
||||||
expressions.Add(parse());
|
expressions.Add(parse());
|
||||||
}
|
}
|
||||||
return Cons.FromList(expressions);
|
var r = Cons.FromList(expressions);
|
||||||
|
if (!exit_ok) {
|
||||||
|
throw new ApplicationException($"Failed to parse grouping, are you missing some closing braces? Parsed expressions: {r}");
|
||||||
|
}
|
||||||
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
Expression parse_atom(AtomToken at) {
|
Expression parse_atom(AtomToken at) {
|
||||||
|
|
|
@ -47,6 +47,40 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
|
||||||
this["any"] = e.eval("(lambda (fc l) (apply or (map fc l)))");
|
this["any"] = e.eval("(lambda (fc l) (apply or (map fc l)))");
|
||||||
this["all"] = e.eval("(lambda (fc l) (apply and (map fc l)))");
|
this["all"] = e.eval("(lambda (fc l) (apply and (map fc l)))");
|
||||||
this["append"] = e.eval("(lambda (l i) (if (null l) i (cons (car l) (append (cdr l) i))))");
|
this["append"] = e.eval("(lambda (l i) (if (null l) i (cons (car l) (append (cdr l) i))))");
|
||||||
|
this["reverse"] = e.eval(
|
||||||
|
"""
|
||||||
|
(lambda
|
||||||
|
(lst)
|
||||||
|
(let
|
||||||
|
(rev-helper
|
||||||
|
(lambda
|
||||||
|
(l acc)
|
||||||
|
(if
|
||||||
|
(null l)
|
||||||
|
acc
|
||||||
|
(rev-helper (cdr l) (cons (car l) acc)))))
|
||||||
|
(rev-helper lst '())))
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
this["split"] = e.eval(
|
||||||
|
"""
|
||||||
|
(lambda
|
||||||
|
(lst n)
|
||||||
|
(if
|
||||||
|
(or (= n 0) (null lst))
|
||||||
|
(cons '() lst)
|
||||||
|
(let
|
||||||
|
(s (split (cdr lst) (- n 1)))
|
||||||
|
(cons (cons (car lst) (car s)) (cdr s)))))
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
this["length"] = e.eval(
|
||||||
|
"""
|
||||||
|
(lambda
|
||||||
|
(lst n)
|
||||||
|
(if (null lst) n (length (cdr lst) (+ n 1))))
|
||||||
|
"""
|
||||||
|
);
|
||||||
this["qsort"] = e.eval(
|
this["qsort"] = e.eval(
|
||||||
"""
|
"""
|
||||||
(lambda
|
(lambda
|
||||||
|
@ -56,25 +90,50 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
|
||||||
(lambda
|
(lambda
|
||||||
(list0)
|
(list0)
|
||||||
(car list0)))
|
(car list0)))
|
||||||
|
;(split
|
||||||
|
; (lambda
|
||||||
|
; (list0 pivot fc h0 h1)
|
||||||
|
; (cond
|
||||||
|
; ((null list0) (list list0 pivot fc h0 h1))
|
||||||
|
; ((fc (car list0) pivot) (split (cdr list0) pivot fc h0 (cons (car list0) h1)))
|
||||||
|
; (t (split (cdr list0) pivot fc (cons (car list0) h0) h1)))))
|
||||||
(split
|
(split
|
||||||
(lambda
|
(lambda
|
||||||
(list0 pivot fc h0 h1)
|
(list0 pivot fc h0 h1 heq)
|
||||||
(cond
|
(cond
|
||||||
((null list0) (list list0 pivot fc h0 h1))
|
((null list0) (list list0 pivot fc h0 h1 heq))
|
||||||
((fc (car list0) pivot) (split (cdr list0) pivot fc h0 (cons (car list0) h1)))
|
((and
|
||||||
(t (split (cdr list0) pivot fc (cons (car list0) h0) h1)))))
|
(fc (car list0) pivot)
|
||||||
|
(fc pivot (car list0))) (split (cdr list0) pivot fc h0 h1 (cons (car list0) heq)))
|
||||||
|
((fc (car list0) pivot) (split (cdr list0) pivot fc h0 (cons (car list0) h1) heq))
|
||||||
|
((fc pivot (car list0)) (split (cdr list0) pivot fc (cons (car list0) h0) h1 heq))
|
||||||
|
((= (car list0) pivot) (split (cdr list0) pivot fc h0 h1 (cons (car list0) heq)))
|
||||||
|
(t (split (cdr list0) pivot fc h0 h1 heq)))))
|
||||||
|
;(sort
|
||||||
|
; (lambda
|
||||||
|
; (fc list0)
|
||||||
|
; (cond
|
||||||
|
; ((null list0) nil)
|
||||||
|
; ((null (cdr list0)) list0)
|
||||||
|
; (t
|
||||||
|
; (let*
|
||||||
|
; (halves (split list0 (getpivot list0) fc nil nil))
|
||||||
|
; (h0 (car (cdr (cdr (cdr halves)))))
|
||||||
|
; (h1 (car (cdr (cdr (cdr (cdr halves))))))
|
||||||
|
; (append (sort fc h0) (sort fc h1)))))))
|
||||||
(sort
|
(sort
|
||||||
(lambda
|
(lambda
|
||||||
(fc list0)
|
(fc list0)
|
||||||
(cond
|
(begin t (cond
|
||||||
((null list0) nil)
|
((null list0) nil)
|
||||||
((null (cdr list0)) list0)
|
((null (cdr list0)) list0)
|
||||||
(t
|
(t
|
||||||
(let*
|
(let*
|
||||||
(halves (split list0 (getpivot list0) fc nil nil))
|
(halves (split list0 (getpivot list0) fc nil nil nil))
|
||||||
(h0 (car (cdr (cdr (cdr halves)))))
|
(h0 (car (cdr (cdr (cdr halves)))))
|
||||||
(h1 (car (cdr (cdr (cdr (cdr halves))))))
|
(h1 (car (cdr (cdr (cdr (cdr halves))))))
|
||||||
(append (sort fc h0) (sort fc h1)))))))
|
(heq (car (cdr (cdr (cdr (cdr (cdr halves)))))))
|
||||||
|
(append (append (sort fc h0) heq) (sort fc h1))))))))
|
||||||
(sort fc list00)))
|
(sort fc list00)))
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
@ -279,7 +338,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
|
||||||
IList<Expression> r = new List<Expression>();
|
IList<Expression> r = new List<Expression>();
|
||||||
MethodInfo? mi = o.Value().GetType().GetMethod(s.Value(), l_types);
|
MethodInfo? mi = o.Value().GetType().GetMethod(s.Value(), l_types);
|
||||||
if (mi == null) {
|
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_));
|
return Object.FromBase(mi.Invoke(o.Value(), l_));
|
||||||
|
@ -418,7 +477,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
|
||||||
Executor new_e = new Executor(new SubEnvironment(e.environment), e.builtins, e.builtinsLater);
|
Executor new_e = new Executor(new SubEnvironment(e.environment), e.builtins, e.builtinsLater);
|
||||||
foreach (var pair in args.SkipLast(1)) {
|
foreach (var pair in args.SkipLast(1)) {
|
||||||
if (pair is not Cons pair_cons) {
|
if (pair is not Cons pair_cons) {
|
||||||
throw new ApplicationException("No expression for let*");
|
throw new ApplicationException($"All arguments but the last have to be a list of two items, got {pair}");
|
||||||
}
|
}
|
||||||
Symbol refname = (Symbol) pair_cons.Item1;
|
Symbol refname = (Symbol) pair_cons.Item1;
|
||||||
Expression exp = ((Cons) pair_cons.Item2).Item1;
|
Expression exp = ((Cons) pair_cons.Item2).Item1;
|
||||||
|
@ -431,7 +490,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
|
||||||
List<(Symbol, Expression)> vars = new List<(Symbol, Expression)>();
|
List<(Symbol, Expression)> vars = new List<(Symbol, Expression)>();
|
||||||
foreach (var pair in args.SkipLast(1)) {
|
foreach (var pair in args.SkipLast(1)) {
|
||||||
if (pair is not Cons pair_cons) {
|
if (pair is not Cons pair_cons) {
|
||||||
throw new ApplicationException("");
|
throw new ApplicationException($"All arguments but the last have to be a list of two items, got {pair}");
|
||||||
}
|
}
|
||||||
Symbol refname = (Symbol) pair_cons.Item1;
|
Symbol refname = (Symbol) pair_cons.Item1;
|
||||||
Expression exp_ = ((Cons) pair_cons.Item2).Item1;
|
Expression exp_ = ((Cons) pair_cons.Item2).Item1;
|
||||||
|
@ -447,7 +506,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
|
||||||
if (args.First() is Cons proc_args_) { proc_args = proc_args_.ToList().Select(x => (Symbol) x); }
|
if (args.First() is Cons proc_args_) { proc_args = proc_args_.ToList().Select(x => (Symbol) x); }
|
||||||
else if (args.First() == Boolean.FALSE) { proc_args = new List<Symbol>(); }
|
else if (args.First() == Boolean.FALSE) { proc_args = new List<Symbol>(); }
|
||||||
else {
|
else {
|
||||||
throw new ApplicationException("");
|
throw new ApplicationException($"Expexted an argument list, but got {args.First()}");
|
||||||
}
|
}
|
||||||
return new Procedure(proc_args, args.Skip(1).First(), true);
|
return new Procedure(proc_args, args.Skip(1).First(), true);
|
||||||
}
|
}
|
||||||
|
@ -456,7 +515,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
|
||||||
if (args.First() is Cons proc_args_) { proc_args = proc_args_.ToList().Select(x => (Symbol) x); }
|
if (args.First() is Cons proc_args_) { proc_args = proc_args_.ToList().Select(x => (Symbol) x); }
|
||||||
else if (args.First() == Boolean.FALSE) { proc_args = new List<Symbol>(); }
|
else if (args.First() == Boolean.FALSE) { proc_args = new List<Symbol>(); }
|
||||||
else {
|
else {
|
||||||
throw new ApplicationException("");
|
throw new ApplicationException($"Expexted an argument list, but got {args.First()}");
|
||||||
}
|
}
|
||||||
return new Procedure(proc_args, args.Skip(1).First(), false);
|
return new Procedure(proc_args, args.Skip(1).First(), false);
|
||||||
}
|
}
|
||||||
|
@ -542,7 +601,11 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
|
||||||
throw new ApplicationException($"Not handled case '{expression}'");
|
throw new ApplicationException($"Not handled case '{expression}'");
|
||||||
}
|
}
|
||||||
public Expression eval(Parser p) {
|
public Expression eval(Parser p) {
|
||||||
return eval(p.parse());
|
Expression r = eval(p.parse());
|
||||||
|
if (p._sts.Available() > 0) {
|
||||||
|
throw new ApplicationException($"Did not consume all tokens, remaining program is {string.Join(" ", p._sts.Remainder())}");
|
||||||
|
}
|
||||||
|
return r;
|
||||||
}
|
}
|
||||||
public Expression eval(StringTokenStream sts) {
|
public Expression eval(StringTokenStream sts) {
|
||||||
return eval(new Parser(sts));
|
return eval(new Parser(sts));
|
||||||
|
|
56
Jellyfin.Plugin.SmartPlaylist/Pages/lispPlayground.html
Normal file
56
Jellyfin.Plugin.SmartPlaylist/Pages/lispPlayground.html
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>LispPlayground</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="LispPlaygroundConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox" data-controller="__plugin/lispPlayground.js">
|
||||||
|
<div data-role="content">
|
||||||
|
<div class="content-primary">
|
||||||
|
<form id="LispPlaygroundConfigForm">
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="LispPlaygroundInputLinks">Input Items</label>
|
||||||
|
<div class="fieldDescription">
|
||||||
|
Items which should be given to the filter program, one link per line.
|
||||||
|
This list is provided to the program in the
|
||||||
|
<code>*items*</code> variable.<br>
|
||||||
|
Hint: Just copy the link to the media item,
|
||||||
|
it should contain a <code>id</code> in
|
||||||
|
the query part of the URL's
|
||||||
|
fragment.<br>
|
||||||
|
Example:
|
||||||
|
<code>https://jellyfin.example.com/web/#/details?id=9f63ee779963588ed2ad94d37d6abe4e&serverId=dc0683d7a413473eb8246d21eaea2f99</code><br>
|
||||||
|
<textarea id="LispPlaygroundInputLinks" class="emby-input smartplayground-monospace" name="InputLinks" rows="6" cols="120"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="LispPlaygroundEditProgram">Program</label>
|
||||||
|
<div class="fieldDescription">A program which should return <code>t</code> or <code>nil</code> to include or exclude the provided <code>item</code>.</div>
|
||||||
|
<textarea id="LispPlaygroundEditProgram" class="emby-input smartplayground-monospace" name="Program" rows="16" cols="120"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="LispPlaygroundOutput">Output</label>
|
||||||
|
<div class="fieldDescription">The output of the program.</div>
|
||||||
|
<textarea id="LispPlaygroundOutput" class="emby-input smartplayground-monospace" name="Output" rows="16" cols="120"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="LispPlaygroundReturn">Final expression</label>
|
||||||
|
<div class="fieldDescription">The final expression the program has been reduced to.</div>
|
||||||
|
<textarea id="LispPlaygroundReturn" class="emby-input smartplayground-monospace" name="Output" rows="1" cols="120"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||||
|
<span>Run</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.smartplayground-monospace {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
50
Jellyfin.Plugin.SmartPlaylist/Pages/lispPlayground.js
Normal file
50
Jellyfin.Plugin.SmartPlaylist/Pages/lispPlayground.js
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
|
||||||
|
function fillForm(o) {
|
||||||
|
const output = document.querySelector('#LispPlaygroundOutput');
|
||||||
|
const return_ = document.querySelector('#LispPlaygroundReturn');
|
||||||
|
output.value = (o.hasOwnProperty("Output")) ? o.Output : o.Traceback;
|
||||||
|
return_.value = (o.hasOwnProperty("FinalExpression")) ? o.FinalExpression : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiClient.runLispProgram = function (inputLinks, program) {
|
||||||
|
const url = ApiClient.getUrl('LispPlayground');
|
||||||
|
return this.ajax({
|
||||||
|
type: 'POST',
|
||||||
|
url: url,
|
||||||
|
dataType: 'json',
|
||||||
|
contentType: 'application/json; charset=UTF-8',
|
||||||
|
data: JSON.stringify({
|
||||||
|
InputLinks: inputLinks,
|
||||||
|
Program: program,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function initial_load() {
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (view, params) {
|
||||||
|
view.addEventListener('viewshow', function() {
|
||||||
|
initial_load(null);
|
||||||
|
});
|
||||||
|
view.addEventListener('viewhide', function (_e) {});
|
||||||
|
view.addEventListener('viewdestroy', function (_e) {});
|
||||||
|
|
||||||
|
document.querySelector('#LispPlaygroundConfigPage')
|
||||||
|
.addEventListener('pageshow', function() {
|
||||||
|
initial_load();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('#LispPlaygroundConfigForm')
|
||||||
|
.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
const editInputLinks = document.querySelector('#LispPlaygroundInputLinks');
|
||||||
|
const editProgram = document.querySelector('#LispPlaygroundEditProgram');
|
||||||
|
ApiClient.runLispProgram(editInputLinks.value, editProgram.value).then(function (r) {
|
||||||
|
fillForm(r);
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
54
Jellyfin.Plugin.SmartPlaylist/Pages/smartCollections.html
Normal file
54
Jellyfin.Plugin.SmartPlaylist/Pages/smartCollections.html
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>SmartCollection</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="SmartCollectionConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox" data-controller="__plugin/smartCollections.js">
|
||||||
|
<div data-role="content">
|
||||||
|
<div class="content-primary">
|
||||||
|
<form id="SmartCollectionConfigForm">
|
||||||
|
<div>
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="SmartcollectionSelection">Choose a collection to edit</label>
|
||||||
|
<select id="SmartcollectionSelection" class="emby-select">
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="SmartcollectionEditName">Name</label>
|
||||||
|
<input id="SmartcollectionEditName" type="text" class="emby-input"/>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="SmartcollectionEditProgram">Program</label>
|
||||||
|
<div class="fieldDescription">A program which should return <code>t</code> or <code>nil</code> to include or exclude the provided <code>item</code>.</div>
|
||||||
|
<textarea id="SmartcollectionEditProgram" class="emby-input smartcollection-monospace" name="Program" rows="16" cols="120"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="SmartcollectionEditSortProgram">Sort Program</label>
|
||||||
|
<div class="fieldDescription">A program which should return a list of items to include in the collection, sorted however you like.</div>
|
||||||
|
<textarea id="SmartcollectionEditSortProgram" class="emby-input smartcollection-monospace" name="SortProgram" rows="16" cols="120"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="SmartcollectionEditEnabled">Enabled</label>
|
||||||
|
<div class="fieldDescription">Is the collection enabled.</div>
|
||||||
|
<input id="SmartcollectionEditEnabled" type="checkbox" class="emby-input"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||||
|
<span>Save</span>
|
||||||
|
</button>
|
||||||
|
<button is="emby-button" type="reset" class="raised button-reset block emby-button">
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.smartcollection-monospace {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
134
Jellyfin.Plugin.SmartPlaylist/Pages/smartCollections.js
Normal file
134
Jellyfin.Plugin.SmartPlaylist/Pages/smartCollections.js
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiClient.deleteSmartCollection = function (id) {
|
||||||
|
const url = ApiClient.getUrl(`SmartCollection/${id}`)
|
||||||
|
return this.ajax({
|
||||||
|
type: 'DELETE',
|
||||||
|
url: url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (view, params) {
|
||||||
|
view.addEventListener('viewshow', function() {
|
||||||
|
initial_load(null);
|
||||||
|
});
|
||||||
|
view.addEventListener('viewhide', function (_e) {});
|
||||||
|
view.addEventListener('viewdestroy', function (_e) {});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
document.querySelector('#SmartCollectionConfigForm')
|
||||||
|
.addEventListener('reset', function (e) {
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
const selection = document.querySelector('#SmartcollectionSelection');
|
||||||
|
const selectedId = COLLECTIONS[selection.selectedIndex].Id;
|
||||||
|
ApiClient.deleteSmartCollection(selectedId).then(function () {
|
||||||
|
initial_load(null);
|
||||||
|
});
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
}
|
60
Jellyfin.Plugin.SmartPlaylist/Pages/smartPlaylists.html
Normal file
60
Jellyfin.Plugin.SmartPlaylist/Pages/smartPlaylists.html
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>SmartPlaylist</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="SmartPlaylistConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox" data-controller="__plugin/smartPlaylists.js">
|
||||||
|
<div data-role="content">
|
||||||
|
<div class="content-primary">
|
||||||
|
<form id="SmartPlaylistConfigForm">
|
||||||
|
<div>
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="SmartplaylistSelection">Choose a playlist to edit</label>
|
||||||
|
<select id="SmartplaylistSelection" class="emby-select">
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="SmartplaylistEditName">Name</label>
|
||||||
|
<input id="SmartplaylistEditName" type="text" class="emby-input"/>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="SmartplaylistEditProgram">Program</label>
|
||||||
|
<div class="fieldDescription">A program which should return <code>t</code> or <code>nil</code> to include or exclude the provided <code>item</code>.</div>
|
||||||
|
<textarea id="SmartplaylistEditProgram" class="emby-input smartplaylist-monospace" name="Program" rows="16" cols="120"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="SmartplaylistEditSortProgram">Sort Program</label>
|
||||||
|
<div class="fieldDescription">A program which should return a list of items to include in the playlist, sorted however you like.</div>
|
||||||
|
<textarea id="SmartplaylistEditSortProgram" class="emby-input smartplaylist-monospace" name="SortProgram" rows="16" cols="120"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="SmartplaylistEditUsers">Users</label>
|
||||||
|
<div class="fieldDescription">Which users should get access to the playlist.</div>
|
||||||
|
<select multiple id="SmartplaylistEditUsers" class="emby-select">
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="SmartplaylistEditEnabled">Enabled</label>
|
||||||
|
<div class="fieldDescription">Is the playlist enabled.</div>
|
||||||
|
<input id="SmartplaylistEditEnabled" type="checkbox" class="emby-input"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||||
|
<span>Save</span>
|
||||||
|
</button>
|
||||||
|
<button is="emby-button" type="reset" class="raised button-reset block emby-button">
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.smartplaylist-monospace {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
160
Jellyfin.Plugin.SmartPlaylist/Pages/smartPlaylists.js
Normal file
160
Jellyfin.Plugin.SmartPlaylist/Pages/smartPlaylists.js
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiClient.deleteSmartPlaylist = function (id) {
|
||||||
|
const url = ApiClient.getUrl(`SmartPlaylist/${id}`)
|
||||||
|
return this.ajax({
|
||||||
|
type: 'DELETE',
|
||||||
|
url: url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (view, params) {
|
||||||
|
view.addEventListener('viewshow', function() {
|
||||||
|
initial_load(null);
|
||||||
|
});
|
||||||
|
view.addEventListener('viewhide', function (_e) {});
|
||||||
|
view.addEventListener('viewdestroy', function (_e) {});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
document.querySelector('#SmartPlaylistConfigForm')
|
||||||
|
.addEventListener('reset', function (e) {
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
const selection = document.querySelector('#SmartplaylistSelection');
|
||||||
|
const selectedId = PLAYLISTS[selection.selectedIndex].Id;
|
||||||
|
ApiClient.deleteSmartPlaylist(selectedId).then(function () {
|
||||||
|
initial_load(null);
|
||||||
|
});
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
}
|
|
@ -27,8 +27,42 @@ namespace Jellyfin.Plugin.SmartPlaylist {
|
||||||
return new[] {
|
return new[] {
|
||||||
new PluginPageInfo {
|
new PluginPageInfo {
|
||||||
Name = this.Name,
|
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),
|
||||||
|
},
|
||||||
|
new PluginPageInfo {
|
||||||
|
Name = "Lisp Playground",
|
||||||
|
DisplayName = "Lisp Playground",
|
||||||
|
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.lispPlayground.html", GetType().Namespace),
|
||||||
|
EnableInMainMenu = true,
|
||||||
|
},
|
||||||
|
new PluginPageInfo {
|
||||||
|
Name = "lispPlayground.js",
|
||||||
|
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.lispPlayground.js", GetType().Namespace),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
81
Jellyfin.Plugin.SmartPlaylist/ScheduledTasks/Common.cs
Normal file
81
Jellyfin.Plugin.SmartPlaylist/ScheduledTasks/Common.cs
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
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.AudioBook,
|
||||||
|
BaseItemKind.Book,
|
||||||
|
BaseItemKind.BoxSet,
|
||||||
|
BaseItemKind.Channel,
|
||||||
|
// BaseItemKind.ChannelFolderItem,
|
||||||
|
// BaseItemKind.CollectionFolder,
|
||||||
|
BaseItemKind.Episode,
|
||||||
|
// BaseItemKind.Folder,
|
||||||
|
BaseItemKind.Genre,
|
||||||
|
// BaseItemKind.ManualPlaylistsFolder,
|
||||||
|
BaseItemKind.Movie,
|
||||||
|
BaseItemKind.LiveTvChannel,
|
||||||
|
BaseItemKind.LiveTvProgram,
|
||||||
|
BaseItemKind.MusicAlbum,
|
||||||
|
BaseItemKind.MusicArtist,
|
||||||
|
BaseItemKind.MusicGenre,
|
||||||
|
BaseItemKind.MusicVideo,
|
||||||
|
BaseItemKind.Person,
|
||||||
|
BaseItemKind.Photo,
|
||||||
|
BaseItemKind.PhotoAlbum,
|
||||||
|
BaseItemKind.Playlist,
|
||||||
|
// BaseItemKind.PlaylistsFolder,
|
||||||
|
BaseItemKind.Program,
|
||||||
|
BaseItemKind.Recording,
|
||||||
|
BaseItemKind.Season,
|
||||||
|
BaseItemKind.Series,
|
||||||
|
BaseItemKind.Studio,
|
||||||
|
BaseItemKind.Trailer,
|
||||||
|
BaseItemKind.TvChannel,
|
||||||
|
BaseItemKind.TvProgram,
|
||||||
|
BaseItemKind.UserView,
|
||||||
|
BaseItemKind.Video,
|
||||||
|
BaseItemKind.Year
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,152 @@
|
||||||
|
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;
|
||||||
|
using Jellyfin.Data.Entities;
|
||||||
|
|
||||||
|
using Jellyfin.Plugin.SmartPlaylist.Lisp;
|
||||||
|
using Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
_logger.LogError("Program crashed on item {0}:\n{1}", i.Id, expression.ToString());
|
||||||
|
_logger.LogError("Environment:\n{0}", executor.environment);
|
||||||
|
_logger.LogError("Traceback:\n{0}", e.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.LinkedChildren.Select(x => x.ItemId).Where(x => x != null).Select(x => x.Value);
|
||||||
|
await _collectionManager.RemoveFromCollectionAsync(collectionId, existingItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,27 +7,17 @@ using MediaBrowser.Controller.Playlists;
|
||||||
using MediaBrowser.Controller.Providers;
|
using MediaBrowser.Controller.Providers;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
using Jellyfin.Data.Entities;
|
using Jellyfin.Data.Entities;
|
||||||
using Jellyfin.Data.Enums;
|
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Model.Playlists;
|
using MediaBrowser.Model.Playlists;
|
||||||
|
|
||||||
using Jellyfin.Plugin.SmartPlaylist.Lisp;
|
using Jellyfin.Plugin.SmartPlaylist.Lisp;
|
||||||
using Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler;
|
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 {
|
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 ILibraryManager _libraryManager;
|
||||||
private readonly IUserManager _userManager;
|
private readonly IUserManager _userManager;
|
||||||
private readonly IProviderManager _providerManager;
|
private readonly IProviderManager _providerManager;
|
||||||
|
@ -44,15 +34,14 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
|
||||||
IFileSystem fileSystem,
|
IFileSystem fileSystem,
|
||||||
IPlaylistManager playlistManager,
|
IPlaylistManager playlistManager,
|
||||||
IServerApplicationPaths serverApplicationPaths
|
IServerApplicationPaths serverApplicationPaths
|
||||||
) {
|
) : base(logger) {
|
||||||
_logger = logger;
|
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_providerManager = providerManager;
|
_providerManager = providerManager;
|
||||||
_fileSystem = fileSystem;
|
_fileSystem = fileSystem;
|
||||||
_playlistManager = playlistManager;
|
_playlistManager = playlistManager;
|
||||||
|
|
||||||
_store = new Store(new SmartPlaylistFileSystem(serverApplicationPaths));
|
_store = new Store(new SmartFileSystem(serverApplicationPaths));
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Category => "Library";
|
public string Category => "Library";
|
||||||
|
@ -81,62 +70,41 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
|
||||||
return playlistGuid;
|
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) {
|
private IEnumerable<Guid> FilterPlaylistItems(IEnumerable<BaseItem> items, User user, SmartPlaylistDto smartPlaylist) {
|
||||||
List<BaseItem> results = new List<BaseItem>();
|
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
|
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 executor = SetupExecutor();
|
||||||
|
|
||||||
executor.environment.Set("*user*", Lisp_Object.FromBase(user));
|
executor.environment.Set("*user*", Lisp.Object.FromBase(user));
|
||||||
foreach (var i in items) {
|
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);
|
try {
|
||||||
_logger.LogTrace("Item {0} evaluated to {1}", i, r.ToString());
|
var r = executor.eval(expression);
|
||||||
if ((r is not Lisp_Boolean r_bool) || (r_bool.Value())) {
|
_logger.LogTrace("Item {0} evaluated to {1}", i, r.ToString());
|
||||||
_logger.LogDebug("Added '{0}' to Smart Playlist {1}", i, smartPlaylist.Name);
|
if ((r is not Lisp.Boolean r_bool) || (r_bool.Value())) {
|
||||||
results.Add(i);
|
_logger.LogDebug("Added '{0}' to Smart Playlist {1}", i, smartPlaylist.Name);
|
||||||
|
results.Add(i);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
_logger.LogError("Program crashed on item {0}:\n{1}", i.Id, expression.ToString());
|
||||||
|
_logger.LogError("Environment:\n{0}", executor.environment);
|
||||||
|
_logger.LogError("Traceback:\n{0}", e.ToString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
executor = SetupExecutor();
|
executor = SetupExecutor();
|
||||||
executor.environment.Set("*user*", Lisp_Object.FromBase(user));
|
executor.environment.Set("*user*", Lisp.Object.FromBase(user));
|
||||||
executor.environment.Set("*items*", Lisp_Object.FromBase(results));
|
executor.environment.Set("*items*", Lisp.Object.FromBase(results));
|
||||||
results = new List<BaseItem>();
|
results = new List<BaseItem>();
|
||||||
var sort_result = executor.eval(smartPlaylist.SortProgram);
|
var sort_result = executor.eval(smartPlaylist.SortProgram);
|
||||||
if (sort_result is Cons sorted_items) {
|
if (sort_result is Cons sorted_items) {
|
||||||
foreach (var i in sorted_items.ToList()) {
|
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);
|
results.Add(iBaseItem);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw new ApplicationException($"Returned sorted list does contain unexpected items, got {i}");
|
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 {
|
} else {
|
||||||
throw new ApplicationException($"Did not return a list of items, returned {sort_result}");
|
throw new ApplicationException($"Did not return a list of items, returned {sort_result}");
|
||||||
}
|
}
|
||||||
|
@ -162,6 +130,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
|
||||||
foreach (SmartPlaylistDto dto in all_playlists) {
|
foreach (SmartPlaylistDto dto in all_playlists) {
|
||||||
if (!dto.Enabled) {
|
if (!dto.Enabled) {
|
||||||
i += 1;
|
i += 1;
|
||||||
|
progress.Report(100 * ((double)i)/all_playlists.Count());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
var changedDto = false;
|
var changedDto = false;
|
||||||
|
|
|
@ -108,4 +108,15 @@ namespace Jellyfin.Plugin.SmartPlaylist {
|
||||||
info.AddValue("Enabled", Enabled);
|
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; }
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -3,34 +3,61 @@ using MediaBrowser.Controller;
|
||||||
namespace Jellyfin.Plugin.SmartPlaylist {
|
namespace Jellyfin.Plugin.SmartPlaylist {
|
||||||
|
|
||||||
public interface ISmartPlaylistFileSystem {
|
public interface ISmartPlaylistFileSystem {
|
||||||
public string StoragePath { get; }
|
public string PlaylistStoragePath { get; }
|
||||||
public string GetSmartPlaylistFilePath(SmartPlaylistId smartPlaylistId);
|
public string CollectionStoragePath { get; }
|
||||||
public string FindSmartPlaylistFilePath(SmartPlaylistId smartPlaylistId);
|
|
||||||
|
public string GetSmartPlaylistFilePath(string id);
|
||||||
|
public string FindSmartPlaylistFilePath(string id);
|
||||||
public string[] FindAllSmartPlaylistFilePaths();
|
public string[] FindAllSmartPlaylistFilePaths();
|
||||||
|
|
||||||
|
public string GetSmartCollectionFilePath(string id);
|
||||||
|
public string FindSmartCollectionFilePath(string id);
|
||||||
|
public string[] FindAllSmartCollectionFilePaths();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SmartPlaylistFileSystem : ISmartPlaylistFileSystem {
|
public class SmartFileSystem : ISmartPlaylistFileSystem {
|
||||||
public SmartPlaylistFileSystem(IServerApplicationPaths serverApplicationPaths) {
|
public SmartFileSystem(IServerApplicationPaths serverApplicationPaths) {
|
||||||
StoragePath = Path.Combine(serverApplicationPaths.DataPath, "smartplaylists");
|
PlaylistStoragePath = Path.Combine(serverApplicationPaths.DataPath, "smartplaylists");
|
||||||
if (!Directory.Exists(StoragePath)) { Directory.CreateDirectory(StoragePath); }
|
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 PlaylistStoragePath { get; }
|
||||||
public string GetSmartPlaylistFilePath(SmartPlaylistId smartPlaylistId) {
|
public string CollectionStoragePath { get; }
|
||||||
return Path.Combine(StoragePath, $"{smartPlaylistId}.yaml");
|
public string GetSmartPlaylistFilePath(string id) {
|
||||||
|
return Path.Combine(PlaylistStoragePath, $"{id}.yaml");
|
||||||
}
|
}
|
||||||
public string FindSmartPlaylistFilePath(SmartPlaylistId smartPlaylistId) {
|
public string FindSmartPlaylistFilePath(string id) {
|
||||||
return Directory.GetFiles(StoragePath, $"{smartPlaylistId}.yaml", SearchOption.AllDirectories).Concat(
|
return Directory.GetFiles(PlaylistStoragePath, $"{id}.yaml", SearchOption.AllDirectories).Concat(
|
||||||
Directory.GetFiles(StoragePath, $"{smartPlaylistId}.yml", SearchOption.AllDirectories)
|
Directory.GetFiles(PlaylistStoragePath, $"{id}.yml", SearchOption.AllDirectories)
|
||||||
).Concat(
|
).Concat(
|
||||||
Directory.GetFiles(StoragePath, $"{smartPlaylistId}.json", SearchOption.AllDirectories)
|
Directory.GetFiles(PlaylistStoragePath, $"{id}.json", SearchOption.AllDirectories)
|
||||||
).First();
|
).First();
|
||||||
}
|
}
|
||||||
public string[] FindAllSmartPlaylistFilePaths() {
|
public string[] FindAllSmartPlaylistFilePaths() {
|
||||||
return Directory.GetFiles(StoragePath, "*.yaml", SearchOption.AllDirectories).Concat(
|
return Directory.GetFiles(PlaylistStoragePath, "*.yaml", SearchOption.AllDirectories).Concat(
|
||||||
Directory.GetFiles(StoragePath, "*.yml", SearchOption.AllDirectories)
|
Directory.GetFiles(PlaylistStoragePath, "*.yml", SearchOption.AllDirectories)
|
||||||
).Concat(
|
).Concat(
|
||||||
Directory.GetFiles(StoragePath, "*.json", SearchOption.AllDirectories)
|
Directory.GetFiles(PlaylistStoragePath, "*.json", SearchOption.AllDirectories)
|
||||||
).ToArray();
|
).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();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,12 @@ namespace Jellyfin.Plugin.SmartPlaylist {
|
||||||
Task SaveSmartPlaylistAsync(SmartPlaylistDto smartPlaylist);
|
Task SaveSmartPlaylistAsync(SmartPlaylistDto smartPlaylist);
|
||||||
void DeleteSmartPlaylistById(SmartPlaylistId smartPlaylistId);
|
void DeleteSmartPlaylistById(SmartPlaylistId smartPlaylistId);
|
||||||
void DeleteSmartPlaylist(SmartPlaylistDto smartPlaylist);
|
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 {
|
public class Store : IStore {
|
||||||
|
@ -59,5 +65,51 @@ namespace Jellyfin.Plugin.SmartPlaylist {
|
||||||
if (File.Exists(smartPlaylist.Filename)) { File.Delete(smartPlaylist.Filename); }
|
if (File.Exists(smartPlaylist.Filename)) { File.Delete(smartPlaylist.Filename); }
|
||||||
DeleteSmartPlaylistById(smartPlaylist.Id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,4 +2,6 @@ global using System;
|
||||||
|
|
||||||
global using UserId = System.Guid;
|
global using UserId = System.Guid;
|
||||||
global using PlaylistId = System.Guid;
|
global using PlaylistId = System.Guid;
|
||||||
|
global using CollectionId = System.Guid;
|
||||||
global using SmartPlaylistId = string;
|
global using SmartPlaylistId = string;
|
||||||
|
global using SmartCollectionId = string;
|
||||||
|
|
|
@ -56,6 +56,9 @@ namespace Jellyfin.Plugin.SmartPlaylist.Util {
|
||||||
public IStream<T> Copy() {
|
public IStream<T> Copy() {
|
||||||
return new Stream<T>(_items, _cursor, _ephemeralCursor);
|
return new Stream<T>(_items, _cursor, _ephemeralCursor);
|
||||||
}
|
}
|
||||||
|
public IList<T> Remainder() {
|
||||||
|
return _items.Skip(_cursor).ToList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
name: Smart Playlist
|
name: Smart Playlist
|
||||||
guid: dd2326e3-4d3e-4bfc-80e6-28502c1131df
|
guid: dd2326e3-4d3e-4bfc-80e6-28502c1131df
|
||||||
version: 0.4.1.0
|
version: 0.5.3.0
|
||||||
targetAbi: 10.10.3.0
|
targetAbi: 10.10.6.0
|
||||||
framework: net8.0
|
framework: net8.0
|
||||||
owner: redxef
|
owner: redxef
|
||||||
overview: Smart playlists with Lisp filter engine.
|
overview: Smart playlists with Lisp filter engine.
|
||||||
|
@ -14,71 +14,9 @@ artifacts:
|
||||||
- jellyfin-smart-playlist.dll
|
- jellyfin-smart-playlist.dll
|
||||||
- YamlDotNet.dll
|
- YamlDotNet.dll
|
||||||
changelog: |
|
changelog: |
|
||||||
## v0.4.1.0
|
## v0.5.3.0
|
||||||
- improve defaults for new playlists
|
- bump Jellyfin ABI version to 10.10.6
|
||||||
|
- bump yamldotnet to 16.3.0
|
||||||
|
|
||||||
**Fixes**:
|
**Fixes**:
|
||||||
- finally get th percentage indicator of the scheduled task right
|
- qsort doesn't loop endlessly with duplicate values in import anymore
|
||||||
|
|
||||||
## v0.4.0.0
|
|
||||||
- Add a basic UI to configure the playlists.
|
|
||||||
- It's now possible to print log messages to the jellyfin log by calling `logd`, `logi`, `logw` or `loge`
|
|
||||||
for the respective levels `debug`, `info`, `warning` or `error`.
|
|
||||||
- Allow calling generic methods via `(invoke-generic object methodname args list-of-types)`.
|
|
||||||
- Add quoting via single quote: `'`.
|
|
||||||
- Add special case for `(quote <form>)` to be rendered as `'<form>`.
|
|
||||||
- It is now possible to include comments in the source via a semicolon (`;`).
|
|
||||||
- Respect the `Enabled` flag and only process the playlists that are enabled.
|
|
||||||
- New methods have been added: `rand`, `shuf`.
|
|
||||||
- Add `find-artist`, `get-name` and `find-parent` default definitions.
|
|
||||||
- Update YamlDotNet to v16.2.1.
|
|
||||||
|
|
||||||
**Breaking changes**:
|
|
||||||
- Rename global environment variables to be enclosed by `*`.
|
|
||||||
|
|
||||||
**Fixes**:
|
|
||||||
- The initialization of the executor now contains the same default definitions for the SortProgram and the normal Program.
|
|
||||||
- The progress report now considers the SmartPlaylists and not the individual playlists per user.
|
|
||||||
- It is now possible to pass builtins as arguments. Previously to get `(qsort > (list 1 2 3))` one had to write
|
|
||||||
something like this: `(qsort (lambda (a b) (> a b)) (list 1 2 3))`.
|
|
||||||
- A program no longer has to be a list, `t` is a valid program.
|
|
||||||
- Fix list parsing in cases where a space was before the closing parenthesis.
|
|
||||||
|
|
||||||
## v0.3.0.0
|
|
||||||
- Add a second program (`SortProgram`) which is run after the filtering, this
|
|
||||||
program should return the list of items, but in the order in which they should appear in
|
|
||||||
the playlist. The default is `(begin items)` which returns the list as is.
|
|
||||||
- Extend builtin lisp definitions: add `qsort` and string comparison methods
|
|
||||||
- Extend default program definitions: add `all-genres` and `any-genres` to quickly specify a list of genres which to include (or excluding when negating)
|
|
||||||
- Update Jellyfin to v 10.10.3
|
|
||||||
|
|
||||||
**Fixes**:
|
|
||||||
- The progress report now correctly gives a percentage in the range [0, 100].
|
|
||||||
|
|
||||||
## v0.2.2.0
|
|
||||||
- Update Jellyfin to v 10.10.2
|
|
||||||
|
|
||||||
## v0.2.1.0
|
|
||||||
- Make default program configuration a textarea in the settings page
|
|
||||||
- Add convinience definitions: `is-type`, `name-contains`
|
|
||||||
- Update YamlDotNet to v 16.2.0
|
|
||||||
|
|
||||||
**Fixes**:
|
|
||||||
- The default program was malformed, a closing bracket was at the wrong position
|
|
||||||
- The `haskeys` function could only be called on Objects
|
|
||||||
|
|
||||||
## v0.2.0.0
|
|
||||||
- Switch to yaml loading, old json files are still accepted
|
|
||||||
- Rework lisp interpreter to be more conventional
|
|
||||||
- Use arbitrary strings as ids for playlists
|
|
||||||
- Add configuration page with some default definitions for
|
|
||||||
the filter expressions.
|
|
||||||
|
|
||||||
**Breaking Changes**:
|
|
||||||
- The lisp interpreter will now only detect strings in double quotes (`"`).
|
|
||||||
- The interpreter will also not allow specifying lists without quoting them.
|
|
||||||
`(1 2 3)` ... used to work but will no longer, replace by either specifying
|
|
||||||
the list as `(list 1 2 3)` or `(quote (1 2 3))`.
|
|
||||||
|
|
||||||
## v0.1.1.0
|
|
||||||
- Initial Alpha release.
|
|
||||||
|
|
|
@ -1,201 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>SmartPlaylist</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="SmartPlaylistConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
|
||||||
<div data-role="content">
|
|
||||||
<div class="content-primary">
|
|
||||||
<form id="SmartPlaylistConfigForm">
|
|
||||||
<div class="inputContainer">
|
|
||||||
<label class="inputLabel inputLabelUnfocused" for="InitialProgram">Initial Program</label>
|
|
||||||
<div class="fieldDescription">A program which can set up the environment</div>
|
|
||||||
<textarea id="InitialProgram" class="emby-input smartplaylist-monospace" name="InitialProgram" rows="16" cols="120"></textarea>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="inputLabel inputLabelUnfocused" for="SmartplaylistSelection">Choose a playlist to edit</label>
|
|
||||||
<select id="SmartplaylistSelection" class="emby-select">
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="inputLabel inputLabelUnfocused" for="SmartplaylistEditName">Name</label>
|
|
||||||
<input id="SmartplaylistEditName" type="text" class="emby-input"/>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<label class="inputLabel inputLabelUnfocused" for="SmartplaylistEditProgram">Program</label>
|
|
||||||
<div class="fieldDescription">A program which should return <code>t</code> or <code>nil</code> to include or exclude the provided <code>item</code>.</div>
|
|
||||||
<textarea id="SmartplaylistEditProgram" class="emby-input smartplaylist-monospace" name="Program" rows="16" cols="120"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<label class="inputLabel inputLabelUnfocused" for="SmartplaylistEditSortProgram">Sort Program</label>
|
|
||||||
<div class="fieldDescription">A program which should return a list of items to include in the playlist, sorted however you like.</div>
|
|
||||||
<textarea id="SmartplaylistEditSortProgram" class="emby-input smartplaylist-monospace" name="SortProgram" rows="16" cols="120"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<label class="inputLabel inputLabelUnfocused" for="SmartplaylistEditUsers">Users</label>
|
|
||||||
<div class="fieldDescription">Which users should get access to the playlist.</div>
|
|
||||||
<select multiple id="SmartplaylistEditUsers" class="emby-select">
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<label class="inputLabel inputLabelUnfocused" for="SmartPlaylistEditEnabled">Enabled</label>
|
|
||||||
<div class="fieldDescription">Is the playlist enabled.</div>
|
|
||||||
<input id="SmartplaylistEditEnabled" type="checkbox" class="emby-input"/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
|
||||||
<span>Save</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<style>
|
|
||||||
.smartplaylist-monospace {
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script type="text/javascript">
|
|
||||||
var SmartPlaylistConfig = {
|
|
||||||
pluginUniqueId: 'dd2326e3-4d3e-4bfc-80e6-28502c1131df'
|
|
||||||
};
|
|
||||||
|
|
||||||
function changeEditBox(config, id) {
|
|
||||||
const selection = document.querySelector('#SmartplaylistSelection');
|
|
||||||
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');
|
|
||||||
if (id === null) {
|
|
||||||
selection.selectedIndex = 0;
|
|
||||||
editName.value = 'My New Smartplaylist';
|
|
||||||
editProgram.value = '(is-favourite)';
|
|
||||||
editSortProgram.value = '(begin *items*)';
|
|
||||||
editUsers.innerHTML = '';
|
|
||||||
for (const u of config.Users) {
|
|
||||||
var o = document.createElement('option');
|
|
||||||
o.value = u[0];
|
|
||||||
o.innerHTML = u[1];
|
|
||||||
o.setAttribute('selected', 'selected');
|
|
||||||
editUsers.appendChild(o);
|
|
||||||
}
|
|
||||||
editEnabled.checked = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
function matchId(p) {
|
|
||||||
return p.Id == id;
|
|
||||||
}
|
|
||||||
const index = config.Playlists.map(function (x) { return x.Id }).indexOf(id);
|
|
||||||
selection.selectedIndex = index + 1;
|
|
||||||
const p = config.Playlists[index];
|
|
||||||
editName.value = p.Name;
|
|
||||||
editProgram.value = p.Program;
|
|
||||||
editSortProgram.value = p.SortProgram;
|
|
||||||
editUsers.innerHTML = '';
|
|
||||||
for (const u of config.Users) {
|
|
||||||
var o = document.createElement('option');
|
|
||||||
o.value = u[0];
|
|
||||||
o.innerHTML = u[1];
|
|
||||||
if (p.Playlists.map((x) => x.UserId).includes(u[0])) {
|
|
||||||
o.setAttribute('selected', 'selected');
|
|
||||||
}
|
|
||||||
editUsers.appendChild(o);
|
|
||||||
}
|
|
||||||
editEnabled.checked = p.Enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillPlaylistSelect(config) {
|
|
||||||
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 config.Playlists) {
|
|
||||||
var o = document.createElement('option');
|
|
||||||
o.value = i.Id;
|
|
||||||
o.innerHTML = i.Name;
|
|
||||||
selection.appendChild(o);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelector('#SmartPlaylistConfigPage')
|
|
||||||
.addEventListener('pageshow', function() {
|
|
||||||
Dashboard.showLoadingMsg();
|
|
||||||
ApiClient.getPluginConfiguration(SmartPlaylistConfig.pluginUniqueId).then(function (config) {
|
|
||||||
document.querySelector('#InitialProgram').value = config.InitialProgram;
|
|
||||||
fillPlaylistSelect(config);
|
|
||||||
changeEditBox(config, (config.Playlists.length > 0) ? config.Playlists[0].Id : null);
|
|
||||||
Dashboard.hideLoadingMsg();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelector('#SmartplaylistSelection')
|
|
||||||
.addEventListener('change', function() {
|
|
||||||
Dashboard.showLoadingMsg();
|
|
||||||
ApiClient.getPluginConfiguration(SmartPlaylistConfig.pluginUniqueId).then(function (config) {
|
|
||||||
const selection = document.querySelector('#SmartplaylistSelection');
|
|
||||||
changeEditBox(config, (selection.selectedIndex > 0) ? config.Playlists[selection.selectedIndex - 1].Id : null);
|
|
||||||
Dashboard.hideLoadingMsg();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelector('#SmartPlaylistConfigForm')
|
|
||||||
.addEventListener('submit', function(e) {
|
|
||||||
Dashboard.showLoadingMsg();
|
|
||||||
ApiClient.getPluginConfiguration(SmartPlaylistConfig.pluginUniqueId).then(function (config) {
|
|
||||||
config.InitialProgram = document.querySelector('#InitialProgram').value;
|
|
||||||
const selection = document.querySelector('#SmartplaylistSelection');
|
|
||||||
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');
|
|
||||||
var index = selection.selectedIndex;
|
|
||||||
if (index === 0) {
|
|
||||||
const o = {
|
|
||||||
Id: editName.value,
|
|
||||||
Name: editName.value,
|
|
||||||
Program: editProgram.value,
|
|
||||||
SortProgram: editSortProgram.value,
|
|
||||||
Playlists: Array.from(editUsers.options).filter((x) => x.selected).map((x) => {
|
|
||||||
const m = {UserId: x.value, PlaylistId: "00000000-0000-0000-0000-000000000000"};
|
|
||||||
return m;
|
|
||||||
}),
|
|
||||||
Enabled: editEnabled.checked,
|
|
||||||
};
|
|
||||||
config.Playlists.push(o);
|
|
||||||
} else {
|
|
||||||
config.Playlists[index-1].Id = editName.value;
|
|
||||||
config.Playlists[index-1].Name = editName.value;
|
|
||||||
config.Playlists[index-1].Program = editProgram.value;
|
|
||||||
config.Playlists[index-1].SortProgram = editSortProgram.value;
|
|
||||||
config.Playlists[index-1].Playlists = Array.from(editUsers.options).filter((x) => x.selected).map((x) => {
|
|
||||||
const existing = config.Playlists[index-1].Playlists.filter((x_) => x_.UserId === x.value).map((x_) => x_.PlaylistId);
|
|
||||||
const m = {UserId: x.value, PlaylistId: ((existing.length > 0) ? existing[0] : "00000000-0000-0000-0000-000000000000")};
|
|
||||||
return m;
|
|
||||||
}),
|
|
||||||
config.Playlists[index-1].Enabled = editEnabled.checked;
|
|
||||||
}
|
|
||||||
ApiClient.updatePluginConfiguration(SmartPlaylistConfig.pluginUniqueId, config).then(function (result) {
|
|
||||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
|
||||||
ApiClient.getPluginConfiguration(SmartPlaylistConfig.pluginUniqueId).then(function (config) {
|
|
||||||
document.querySelector('#InitialProgram').value = config.InitialProgram;
|
|
||||||
fillPlaylistSelect(config);
|
|
||||||
changeEditBox(config, (config.Playlists.length > 0) ? config.Playlists[0].Id : null);
|
|
||||||
Dashboard.hideLoadingMsg();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -5,18 +5,32 @@
|
||||||
<RootNamespace>Jellyfin.Plugin.SmartPlaylist</RootNamespace>
|
<RootNamespace>Jellyfin.Plugin.SmartPlaylist</RootNamespace>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<Version>0.4.1.0</Version>
|
<Version>0.5.3.0</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Jellyfin.Controller" Version="10.10.3" />
|
<PackageReference Include="Jellyfin.Controller" Version="10.10.6" />
|
||||||
<PackageReference Include="Jellyfin.Model" Version="10.10.3" />
|
<PackageReference Include="Jellyfin.Model" Version="10.10.6" />
|
||||||
<PackageReference Include="YamlDotNet" Version="16.2.1" />
|
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Remove="configPage.html"/>
|
<None Remove="Configuration\configPage.html"/>
|
||||||
<EmbeddedResource Include="configPage.html"/>
|
<None Remove="Configuration\configPage.js"/>
|
||||||
|
<None Remove="Pages\smartPlaylists.html"/>
|
||||||
|
<None Remove="Pages\smartPlaylists.js"/>
|
||||||
|
<None Remove="Pages\smartCollections.html"/>
|
||||||
|
<None Remove="Pages\smartCollections.js"/>
|
||||||
|
<None Remove="Pages\lispPlayground.html"/>
|
||||||
|
<None Remove="Pages\lispPlayground.js"/>
|
||||||
|
<EmbeddedResource Include="Configuration\configPage.html"/>
|
||||||
|
<EmbeddedResource Include="Configuration\configPage.js"/>
|
||||||
|
<EmbeddedResource Include="Pages\smartPlaylists.html"/>
|
||||||
|
<EmbeddedResource Include="Pages\smartPlaylists.js"/>
|
||||||
|
<EmbeddedResource Include="Pages\smartCollections.html"/>
|
||||||
|
<EmbeddedResource Include="Pages\smartCollections.js"/>
|
||||||
|
<EmbeddedResource Include="Pages\lispPlayground.html"/>
|
||||||
|
<EmbeddedResource Include="Pages\lispPlayground.js"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
@ -5,7 +5,7 @@ Smart playlists with Lisp filter engine.
|
||||||
This readme contains instructions for the most recent changes in
|
This readme contains instructions for the most recent changes in
|
||||||
the development branch (`main`). To view the file appropriate
|
the development branch (`main`). To view the file appropriate
|
||||||
for your version select the tag corresponding to your version.
|
for your version select the tag corresponding to your version.
|
||||||
The latest version is [v0.4.1.0](https://gitea.redxef.at/redxef/jellyfin-smart-playlist/src/tag/v0.4.1.0).
|
The latest version is [v0.5.3.0](https://gitea.redxef.at/redxef/jellyfin-smart-playlist/src/tag/v0.5.3.0).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
16
Test/test.sh
16
Test/test.sh
|
@ -4,14 +4,16 @@ JELLYFIN=jellyfin/jellyfin
|
||||||
|
|
||||||
set -eu
|
set -eu
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
pwd
|
|
||||||
(
|
|
||||||
cd ../Jellyfin.Plugin.SmartPlaylist/
|
|
||||||
dotnet build
|
|
||||||
)
|
|
||||||
pwd
|
|
||||||
mkdir -p ./cache ./media ./config/plugins/jellyfin-smart-playlist
|
mkdir -p ./cache ./media ./config/plugins/jellyfin-smart-playlist
|
||||||
cp ../Jellyfin.Plugin.SmartPlaylist/bin/Debug/net8.0/jellyfin-smart-playlist.dll ./config/plugins/jellyfin-smart-playlist/
|
if [ "$#" -eq 1 ] && [ "$1" = '--skip-build' ]; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
(
|
||||||
|
cd ../Jellyfin.Plugin.SmartPlaylist/
|
||||||
|
dotnet build
|
||||||
|
)
|
||||||
|
cp ../Jellyfin.Plugin.SmartPlaylist/bin/Debug/net8.0/jellyfin-smart-playlist.dll ./config/plugins/jellyfin-smart-playlist/
|
||||||
|
fi
|
||||||
docker pull "$JELLYFIN"
|
docker pull "$JELLYFIN"
|
||||||
docker run --rm --user "$(id -u):$(id -g)" \
|
docker run --rm --user "$(id -u):$(id -g)" \
|
||||||
-v ./cache:/cache \
|
-v ./cache:/cache \
|
||||||
|
|
|
@ -267,6 +267,10 @@ namespace Tests
|
||||||
//Assert.Equal("", e.eval("(shuf (list 0 1 2 3 4 5 6))").ToString());
|
//Assert.Equal("", e.eval("(shuf (list 0 1 2 3 4 5 6))").ToString());
|
||||||
Assert.Equal("(1 2 3 4 5 6 7)", e.eval("(qsort (lambda (a b) (> a b)) '(5 4 7 3 2 6 1))").ToString());
|
Assert.Equal("(1 2 3 4 5 6 7)", e.eval("(qsort (lambda (a b) (> a b)) '(5 4 7 3 2 6 1))").ToString());
|
||||||
Assert.Equal("(1 2 3 4 5 6 7)", e.eval("(qsort > (list 5 4 7 3 2 6 1))").ToString());
|
Assert.Equal("(1 2 3 4 5 6 7)", e.eval("(qsort > (list 5 4 7 3 2 6 1))").ToString());
|
||||||
|
Assert.Equal("(0 1 2 4 5 5)", e.eval("(qsort > '(2 4 1 5 5 0))").ToString());
|
||||||
|
Assert.Equal("(5 5 4 2 1 0)", e.eval("(qsort < '(2 4 1 5 5 0))").ToString());
|
||||||
|
Assert.Equal("(5 5 4 2 1 0)", e.eval("(qsort <= '(2 4 1 5 5 0))").ToString());
|
||||||
|
Assert.Equal("(0 1 2 4 5 5)", e.eval("(qsort >= '(2 4 1 5 5 0))").ToString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,7 @@ resources:
|
||||||
basic:
|
basic:
|
||||||
username: ((gitea.username))
|
username: ((gitea.username))
|
||||||
password: ((gitea.token))
|
password: ((gitea.token))
|
||||||
|
version_check_method: none
|
||||||
- name: artifact
|
- name: artifact
|
||||||
type: http-resource
|
type: http-resource
|
||||||
source:
|
source:
|
||||||
|
|
10
examples.md
10
examples.md
|
@ -26,19 +26,19 @@
|
||||||
(is-genre "swing" g)))
|
(is-genre "swing" g)))
|
||||||
```
|
```
|
||||||
|
|
||||||
- `All of Seeed`: A playlist containing all items by artist `Seeed`,
|
- `All of Haller`: A playlist containing all items by artist `Haller`,
|
||||||
useless, since you also can just navigate to the artist to play all
|
useless, since you also can just navigate to the artist to play all
|
||||||
their songs, but a good example.
|
their songs, but a good example.
|
||||||
```
|
```
|
||||||
Id: Seeed
|
Id: Haller
|
||||||
Name: Seeed
|
Name: Haller
|
||||||
Program: |
|
Program: |
|
||||||
(let
|
(let
|
||||||
(parent
|
(parent
|
||||||
(invoke-generic *item* "FindParent" nil (list "MediaBrowser.Controller.Entities.Audio.MusicArtist, MediaBrowser.Controller")))
|
(invoke-generic *item* "FindParent" nil (list "MediaBrowser.Controller.Entities.Audio.MusicArtist, MediaBrowser.Controller")))
|
||||||
(cond
|
(cond
|
||||||
((null parent) nil)
|
((null parent) nil)
|
||||||
(t (string= (car (getitems parent "Name")) "Haller"))))
|
(t (string= (lower (car (getitems parent "Name"))) (lower "Haller")))))
|
||||||
```
|
```
|
||||||
or simplified with definitions contained in the preamble:
|
or simplified with definitions contained in the preamble:
|
||||||
```
|
```
|
||||||
|
@ -91,7 +91,7 @@
|
||||||
(lambda
|
(lambda
|
||||||
(i)
|
(i)
|
||||||
;; the `(and (find-artist)` is here to prevent null violations.
|
;; the `(and (find-artist)` is here to prevent null violations.
|
||||||
(and (find-artist) (string= i (lower (get-name (find-artist))))))
|
(and (find-artist) (string= (lower i) (lower (get-name (find-artist))))))
|
||||||
*include-artists*)))
|
*include-artists*)))
|
||||||
SortProgram: (begin (shuf *items*))
|
SortProgram: (begin (shuf *items*))
|
||||||
Filename: /config/data/smartplaylists/German.yaml
|
Filename: /config/data/smartplaylists/German.yaml
|
||||||
|
|
Loading…
Add table
Reference in a new issue