Compare commits

...

37 commits

Author SHA1 Message Date
5944686d1d
feat: allow deleting via the ui. 2025-03-09 20:44:23 +01:00
7a590ade7e
feat: allow deleting of smart playlists. 2025-03-09 20:27:45 +01:00
f3a6b4a225
fix: print no longer prints newline, but added println. 2025-03-02 18:36:38 +01:00
2624b83a79
fix: make the parser more robust and no longer accept invalid input silently. 2025-03-02 18:35:36 +01:00
1d472ed61f
fix: make input smaller than program window.
Refs: #6
2025-02-26 23:46:15 +01:00
9bb68f6fac
docs: improve description.
Refs: #6
2025-02-26 23:44:25 +01:00
a00b01c577
feat: allow specifying items as inputs for the lisp playground.
Refs: #6
2025-02-26 23:36:52 +01:00
b35df95d86
feat: also log failing item id. 2025-02-26 22:50:40 +01:00
54a50ef04b
feat: add logging for crashed programs, don't crash the whole process. 2025-02-26 22:43:57 +01:00
b0593585df
fix: move FilterCollectionItems from Common.cs to GenerateCollection.cs.
It had no place being in common.
2025-02-26 22:31:18 +01:00
4db98eb1c8
docs: lowercase all artists in artist examples. 2025-02-24 23:04:13 +01:00
80803cd048
fix: pipeline version check for releases list. 2025-02-18 18:52:47 +01:00
571895bef8
fix: version in build.yaml. 2025-02-18 18:51:14 +01:00
2c6972cf86
ci: prepare for release. 2025-02-18 18:38:58 +01:00
feaf1b0e31
chore: bump jellyfin and yamldotnet versions. 2025-02-18 18:38:13 +01:00
64ae51ba71
fix: qsort ran into an infinite loop when encountering duplicate values. 2025-01-26 22:38:52 +01:00
d8145565cd
ci: prepare for release. 2025-01-26 00:26:03 +01:00
5689d0424c
chore: bump jellyfin ABI version. 2025-01-26 00:21:59 +01:00
eef2f32e14
fix: navigating to the config pages allways loads, not only on the first attempt. 2025-01-21 22:51:25 +01:00
2f07efd215
ci: prepare for release. 2025-01-20 21:18:07 +01:00
1aeb4d3cff
fix: readd updating path of plugin config.
That got lost during the restructuring for smart collections.
2025-01-20 21:13:24 +01:00
fef10b5736
feat: add length function. 2025-01-20 20:59:47 +01:00
49bacbffde
feat: implement split function.
Takes a list and an integer, splits off n elements from the list
and returns a list where the first element contains the first n
elements of the original list, the cdr of that list contains the
rest.
2025-01-20 20:52:11 +01:00
49298a3ca2
feat: add "reverse" function definition. 2025-01-20 18:05:26 +01:00
aea313a813
ci: prepare for release 0.5.0.0. 2025-01-20 00:06:08 +01:00
a569d863ae
feat: add lisp playground.
Refs: #4
2025-01-20 00:00:15 +01:00
001aad5ed9
Merge branch 'enable-all-item-kinds'
Refs: #2
2025-01-19 22:43:57 +01:00
614c0a0cb1
Merge branch 'collections'
Refs: #1
2025-01-19 22:40:27 +01:00
9ca1712b5d
fix: loading of the config pages. 2025-01-19 22:39:24 +01:00
aa6fed146d
feat: enable collections. 2025-01-19 22:28:32 +01:00
6ac75835f0
feat: move smart playlist configuration into own menu. 2025-01-19 21:50:53 +01:00
f25eafd186
test: add option to startup script to not load the newest plugin. 2025-01-19 15:32:25 +01:00
f5448e8a51
feat: add smart collections to backend. 2025-01-19 15:30:32 +01:00
dcdee2403c
Revert "feat: add collection or playlist choice to config page and dto."
This reverts commit 457755d743.
2025-01-19 15:26:44 +01:00
d2a10a967e
docs: fix typo. 2025-01-17 18:33:21 +01:00
457755d743
feat: add collection or playlist choice to config page and dto.
Refs: #1
2025-01-16 19:39:39 +01:00
4bc3b463cb
feat: enable more item kinds.
Refs: #2
2025-01-16 18:56:53 +01:00
31 changed files with 1384 additions and 399 deletions

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

View file

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

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

View file

@ -1,5 +1,4 @@
using MediaBrowser.Model.Plugins;
using MediaBrowser.Controller;
namespace Jellyfin.Plugin.SmartPlaylist {
public class PluginConfiguration : BasePluginConfiguration {
@ -43,30 +42,7 @@ namespace Jellyfin.Plugin.SmartPlaylist {
(define find-parent (lambda (typename) (invoke-generic *item* "FindParent" nil (list typename))))
(define find-artist (lambda nil (find-parent "MediaBrowser.Controller.Entities.Audio.MusicArtist, MediaBrowser.Controller"))))
""";
store = new Store(new SmartPlaylistFileSystem(Plugin.Instance.ServerApplicationPaths));
}
private Store store { get; set; }
public string InitialProgram { get; set; }
public SmartPlaylistDto[] Playlists {
get {
return store.GetAllSmartPlaylistsAsync().GetAwaiter().GetResult();
}
set {
var existing = store.GetAllSmartPlaylistsAsync().GetAwaiter().GetResult().Select(x => x.Id).ToList();
foreach (var p in value) {
existing.Remove(p.Id);
store.SaveSmartPlaylistAsync(p).GetAwaiter().GetResult();
}
foreach (var p in existing) {
store.DeleteSmartPlaylistById(p);
}
}
}
public object[][] Users {
get {
return Plugin.Instance.UserManager.Users.Select(x => new object[]{x.Id, x.Username}).ToArray();
}
set { }
}
}
}

View 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>

View 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;
});

View file

@ -3,7 +3,7 @@ using Jellyfin.Plugin.SmartPlaylist.Lisp;
namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
public class Parser {
private StringTokenStream _sts;
internal StringTokenStream _sts;
public Parser(StringTokenStream tokens) {
_sts = tokens;
}
@ -29,13 +29,18 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
Debug.Assert(start.value == end.value);
Debug.Assert("\"".Contains(start.value));
string r = "";
bool exit_ok = false;
while (_sts.Available() > 0) {
Token<string> t = _sts.Get();
if (t.value == end.value) {
exit_ok = true;
break;
}
r += t.value;
}
if (!exit_ok) {
throw new ApplicationException($"Failed to parse string, are you missing the closing quotes? String is: {r}");
}
_sts.Commit();
return new String(r);
}
@ -69,11 +74,16 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
if (";".Contains(start.value)) {
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>();
bool exit_ok = false;
while (_sts.Available() > 0) {
Token<string> t = _sts.Get();
if (t.value == end.value) {
exit_ok = true;
_sts.Commit();
break;
}
@ -87,7 +97,11 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
_sts.Rewind(1);
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) {

View file

@ -47,6 +47,40 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
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["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(
"""
(lambda
@ -56,25 +90,50 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
(lambda
(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
(lambda
(list0 pivot fc h0 h1)
(list0 pivot fc h0 h1 heq)
(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)))))
((null list0) (list list0 pivot fc h0 h1 heq))
((and
(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
(lambda
(fc list0)
(cond
(begin t (cond
((null list0) nil)
((null (cdr list0)) list0)
(t
(let*
(halves (split list0 (getpivot list0) fc nil nil))
(halves (split list0 (getpivot list0) fc nil nil nil))
(h0 (car (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)))
"""
);
@ -279,7 +338,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
IList<Expression> r = new List<Expression>();
MethodInfo? mi = o.Value().GetType().GetMethod(s.Value(), l_types);
if (mi == null) {
throw new ApplicationException($"{o.Value()} has no method {s.Value()}");
throw new ApplicationException($"{o.Value()} ({o.Value().GetType()}) has no method {s.Value()}");
}
return Object.FromBase(mi.Invoke(o.Value(), l_));
@ -418,7 +477,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
Executor new_e = new Executor(new SubEnvironment(e.environment), e.builtins, e.builtinsLater);
foreach (var pair in args.SkipLast(1)) {
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;
Expression exp = ((Cons) pair_cons.Item2).Item1;
@ -431,7 +490,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
List<(Symbol, Expression)> vars = new List<(Symbol, Expression)>();
foreach (var pair in args.SkipLast(1)) {
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;
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); }
else if (args.First() == Boolean.FALSE) { proc_args = new List<Symbol>(); }
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);
}
@ -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); }
else if (args.First() == Boolean.FALSE) { proc_args = new List<Symbol>(); }
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);
}
@ -542,7 +601,11 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
throw new ApplicationException($"Not handled case '{expression}'");
}
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) {
return eval(new Parser(sts));

View 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>

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

View 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>

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

View 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>

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

View file

@ -27,8 +27,42 @@ namespace Jellyfin.Plugin.SmartPlaylist {
return new[] {
new PluginPageInfo {
Name = this.Name,
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.configPage.html", GetType().Namespace)
}
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace),
},
new PluginPageInfo {
Name = "configPage.js",
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.js", GetType().Namespace),
},
new PluginPageInfo {
Name = "Smart Playlists",
DisplayName = "Smart Playlists",
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.smartPlaylists.html", GetType().Namespace),
EnableInMainMenu = true,
},
new PluginPageInfo {
Name = "smartPlaylists.js",
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.smartPlaylists.js", GetType().Namespace),
},
new PluginPageInfo {
Name = "Smart Collections",
DisplayName = "Smart Collections",
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.smartCollections.html", GetType().Namespace),
EnableInMainMenu = true,
},
new PluginPageInfo {
Name = "smartCollections.js",
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Pages.smartCollections.js", GetType().Namespace),
},
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),
},
};
}
}

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

View file

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

View file

@ -7,27 +7,17 @@ using MediaBrowser.Controller.Playlists;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using MediaBrowser.Model.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Playlists;
using Jellyfin.Plugin.SmartPlaylist.Lisp;
using Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler;
using Lisp_Object = Jellyfin.Plugin.SmartPlaylist.Lisp.Object;
using Lisp_Boolean = Jellyfin.Plugin.SmartPlaylist.Lisp.Boolean;
namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
public class GeneratePlaylist : IScheduledTask {
public class GeneratePlaylist : Common, IScheduledTask {
public static readonly BaseItemKind[] AvailableFilterItems = {
BaseItemKind.Audio,
BaseItemKind.MusicAlbum,
BaseItemKind.Playlist,
};
private readonly ILogger _logger;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
private readonly IProviderManager _providerManager;
@ -44,15 +34,14 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
IFileSystem fileSystem,
IPlaylistManager playlistManager,
IServerApplicationPaths serverApplicationPaths
) {
_logger = logger;
) : base(logger) {
_libraryManager = libraryManager;
_userManager = userManager;
_providerManager = providerManager;
_fileSystem = fileSystem;
_playlistManager = playlistManager;
_store = new Store(new SmartPlaylistFileSystem(serverApplicationPaths));
_store = new Store(new SmartFileSystem(serverApplicationPaths));
}
public string Category => "Library";
@ -81,62 +70,41 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
return playlistGuid;
}
private Executor SetupExecutor() {
var env = new DefaultEnvironment();
var executor = new Executor(env);
executor.builtins["logd"] = (x) => {
_logger.LogDebug(((Lisp.String)x.First()).Value(), x.Skip(1).ToArray());
return Lisp_Boolean.TRUE;
};
executor.builtins["logi"] = (x) => {
_logger.LogInformation(((Lisp.String)x.First()).Value(), x.Skip(1).ToArray());
return Lisp_Boolean.TRUE;
};
executor.builtins["logw"] = (x) => {
_logger.LogWarning(((Lisp.String)x.First()).Value(), x.Skip(1).ToArray());
return Lisp_Boolean.TRUE;
};
executor.builtins["loge"] = (x) => {
_logger.LogError(((Lisp.String)x.First()).Value(), x.Skip(1).ToArray());
return Lisp_Boolean.TRUE;
};
if (Plugin.Instance is not null) {
executor.eval(Plugin.Instance.Configuration.InitialProgram);
} else {
throw new ApplicationException("Plugin Instance is not yet initialized");
}
return executor;
}
private IEnumerable<Guid> FilterPlaylistItems(IEnumerable<BaseItem> items, User user, SmartPlaylistDto smartPlaylist) {
List<BaseItem> results = new List<BaseItem>();
Expression expression = new Parser(StringTokenStream.generate(smartPlaylist.Program)).parse(); // parse here, so that we don't repeat the work for each item
Executor executor = SetupExecutor();
executor.environment.Set("*user*", Lisp_Object.FromBase(user));
executor.environment.Set("*user*", Lisp.Object.FromBase(user));
foreach (var i in items) {
executor.environment.Set("*item*", Lisp_Object.FromBase(i));
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 Playlist {1}", i, smartPlaylist.Name);
results.Add(i);
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 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.environment.Set("*user*", Lisp_Object.FromBase(user));
executor.environment.Set("*items*", Lisp_Object.FromBase(results));
executor.environment.Set("*user*", Lisp.Object.FromBase(user));
executor.environment.Set("*items*", Lisp.Object.FromBase(results));
results = new List<BaseItem>();
var sort_result = executor.eval(smartPlaylist.SortProgram);
if (sort_result is Cons sorted_items) {
foreach (var i in sorted_items.ToList()) {
if (i is Lisp_Object iObject && iObject.Value() is BaseItem iBaseItem) {
if (i is Lisp.Object iObject && iObject.Value() is BaseItem iBaseItem) {
results.Add(iBaseItem);
continue;
}
throw new ApplicationException($"Returned sorted list does contain unexpected items, got {i}");
}
} else if (sort_result == Lisp_Boolean.FALSE) {
} else if (sort_result == Lisp.Boolean.FALSE) {
} else {
throw new ApplicationException($"Did not return a list of items, returned {sort_result}");
}
@ -162,6 +130,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
foreach (SmartPlaylistDto dto in all_playlists) {
if (!dto.Enabled) {
i += 1;
progress.Report(100 * ((double)i)/all_playlists.Count());
continue;
}
var changedDto = false;

View file

@ -108,4 +108,15 @@ namespace Jellyfin.Plugin.SmartPlaylist {
info.AddValue("Enabled", Enabled);
}
}
[Serializable]
public class SmartCollectionDto {
public SmartCollectionId Id { get; set; }
public CollectionId CollectionId { get; set; }
public string Name { get; set; }
public string Program { get; set; }
public string SortProgram { get; set; }
public string? Filename { get; set; }
public bool Enabled { get; set; }
}
}

View file

@ -3,34 +3,61 @@ using MediaBrowser.Controller;
namespace Jellyfin.Plugin.SmartPlaylist {
public interface ISmartPlaylistFileSystem {
public string StoragePath { get; }
public string GetSmartPlaylistFilePath(SmartPlaylistId smartPlaylistId);
public string FindSmartPlaylistFilePath(SmartPlaylistId smartPlaylistId);
public string PlaylistStoragePath { get; }
public string CollectionStoragePath { get; }
public string GetSmartPlaylistFilePath(string id);
public string FindSmartPlaylistFilePath(string id);
public string[] FindAllSmartPlaylistFilePaths();
public string GetSmartCollectionFilePath(string id);
public string FindSmartCollectionFilePath(string id);
public string[] FindAllSmartCollectionFilePaths();
}
public class SmartPlaylistFileSystem : ISmartPlaylistFileSystem {
public SmartPlaylistFileSystem(IServerApplicationPaths serverApplicationPaths) {
StoragePath = Path.Combine(serverApplicationPaths.DataPath, "smartplaylists");
if (!Directory.Exists(StoragePath)) { Directory.CreateDirectory(StoragePath); }
public class SmartFileSystem : ISmartPlaylistFileSystem {
public SmartFileSystem(IServerApplicationPaths serverApplicationPaths) {
PlaylistStoragePath = Path.Combine(serverApplicationPaths.DataPath, "smartplaylists");
CollectionStoragePath = Path.Combine(serverApplicationPaths.DataPath, "smartcollections");
if (!Directory.Exists(PlaylistStoragePath)) { Directory.CreateDirectory(PlaylistStoragePath); }
if (!Directory.Exists(CollectionStoragePath)) { Directory.CreateDirectory(CollectionStoragePath); }
}
public string StoragePath { get; }
public string GetSmartPlaylistFilePath(SmartPlaylistId smartPlaylistId) {
return Path.Combine(StoragePath, $"{smartPlaylistId}.yaml");
public string PlaylistStoragePath { get; }
public string CollectionStoragePath { get; }
public string GetSmartPlaylistFilePath(string id) {
return Path.Combine(PlaylistStoragePath, $"{id}.yaml");
}
public string FindSmartPlaylistFilePath(SmartPlaylistId smartPlaylistId) {
return Directory.GetFiles(StoragePath, $"{smartPlaylistId}.yaml", SearchOption.AllDirectories).Concat(
Directory.GetFiles(StoragePath, $"{smartPlaylistId}.yml", SearchOption.AllDirectories)
public string FindSmartPlaylistFilePath(string id) {
return Directory.GetFiles(PlaylistStoragePath, $"{id}.yaml", SearchOption.AllDirectories).Concat(
Directory.GetFiles(PlaylistStoragePath, $"{id}.yml", SearchOption.AllDirectories)
).Concat(
Directory.GetFiles(StoragePath, $"{smartPlaylistId}.json", SearchOption.AllDirectories)
Directory.GetFiles(PlaylistStoragePath, $"{id}.json", SearchOption.AllDirectories)
).First();
}
public string[] FindAllSmartPlaylistFilePaths() {
return Directory.GetFiles(StoragePath, "*.yaml", SearchOption.AllDirectories).Concat(
Directory.GetFiles(StoragePath, "*.yml", SearchOption.AllDirectories)
return Directory.GetFiles(PlaylistStoragePath, "*.yaml", SearchOption.AllDirectories).Concat(
Directory.GetFiles(PlaylistStoragePath, "*.yml", SearchOption.AllDirectories)
).Concat(
Directory.GetFiles(StoragePath, "*.json", SearchOption.AllDirectories)
Directory.GetFiles(PlaylistStoragePath, "*.json", SearchOption.AllDirectories)
).ToArray();
}
public string GetSmartCollectionFilePath(string id) {
return Path.Combine(CollectionStoragePath, $"{id}.yaml");
}
public string FindSmartCollectionFilePath(string id) {
return Directory.GetFiles(CollectionStoragePath, $"{id}.yaml", SearchOption.AllDirectories).Concat(
Directory.GetFiles(CollectionStoragePath, $"{id}.yml", SearchOption.AllDirectories)
).Concat(
Directory.GetFiles(CollectionStoragePath, $"{id}.json", SearchOption.AllDirectories)
).First();
}
public string[] FindAllSmartCollectionFilePaths() {
return Directory.GetFiles(CollectionStoragePath, "*.yaml", SearchOption.AllDirectories).Concat(
Directory.GetFiles(CollectionStoragePath, "*.yml", SearchOption.AllDirectories)
).Concat(
Directory.GetFiles(CollectionStoragePath, "*.json", SearchOption.AllDirectories)
).ToArray();
}
}
}

View file

@ -7,6 +7,12 @@ namespace Jellyfin.Plugin.SmartPlaylist {
Task SaveSmartPlaylistAsync(SmartPlaylistDto smartPlaylist);
void DeleteSmartPlaylistById(SmartPlaylistId smartPlaylistId);
void DeleteSmartPlaylist(SmartPlaylistDto smartPlaylist);
Task<SmartCollectionDto> GetSmartCollectionAsync(SmartCollectionId smartCollectionId);
Task<SmartCollectionDto[]> GetAllSmartCollectionsAsync();
Task SaveSmartCollectionAsync(SmartCollectionDto smartCollection);
void DeleteSmartCollectionById(SmartCollectionId smartCollectionId);
void DeleteSmartCollection(SmartCollectionDto smartCollection);
}
public class Store : IStore {
@ -59,5 +65,51 @@ namespace Jellyfin.Plugin.SmartPlaylist {
if (File.Exists(smartPlaylist.Filename)) { File.Delete(smartPlaylist.Filename); }
DeleteSmartPlaylistById(smartPlaylist.Id);
}
private async Task<SmartCollectionDto> LoadCollectionAsync(string filename) {
var r = await File.ReadAllTextAsync(filename);
if (r.Equals("")) {
r = "{}";
}
var dto = new DeserializerBuilder().Build().Deserialize<SmartCollectionDto>(r);
if (dto == null)
{
throw new ApplicationException("");
}
if (dto.Id != Path.GetFileNameWithoutExtension(filename)) {
dto.Id = Path.GetFileNameWithoutExtension(filename);
}
if (dto.Name != Path.GetFileNameWithoutExtension(filename)) {
dto.Name = Path.GetFileNameWithoutExtension(filename);
}
if (dto.Filename != filename) {
dto.Filename = filename;
}
return dto;
}
public async Task<SmartCollectionDto> GetSmartCollectionAsync(SmartCollectionId smartCollectionId) {
string filename = _fileSystem.FindSmartCollectionFilePath(smartCollectionId);
return await LoadCollectionAsync(filename).ConfigureAwait(false);
}
public async Task<SmartCollectionDto[]> GetAllSmartCollectionsAsync() {
var t = _fileSystem.FindAllSmartCollectionFilePaths().Select(LoadCollectionAsync).ToArray();
await Task.WhenAll(t).ConfigureAwait(false);
return t.Where(x => x != null).Select(x => x.Result).ToArray();
}
public async Task SaveSmartCollectionAsync(SmartCollectionDto smartCollection) {
string filename = _fileSystem.GetSmartCollectionFilePath(smartCollection.Id);
var text = new SerializerBuilder().Build().Serialize(smartCollection);
await File.WriteAllTextAsync(filename, text);
}
public void DeleteSmartCollectionById(SmartCollectionId smartCollectionId) {
try {
string filename = _fileSystem.FindSmartCollectionFilePath(smartCollectionId);
if (File.Exists(filename)) { File.Delete(filename); }
} catch (System.InvalidOperationException) {}
}
public void DeleteSmartCollection(SmartCollectionDto smartCollection) {
if (File.Exists(smartCollection.Filename)) { File.Delete(smartCollection.Filename); }
DeleteSmartCollectionById(smartCollection.Id);
}
}
}

View file

@ -2,4 +2,6 @@ global using System;
global using UserId = System.Guid;
global using PlaylistId = System.Guid;
global using CollectionId = System.Guid;
global using SmartPlaylistId = string;
global using SmartCollectionId = string;

View file

@ -56,6 +56,9 @@ namespace Jellyfin.Plugin.SmartPlaylist.Util {
public IStream<T> Copy() {
return new Stream<T>(_items, _cursor, _ephemeralCursor);
}
public IList<T> Remainder() {
return _items.Skip(_cursor).ToList();
}
}
}

View file

@ -1,7 +1,7 @@
name: Smart Playlist
guid: dd2326e3-4d3e-4bfc-80e6-28502c1131df
version: 0.4.1.0
targetAbi: 10.10.3.0
version: 0.5.3.0
targetAbi: 10.10.6.0
framework: net8.0
owner: redxef
overview: Smart playlists with Lisp filter engine.
@ -14,71 +14,9 @@ artifacts:
- jellyfin-smart-playlist.dll
- YamlDotNet.dll
changelog: |
## v0.4.1.0
- improve defaults for new playlists
## v0.5.3.0
- bump Jellyfin ABI version to 10.10.6
- bump yamldotnet to 16.3.0
**Fixes**:
- finally get th percentage indicator of the scheduled task right
## 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.
- qsort doesn't loop endlessly with duplicate values in import anymore

View file

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

View file

@ -5,18 +5,32 @@
<RootNamespace>Jellyfin.Plugin.SmartPlaylist</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>0.4.1.0</Version>
<Version>0.5.3.0</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.10.3" />
<PackageReference Include="Jellyfin.Model" Version="10.10.3" />
<PackageReference Include="YamlDotNet" Version="16.2.1" />
<PackageReference Include="Jellyfin.Controller" Version="10.10.6" />
<PackageReference Include="Jellyfin.Model" Version="10.10.6" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
<ItemGroup>
<None Remove="configPage.html"/>
<EmbeddedResource Include="configPage.html"/>
<None Remove="Configuration\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>
</Project>

View file

@ -5,7 +5,7 @@ Smart playlists with Lisp filter engine.
This readme contains instructions for the most recent changes in
the development branch (`main`). To view the file appropriate
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).
![configuration page](config.png)

View file

@ -4,14 +4,16 @@ JELLYFIN=jellyfin/jellyfin
set -eu
cd "$(dirname "$0")"
pwd
(
cd ../Jellyfin.Plugin.SmartPlaylist/
dotnet build
)
pwd
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 run --rm --user "$(id -u):$(id -g)" \
-v ./cache:/cache \

View file

@ -267,6 +267,10 @@ namespace Tests
//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 > (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());
}
}
}

View file

@ -29,6 +29,7 @@ resources:
basic:
username: ((gitea.username))
password: ((gitea.token))
version_check_method: none
- name: artifact
type: http-resource
source:

View file

@ -26,19 +26,19 @@
(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
their songs, but a good example.
```
Id: Seeed
Name: Seeed
Id: Haller
Name: Haller
Program: |
(let
(parent
(invoke-generic *item* "FindParent" nil (list "MediaBrowser.Controller.Entities.Audio.MusicArtist, MediaBrowser.Controller")))
(cond
((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:
```
@ -91,7 +91,7 @@
(lambda
(i)
;; 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*)))
SortProgram: (begin (shuf *items*))
Filename: /config/data/smartplaylists/German.yaml