Compare commits

..

47 commits

Author SHA1 Message Date
a5bc558486
docs: simplify the readme. 2024-12-22 23:46:56 +01:00
8426d14500
feat: sane defaults for new playlists in the UI. 2024-12-22 21:19:07 +01:00
31f36b6f38
docs: Mention the UI in the How to use section of the readme. 2024-12-22 21:14:50 +01:00
d593bf8597
docs: add a more complex example. 2024-12-22 21:06:06 +01:00
225dc7559f
fix: percentage base for progress was wrong. 2024-12-22 19:45:31 +01:00
5cca31cf16
ci: update urls. 2024-12-22 19:03:51 +01:00
a2abe724b7
ci: prepare for release of v0.4.0.0. 2024-12-22 18:47:46 +01:00
dc5435fa68
docs: update docs to use new global variables. 2024-12-22 18:46:56 +01:00
c5c2b86557
fix: default programs use updated global variables. 2024-12-22 18:43:55 +01:00
e236031920
chore: update yamldotnet to 16.2.1. 2024-12-22 18:21:32 +01:00
6776d9461a
feat!: follow naming convention and enclose global variables in **. 2024-12-22 18:19:48 +01:00
b441a09831
fix: initialize the executor the same for the filter programs and the sort program. 2024-12-22 18:16:45 +01:00
b73f65829d
fix: progress report should consider all playlists and maybe not jump around. 2024-12-21 01:12:01 +01:00
097d267d24
feat: Add UI. 2024-12-21 01:09:41 +01:00
28f3cc682e
feat: respect enabled flag on playlists. 2024-12-21 01:09:02 +01:00
4d2d22e486
fix: allow passing builtins to functions. 2024-12-18 01:20:36 +01:00
c9d7f1ee60
fix: allow programs to not be a list. 2024-12-18 01:15:54 +01:00
805d0efb4f
feat: add special case for string representation of cons which is a quote. 2024-12-18 01:04:44 +01:00
d5e8b69b70
fix: parsing when closing parentheses is has a space before it. 2024-12-18 00:55:36 +01:00
87dd4358d8
feat: parser, ignore comments. 2024-12-18 00:53:26 +01:00
6d7cd33d04
docs: fix cond example for real this time. 2024-12-18 00:34:11 +01:00
8453705bc4
docs: fix cond example. 2024-12-18 00:17:35 +01:00
3bba58d4ac
docs: move definition listing to lisp.md. 2024-12-17 23:33:36 +01:00
f7cbebdd9c
feat: add single quote quoting.
'(a b c) = (quote (a b c))
2024-12-17 23:29:55 +01:00
db5a659dfc
docs: fix grammar. 2024-12-17 23:08:01 +01:00
f0bfecad71
docs: document builtin functions available in lisp. 2024-12-17 23:06:43 +01:00
f73f501642
feat: add random and shuffle. 2024-12-17 22:19:44 +01:00
81184c23a7
feat: add logging definitions. 2024-12-17 18:37:36 +01:00
0059fc43e1
feat: add type cache to increase performance. 2024-12-17 18:18:26 +01:00
3f1a1e1a78
docs: add barebones example on how to find items by a specific artist. 2024-12-17 18:09:39 +01:00
2fc2959110
feat: add default find-artist definition. 2024-12-17 18:09:01 +01:00
e180b397c6
feat: Extend default preamble to contain get-name and find-parent. 2024-12-17 18:02:43 +01:00
18136e0fa6
feat: make it easier to find the correct assembly. 2024-12-17 18:02:09 +01:00
6d62f6eeb0
feat: allow calling generic methods. 2024-12-17 17:56:14 +01:00
4eb6ec1a04
feat: add debug logging to list all loaded assemblies. 2024-12-17 17:54:47 +01:00
96ebc366b6
ci: prepare for release of v0.3.0.0. 2024-11-24 23:28:57 +01:00
05742dd17c
feat: add all-genres and any-genres convenience definitions. 2024-11-24 22:42:44 +01:00
52b270a8d8
chore: make Object constructor internal. 2024-11-19 23:24:44 +01:00
1193ca3005
docs: document SortProgram and give a simple example. 2024-11-19 21:57:51 +01:00
8371dc8536
feat(lisp): add string comparison methods. 2024-11-19 21:57:34 +01:00
1b0c5455dd
feat(lisp): add quicksort implementation. 2024-11-19 21:15:41 +01:00
f479c93c5c
feat: add SortProgram.
Works similar to Program, but receives the list of matched items
and should return the same list but sorted.
This can actually also be used for further filtering.
2024-11-19 17:33:33 +01:00
0844cebd88
chore: bump jellyfin version. 2024-11-19 16:57:34 +01:00
24b3d41df5
fix: scale progress report to percentage. 2024-11-19 16:57:04 +01:00
45844cafec
fix: ToString call to avoid duplicates. 2024-11-18 20:59:20 +01:00
bf286d4ece
fix: Use ItemId instead of Id for LinkedChild.
See #12892
2024-11-18 20:51:52 +01:00
bfcf854d38
chore: bump jellyfin ABI version & tag for release. 2024-11-18 13:10:51 +01:00
17 changed files with 789 additions and 119 deletions

View file

@ -40,11 +40,36 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
return new String(r); return new String(r);
} }
Expression parse_grouping(GroupingToken start, GroupingToken? end) { Expression parse_comment(GroupingToken start, GroupingToken? end) {
Debug.Assert(end != null); Debug.Assert(end != null);
Debug.Assert(";".Contains(start.value));
while (_sts.Available() > 0) {
Token<string> t = _sts.Get();
if (t.value == end.value) {
break;
}
}
_sts.Commit();
return parse();
}
Expression parse_quote(GroupingToken start, GroupingToken? end) {
Debug.Assert(end == null);
Debug.Assert("'".Contains(start.value));
return Cons.FromList(new Expression[]{ new Symbol("quote"), parse()});
}
Expression parse_grouping(GroupingToken start, GroupingToken? end) {
if ("\"".Contains(start.value)) { if ("\"".Contains(start.value)) {
return parse_string(start, end); return parse_string(start, end);
} }
if ("'".Contains(start.value)) {
return parse_quote(start, end);
}
if (";".Contains(start.value)) {
return parse_comment(start, end);
}
Debug.Assert(end != null);
IList<Expression> expressions = new List<Expression>(); IList<Expression> expressions = new List<Expression>();
while (_sts.Available() > 0) { while (_sts.Available() > 0) {
Token<string> t = _sts.Get(); Token<string> t = _sts.Get();
@ -52,6 +77,13 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
_sts.Commit(); _sts.Commit();
break; break;
} }
if (t is SpaceToken) {
// need this here because those tokens can never
// return an expression and trying to parse the last
// expression will not work if its only spaces and a
// closing parentheses.
continue;
}
_sts.Rewind(1); _sts.Rewind(1);
expressions.Add(parse()); expressions.Add(parse());
} }

View file

@ -43,7 +43,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
return null; return null;
} }
char t = program.Get(); char t = program.Get();
if ("()\"".Contains(t)) { if ("()\"';".Contains(t)) {
return new GroupingToken(t.ToString()); return new GroupingToken(t.ToString());
} }
return null; return null;
@ -53,6 +53,10 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
return new GroupingToken(")"); return new GroupingToken(")");
} else if (_value == ")") { } else if (_value == ")") {
return null; return null;
} else if (_value == "'") {
return null;
} else if (_value == ";") {
return new GroupingToken("\n");
} }
return new GroupingToken(_value); return new GroupingToken(_value);
} }
@ -74,7 +78,10 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
} }
value += t; value += t;
} }
return null; if (value.Equals("")) {
return null;
}
return new AtomToken(value);
} }
} }
@ -97,7 +104,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
if (prev_avail == program.Available() && prev_avail == 0) { if (prev_avail == program.Available() && prev_avail == 0) {
break; break;
} else if (prev_avail == program.Available()) { } else if (prev_avail == program.Available()) {
throw new ApplicationException("Program is invalid"); throw new ApplicationException($"Program is invalid, still available: {program.Available()}");
} }
prev_avail = program.Available(); prev_avail = program.Available();
foreach (Type c in _classes) { foreach (Type c in _classes) {

View file

@ -157,11 +157,35 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
return new List<Expression>(); return new List<Expression>();
} }
} }
public class String: Scalar<string> { public class String: Scalar<string>, ISortable<String, Boolean> {
public String(string value) : base(value) {} public String(string value) : base(value) {}
public override string? ToString() { public override string? ToString() {
return $"\"{base.ToString()}\""; return $"\"{base.ToString()}\"";
} }
public static Boolean operator <(String a, String b) {
return (a.Value().CompareTo(b.Value()) < 0) ? Boolean.TRUE : Boolean.FALSE;
}
public static Boolean operator >(String a, String b) {
return b < a;
}
public static Boolean operator <=(String a, String b) {
return (a.Value().CompareTo(b.Value()) <= 0) ? Boolean.TRUE : Boolean.FALSE;
}
public static Boolean operator >=(String a, String b) {
return b <= a;
}
public override int GetHashCode() {
return base.GetHashCode();
}
public override bool Equals(object? other) {
return base.Equals(other);
}
public static Boolean operator ==(String a, String b) {
return (a._value == b._value) ? Boolean.TRUE : Boolean.FALSE;
}
public static Boolean operator !=(String a, String b) {
return (a._value != b._value) ? Boolean.TRUE : Boolean.FALSE;
}
} }
public class Cons: Expression { public class Cons: Expression {
public Expression Item1; public Expression Item1;
@ -219,12 +243,19 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
return $"{Item1} . {Item2}"; return $"{Item1} . {Item2}";
} }
public override string? ToString() { public override string? ToString() {
if (Item1 is Symbol SymbolItem1
&& SymbolItem1.Name() == "quote"
&& Item2 is Cons ConsItem2
&& ConsItem2.Item2.Equals(Boolean.FALSE)
) {
return $"'{ConsItem2.Item1}";
}
return $"({ToStringSimple()})"; return $"({ToStringSimple()})";
} }
} }
public class Object : Scalar<object> { public class Object : Scalar<object> {
public Object(object value) : base(value) { } internal Object(object value) : base(value) {}
public static Expression FromBase(object? o) { public static Expression FromBase(object? o) {
if (o == null) { if (o == null) {
return Boolean.FALSE; return Boolean.FALSE;

View file

@ -46,6 +46,55 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
this["fold"] = e.eval("(lambda (fc i l) (if (null l) i (fold fc (fc i (car l)) (cdr l))))"); this["fold"] = e.eval("(lambda (fc i l) (if (null l) i (fold fc (fc i (car l)) (cdr l))))");
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["qsort"] = e.eval(
"""
(lambda
(fc list00)
(let
(getpivot
(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)))))
(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 fc list00)))
"""
);
this["rand"] = e.eval(
"""
(lambda
(. a)
(cond
((null a) (random))
((null (cdr a)) (% (random) (car a)))
(t (+
(car a)
(%
(random)
(-
(car (cdr a))
(car a)))))))
"""
);
this["shuf"] = new Symbol("shuffle");
} }
} }
@ -80,7 +129,12 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
} }
public class Builtins : Dictionary<string, Function> { public class Builtins : Dictionary<string, Function> {
private static Dictionary<string, Type?> ResolvedTypes = new Dictionary<string, Type?>();
private Random Random;
public Builtins() : base() { public Builtins() : base() {
Random = new Random();
this["atom"] = _atom; this["atom"] = _atom;
this["eq"] = _eq; this["eq"] = _eq;
this["car"] = _car; this["car"] = _car;
@ -95,21 +149,33 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
this["/"] = (x) => _agg((Integer a, Integer b) => a / b, x); this["/"] = (x) => _agg((Integer a, Integer b) => a / b, x);
this["%"] = (x) => _agg((Integer a, Integer b) => a % b, x); this["%"] = (x) => _agg((Integer a, Integer b) => a % b, x);
this["="] = (x) => _cmp((Integer a, Integer b) => a == b, x); this["="] = (x) => _cmp((Atom a, Atom b) => (a == b)? Boolean.TRUE : Boolean.FALSE, x);
this["eq?"] = (x) => _cmp((Integer a, Integer b) => a == b, x); this["!="] = (x) => _cmp((Atom a, Atom b) => (a != b)? Boolean.TRUE : Boolean.FALSE, x);
this["<"] = (x) => _cmp((Integer a, Integer b) => a < b, x); this["<"] = (x) => _cmp((Integer a, Integer b) => a < b, x);
this["<="] = (x) => _cmp((Integer a, Integer b) => a <= b, x); this["<="] = (x) => _cmp((Integer a, Integer b) => a <= b, x);
this[">"] = (x) => _cmp((Integer a, Integer b) => a > b, x); this[">"] = (x) => _cmp((Integer a, Integer b) => a > b, x);
this[">="] = (x) => _cmp((Integer a, Integer b) => a >= b, x); this[">="] = (x) => _cmp((Integer a, Integer b) => a >= b, x);
this["!="] = (x) => _cmp((Integer a, Integer b) => a != b, x);
this["not"] = (x) => { this["not"] = (x) => {
return (x.First() == Boolean.FALSE) ? Boolean.TRUE : Boolean.FALSE; return (x.First() == Boolean.FALSE) ? Boolean.TRUE : Boolean.FALSE;
}; };
this["string="] = (x) => _cmp((String a, String b) => a == b, x);
this["string!="] = (x) => _cmp((String a, String b) => a != b, x);
this["string>"] = (x) => _cmp((String a, String b) => a > b, x);
this["string>="] = (x) => _cmp((String a, String b) => a >= b, x);
this["string<"] = (x) => _cmp((String a, String b) => a < b, x);
this["string<="] = (x) => _cmp((String a, String b) => a <= b, x);
this["haskeys"] = _haskeys; this["haskeys"] = _haskeys;
this["getitems"] = _getitems; this["getitems"] = _getitems;
this["invoke"] = _invoke; this["invoke"] = _invoke;
this["invoke-generic"] = _invoke_generic;
this["random"] = (x) => new Lisp.Integer(Random.Next());
this["shuffle"] = (x) => {
var newx = ((Lisp.Cons) x.First()).ToList().ToArray();
Random.Shuffle<Expression>(newx);
return Lisp.Cons.FromList(newx);
};
} }
private static T _agg<T>(Func<T, T, T> op, IEnumerable<Expression> args) where T : Expression { private static T _agg<T>(Func<T, T, T> op, IEnumerable<Expression> args) where T : Expression {
T agg = (T) args.First(); T agg = (T) args.First();
@ -213,11 +279,73 @@ 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 not method {s.Value()}"); throw new ApplicationException($"{o.Value()} has no method {s.Value()}");
} }
return Object.FromBase(mi.Invoke(o.Value(), l_)); return Object.FromBase(mi.Invoke(o.Value(), l_));
} }
private static Type? GetType(string name) {
if (ResolvedTypes.ContainsKey(name)) {
return ResolvedTypes[name];
}
var t = Type.GetType(
name,
(name) => { return AppDomain.CurrentDomain.GetAssemblies().Where(z => (z.FullName != null) && z.FullName.StartsWith(name.FullName)).FirstOrDefault(); },
null,
true
);
ResolvedTypes[name] = t;
return t;
}
private static Expression _invoke_generic(IEnumerable<Expression> args) {
Object o = new Object(((IInner) args.First()).Inner());
String s = (String) args.Skip(1).First();
IEnumerable<Expression> l;
if (args.Skip(2).First() is Boolean lb && lb == Boolean.FALSE) {
l = new List<Expression>();
} else if (args.Skip(2).First() is Cons lc) {
l = lc.ToList();
} else {
throw new ApplicationException($"Expected a list of arguments, got {args.Skip(2).First()}");
}
IEnumerable<Type> types;
if (args.Skip(3).First() is Boolean lb_ && lb_ == Boolean.FALSE) {
types = new List<Type>();
} else if (args.Skip(3).First() is Cons lc) {
types = lc.ToList().Select(x => GetType(((String) x).Value())).ToList();
} else {
throw new ApplicationException($"Expected a list of arguments, got {args.Skip(3).First()}");
}
object[]? l_ = l.Select<Expression, object>(x => {
switch (x) {
case Integer s:
return s.Value();
case Boolean b:
return b.Value();
case String s:
return s.Value();
case Object o:
return o.Value();
case Cons c:
return c.ToList().ToList();
}
throw new ApplicationException($"Unhandled value {x} (type {x.GetType()})");
}).ToArray();
Type[] l_types = l_.Select( x => {
return x.GetType();
}).ToArray();
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()}");
}
mi = mi.MakeGenericMethod(types.ToArray());
return Object.FromBase(mi.Invoke(o.Value(), l_));
}
} }
public class BuiltinsLater : Dictionary<string, FunctionLater> { public class BuiltinsLater : Dictionary<string, FunctionLater> {
@ -375,6 +503,9 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
switch (expression) { switch (expression) {
case Symbol s: case Symbol s:
if (_environment.Find(s.Name()) is not IEnvironment<string, Expression> env) { if (_environment.Find(s.Name()) is not IEnvironment<string, Expression> env) {
if (builtins.ContainsKey(s.Name()) || builtinsLater.ContainsKey(s.Name())) {
return s;
}
throw new ApplicationException($"Could not find '{s.Name()}'"); throw new ApplicationException($"Could not find '{s.Name()}'");
} }
var r_ = env.Get(s.Name()); var r_ = env.Get(s.Name());

View file

@ -3,14 +3,22 @@ using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Serialization;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Library;
namespace Jellyfin.Plugin.SmartPlaylist { namespace Jellyfin.Plugin.SmartPlaylist {
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages { public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages {
public IServerApplicationPaths ServerApplicationPaths;
public IUserManager UserManager;
public Plugin( public Plugin(
IApplicationPaths applicationPaths, IApplicationPaths applicationPaths,
IXmlSerializer xmlSerializer IXmlSerializer xmlSerializer,
IServerApplicationPaths serverApplicationPaths,
IUserManager userManager
) : base (applicationPaths, xmlSerializer) { ) : base (applicationPaths, xmlSerializer) {
Instance = this; Instance = this;
ServerApplicationPaths = serverApplicationPaths;
UserManager = userManager;
} }
public static Plugin? Instance {get; private set; } public static Plugin? Instance {get; private set; }
public override string Name => "Smart Playlist"; public override string Name => "Smart Playlist";

View file

@ -1,4 +1,5 @@
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 {
@ -20,24 +21,52 @@ namespace Jellyfin.Plugin.SmartPlaylist {
(define genre-list (define genre-list
(lambda nil (lambda nil
(let (let
(_g (getitems item "Genres")) (_g (getitems *item* "Genres"))
(if (null _g) (if (null _g)
nil nil
(car _g))))) (car _g)))))
(define is-favorite (define is-favorite
(lambda nil (lambda nil
(invoke item "IsFavoriteOrLiked" (list user)))) (invoke *item* "IsFavoriteOrLiked" (list *user*))))
(define is-type (define is-type
(lambda (x) (lambda (x)
(and (and
(haskeys item "GetClientTypeName") (haskeys *item* "GetClientTypeName")
(invoke (invoke item "GetClientTypeName" nil) "Equals" (list x))))) (invoke (invoke *item* "GetClientTypeName" nil) "Equals" (list x)))))
(define name-contains (define name-contains
(lambda (x) (lambda (x)
(invoke (lower (car (getitems item "Name"))) "Contains" (list (lower x))))) (invoke (lower (car (getitems *item* "Name"))) "Contains" (list (lower x)))))
(define is-favourite is-favorite)) (define is-favourite is-favorite)
(define all-genres (lambda (want have) (all (lambda (x) (is-genre x have)) want)))
(define any-genres (lambda (want have) (any (lambda (x) (is-genre x have)) want)))
(define get-name (lambda (x) (car (getitems x "Name"))))
(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 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

@ -1,3 +1,4 @@
using System.Globalization;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MediaBrowser.Controller; using MediaBrowser.Controller;
@ -93,26 +94,66 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
return playlistGuid; return playlistGuid;
} }
private IEnumerable<Guid> FilterPlaylistItems(IEnumerable<BaseItem> items, User user, SmartPlaylistDto smartPlaylist) { private Executor SetupExecutor() {
List<Guid> results = new List<Guid>(); var env = new DefaultEnvironment();
Expression expression = new Parser(StringTokenStream.generate(smartPlaylist.Program)).parse(); var executor = new Executor(env);
Executor executor = new Executor(new DefaultEnvironment()); executor.builtins["logd"] = (x) => {
executor.environment.Set("user", new Lisp_Object(user)); _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) { if (Plugin.Instance is not null) {
executor.eval(Plugin.Instance.Configuration.InitialProgram); executor.eval(Plugin.Instance.Configuration.InitialProgram);
} else { } else {
throw new ApplicationException("Plugin Instance is not yet initialized"); 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));
foreach (var i in items) { foreach (var i in items) {
executor.environment.Set("item", new Lisp_Object(i)); executor.environment.Set("*item*", Lisp_Object.FromBase(i));
var r = executor.eval(expression); var r = executor.eval(expression);
_logger.LogTrace("Item {0} evaluated to {1}", i, r.ToString()); _logger.LogTrace("Item {0} evaluated to {1}", i, r.ToString());
if ((r is not Lisp_Boolean r_bool) || (r_bool.Value())) { if ((r is not Lisp_Boolean r_bool) || (r_bool.Value())) {
_logger.LogDebug("Added '{0}' to Smart Playlist {1}", i, smartPlaylist.Name); _logger.LogDebug("Added '{0}' to Smart Playlist {1}", i, smartPlaylist.Name);
results.Add(i.Id); results.Add(i);
} }
} }
return results; 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(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) {
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> GetAllUserMedia(User user) { private IEnumerable<BaseItem> GetAllUserMedia(User user) {
@ -125,7 +166,17 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) {
_logger.LogInformation("Started regenerate Smart Playlists"); _logger.LogInformation("Started regenerate Smart Playlists");
foreach (SmartPlaylistDto dto in await _store.GetAllSmartPlaylistsAsync()) { _logger.LogDebug("Loaded Assemblies:");
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) {
_logger.LogDebug("- {0}", asm);
}
var i = 0;
var all_playlists = await _store.GetAllSmartPlaylistsAsync();
foreach (SmartPlaylistDto dto in all_playlists) {
if (!dto.Enabled) {
i += 1;
continue;
}
var changedDto = false; var changedDto = false;
if (dto.Playlists.Length == 0) { if (dto.Playlists.Length == 0) {
dto.Playlists = _userManager.UsersIds.Select(x => new SmartPlaylistLinkDto { dto.Playlists = _userManager.UsersIds.Select(x => new SmartPlaylistLinkDto {
@ -150,7 +201,6 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
_store.DeleteSmartPlaylist(dto); // delete in case the file was not the canonical one. _store.DeleteSmartPlaylist(dto); // delete in case the file was not the canonical one.
await _store.SaveSmartPlaylistAsync(dto); await _store.SaveSmartPlaylistAsync(dto);
} }
var i = 0;
foreach (SmartPlaylistLinkDto playlistLink in dto.Playlists) { foreach (SmartPlaylistLinkDto playlistLink in dto.Playlists) {
User? user = _userManager.GetUserById(playlistLink.UserId); User? user = _userManager.GetUserById(playlistLink.UserId);
if (user == null) { if (user == null) {
@ -160,9 +210,9 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
var playlist = _playlistManager.GetPlaylists(playlistLink.UserId).Where(x => x.Id == playlistLink.PlaylistId).First(); var playlist = _playlistManager.GetPlaylists(playlistLink.UserId).Where(x => x.Id == playlistLink.PlaylistId).First();
await ClearPlaylist(playlist); await ClearPlaylist(playlist);
await _playlistManager.AddItemToPlaylistAsync(playlist.Id, insertItems, playlistLink.UserId); await _playlistManager.AddItemToPlaylistAsync(playlist.Id, insertItems, playlistLink.UserId);
i += 1;
progress.Report(((double)i)/dto.Playlists.Count());
} }
i += 1;
progress.Report(100 * ((double)i)/all_playlists.Count());
} }
} }
@ -172,7 +222,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
throw new ArgumentException(""); throw new ArgumentException("");
} }
var existingItems = playlist_new.GetManageableItems().ToList(); var existingItems = playlist_new.GetManageableItems().ToList();
await _playlistManager.RemoveItemFromPlaylistAsync(playlist.Id.ToString(), existingItems.Select(x => x.Item1.Id)); await _playlistManager.RemoveItemFromPlaylistAsync(playlist.Id.ToString(), existingItems.Select(x => x.Item1.ItemId?.ToString("N", CultureInfo.InvariantCulture)));
} }
} }
} }

View file

@ -41,11 +41,13 @@ namespace Jellyfin.Plugin.SmartPlaylist {
[Serializable] [Serializable]
public class SmartPlaylistDto : ISerializable { public class SmartPlaylistDto : ISerializable {
private static string DEFAULT_PROGRAM = "(begin (invoke item \"IsFavoriteOrLiked\" (list user)))"; private static string DEFAULT_PROGRAM = "(begin (invoke item \"IsFavoriteOrLiked\" (list *user*)))";
private static string DEFAULT_SORT_PROGRAM = "(begin *items*)";
public SmartPlaylistId Id { get; set; } public SmartPlaylistId Id { get; set; }
public SmartPlaylistLinkDto[] Playlists { get; set; } public SmartPlaylistLinkDto[] Playlists { get; set; }
public string Name { get; set; } public string Name { get; set; }
public string Program { get; set; } public string Program { get; set; }
public string SortProgram { get; set; }
public string? Filename { get; set; } public string? Filename { get; set; }
public bool Enabled { get; set; } public bool Enabled { get; set; }
@ -54,6 +56,7 @@ namespace Jellyfin.Plugin.SmartPlaylist {
Playlists = []; Playlists = [];
Name = Id.ToString(); Name = Id.ToString();
Program = DEFAULT_PROGRAM; Program = DEFAULT_PROGRAM;
SortProgram = DEFAULT_SORT_PROGRAM;
Filename = null; Filename = null;
Enabled = true; Enabled = true;
} }
@ -79,6 +82,11 @@ namespace Jellyfin.Plugin.SmartPlaylist {
} else { } else {
Program = DEFAULT_PROGRAM; Program = DEFAULT_PROGRAM;
} }
if (info.GetValue("SortProgram", typeof(string)) is string _SortProgram) {
SortProgram = _SortProgram;
} else {
SortProgram = DEFAULT_SORT_PROGRAM;
}
if (info.GetValue("Filename", typeof(string)) is string _Filename) { if (info.GetValue("Filename", typeof(string)) is string _Filename) {
Filename = _Filename; Filename = _Filename;
} else { } else {

View file

@ -5,6 +5,7 @@ namespace Jellyfin.Plugin.SmartPlaylist {
Task<SmartPlaylistDto> GetSmartPlaylistAsync(SmartPlaylistId smartPlaylistId); Task<SmartPlaylistDto> GetSmartPlaylistAsync(SmartPlaylistId smartPlaylistId);
Task<SmartPlaylistDto[]> GetAllSmartPlaylistsAsync(); Task<SmartPlaylistDto[]> GetAllSmartPlaylistsAsync();
Task SaveSmartPlaylistAsync(SmartPlaylistDto smartPlaylist); Task SaveSmartPlaylistAsync(SmartPlaylistDto smartPlaylist);
void DeleteSmartPlaylistById(SmartPlaylistId smartPlaylistId);
void DeleteSmartPlaylist(SmartPlaylistDto smartPlaylist); void DeleteSmartPlaylist(SmartPlaylistDto smartPlaylist);
} }
@ -48,7 +49,7 @@ namespace Jellyfin.Plugin.SmartPlaylist {
var text = new SerializerBuilder().Build().Serialize(smartPlaylist); var text = new SerializerBuilder().Build().Serialize(smartPlaylist);
await File.WriteAllTextAsync(filename, text); await File.WriteAllTextAsync(filename, text);
} }
private void DeleteSmartPlaylistById(SmartPlaylistId smartPlaylistId) { public void DeleteSmartPlaylistById(SmartPlaylistId smartPlaylistId) {
try { try {
string filename = _fileSystem.FindSmartPlaylistFilePath(smartPlaylistId); string filename = _fileSystem.FindSmartPlaylistFilePath(smartPlaylistId);
if (File.Exists(filename)) { File.Delete(filename); } if (File.Exists(filename)) { File.Delete(filename); }

View file

@ -1,7 +1,7 @@
name: Smart Playlist name: Smart Playlist
guid: dd2326e3-4d3e-4bfc-80e6-28502c1131df guid: dd2326e3-4d3e-4bfc-80e6-28502c1131df
version: 0.2.1.0 version: 0.4.0.0
targetAbi: 10.10.0.0 targetAbi: 10.10.3.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,6 +14,45 @@ artifacts:
- jellyfin-smart-playlist.dll - jellyfin-smart-playlist.dll
- YamlDotNet.dll - YamlDotNet.dll
changelog: | changelog: |
## 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 ## v0.2.1.0
- Make default program configuration a textarea in the settings page - Make default program configuration a textarea in the settings page
- Add convinience definitions: `is-type`, `name-contains` - Add convinience definitions: `is-type`, `name-contains`
@ -30,7 +69,7 @@ changelog: |
- Add configuration page with some default definitions for - Add configuration page with some default definitions for
the filter expressions. the filter expressions.
**Breaking Changes:** **Breaking Changes**:
- The lisp interpreter will now only detect strings in double quotes (`"`). - The lisp interpreter will now only detect strings in double quotes (`"`).
- The interpreter will also not allow specifying lists without quoting them. - 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 `(1 2 3)` ... used to work but will no longer, replace by either specifying

View file

@ -14,6 +14,36 @@
<div class="fieldDescription">A program which can set up the environment</div> <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> <textarea id="InitialProgram" class="emby-input smartplaylist-monospace" name="InitialProgram" rows="16" cols="120"></textarea>
</div> </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> <div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button"> <button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span> <span>Save</span>
@ -32,11 +62,83 @@
pluginUniqueId: 'dd2326e3-4d3e-4bfc-80e6-28502c1131df' 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') document.querySelector('#SmartPlaylistConfigPage')
.addEventListener('pageshow', function() { .addEventListener('pageshow', function() {
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(SmartPlaylistConfig.pluginUniqueId).then(function (config) { ApiClient.getPluginConfiguration(SmartPlaylistConfig.pluginUniqueId).then(function (config) {
document.querySelector('#InitialProgram').value = config.InitialProgram; 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(); Dashboard.hideLoadingMsg();
}); });
}); });
@ -46,9 +148,48 @@
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(SmartPlaylistConfig.pluginUniqueId).then(function (config) { ApiClient.getPluginConfiguration(SmartPlaylistConfig.pluginUniqueId).then(function (config) {
config.InitialProgram = document.querySelector('#InitialProgram').value; 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) { ApiClient.updatePluginConfiguration(SmartPlaylistConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(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(); e.preventDefault();

View file

@ -5,13 +5,13 @@
<RootNamespace>Jellyfin.Plugin.SmartPlaylist</RootNamespace> <RootNamespace>Jellyfin.Plugin.SmartPlaylist</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Version>0.2.1.0</Version> <Version>0.4.0.0</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.10.0" /> <PackageReference Include="Jellyfin.Controller" Version="10.10.3" />
<PackageReference Include="Jellyfin.Model" Version="10.10.0" /> <PackageReference Include="Jellyfin.Model" Version="10.10.3" />
<PackageReference Include="YamlDotNet" Version="16.2.0" /> <PackageReference Include="YamlDotNet" Version="16.2.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -5,74 +5,18 @@ 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.2.1.0](https://gitea.redxef.at/redxef/jellyfin-smart-playlist/src/tag/v0.2.0.0). The latest version is [v0.4.0.0](https://gitea.redxef.at/redxef/jellyfin-smart-playlist/src/tag/v0.4.0.0).
## How to use ## How to use
After [installing](#installation) the plugin and restarting Jellyfin After [installing](#installation) the plugin and restarting Jellyfin
create a empty file in `config/data/smartplaylists` like this, maybe go to the plugin settings and below the `Initial Program` configuration
you want to generate a playlist of your favourite rock songs: choose the smart playlist you want to edit, or `Create new playlist ...`
to create a new one.
``` [Go here](examples.md) to see some example configurations.
$ touch config/data/smartplaylists/Rock.yaml
```
Afterwards run the Task `(re)generate Smart Playlists`, this will rename Below are all the configuration values for a smart playlist.
the `yaml` file and populate it with some default values. You can now
adjust the file to your liking. [Go here](examples.md) to see more
examples.
```yaml
Id: Rock
Playlists:
- PlaylistId: 24f12e1e-3278-d6d6-0ca4-066e93296c95
UserId: 6eec632a-ff0d-4d09-aad0-bf9e90b14bc6
Name: Rock
Program: (begin (invoke item "IsFavoriteOrLiked" (user)))
Filename: /config/data/smartplaylists/Rock.yaml
Enabled: true
```
This is the default configuration and will always match all your
favorite songs (and songs which are in favourited albums).
To change the filter you can append a `|` (pipe) to the Program
line and write multiline filters like this:
```yaml
Porgram: |
(begin
(invoke item "IsFavoriteOrLiked" (list user)))
```
This is equivalent to the above example (not counting the other
fields obviously).
### Id
Arbitrary Id assigned to this playlist, can usually be left alone.
### Playlists
A list of Playlist/User mappings. By default all users get an entry.
The ids must have the dashes in them as of now. To convert a id
from without dashes to the canonical form run this command:
`echo '<your id here>' | python3 -c 'import uuid; import sys; print(uuid.UUID(sys.stdin.read().strip()))'`
To get your user id navigate to your user profile and copy the part
after `userId` in the address bar.
#### PlaylistId
The id of the playlist that should be managed, must be owned by the
corresponding user.
#### UserId
The user associated with this playlist.
### Name ### Name
@ -84,7 +28,10 @@ still work and remember the correct playlist.
A lisp program to decide on a per item basis if it should be included in A lisp program to decide on a per item basis if it should be included in
the playlist, return `nil` to not include items, return any other value the playlist, return `nil` to not include items, return any other value
to include them. to include them. Global variables `*user*` and `*item*` are predefined
and contain a [User](https://github.com/jellyfin/jellyfin/blob/master/Jellyfin.Data/Entities/User.cs) and
[BaseItem](https://github.com/jellyfin/jellyfin/blob/master/MediaBrowser.Controller/Entities/BaseItem.cs)
respectively.
**!!! The filter expression will include all items matching, if you do **!!! The filter expression will include all items matching, if you do
not specify the kind of item to include/exclude all of them will be not specify the kind of item to include/exclude all of them will be
@ -98,20 +45,24 @@ The configuration page defines some useful functions to make it easier
to create filters. The above filter for liked items could be simplified to create filters. The above filter for liked items could be simplified
to: `(is-favourite)`. to: `(is-favourite)`.
#### Available definitions *Go [here](lisp.md) to get an overview of the built-in functions.*
- **lower**: lowercases a string (`(eq (lower "SomeString") "somestring")`) ### SortProgram
- **is-genre**: check if the item is of this genre, partial matches
allowed, the example filter would match the genre "Nu-Metal" (`(is-genre "metal" (genre-list))`)
- **is-genre-exact**: the same as `is-genre`, but does not match paritally
- **is-favorite**: matches a favorite item (`(is-favorite)`)
- **is-type**: matches the type of item look at
[BaseItemKind.cs](https://github.com/jellyfin/jellyfin/blob/master/Jellyfin.Data/Enums/BaseItemKind.cs)
for a list of items. The plugin has enabled support for `Audio, MusicAlbum, Playlist` (`(is-type "Audio")`)
### Filename This works exactly like [Program](#program), but the input is the
user and a list of items (`*items*`) matched by [Program](#program).
The default is `(begin *items*)`, which doesn't sort at all. To sort
the items by name you could use the following program:
The path to this file, only used internally and updated by the program. ```lisp
(qsort
(lambda
(a b)
(string>
(car (getitems a "Name"))
(car (getitems b "Name"))))
*items*)
```
### Enabled ### Enabled

View file

@ -1,5 +1,6 @@
using Lisp_Boolean = Jellyfin.Plugin.SmartPlaylist.Lisp.Boolean; using Lisp_Boolean = Jellyfin.Plugin.SmartPlaylist.Lisp.Boolean;
using Lisp_Object = Jellyfin.Plugin.SmartPlaylist.Lisp.Object; using Lisp_Object = Jellyfin.Plugin.SmartPlaylist.Lisp.Object;
using Lisp_Integer = Jellyfin.Plugin.SmartPlaylist.Lisp.Integer;
using Jellyfin.Plugin.SmartPlaylist.Lisp; using Jellyfin.Plugin.SmartPlaylist.Lisp;
using Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler; using Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler;
@ -17,6 +18,9 @@ namespace Tests
public int I() { public int I() {
return _i; return _i;
} }
public string G<E>() {
return typeof(E).FullName;
}
} }
public class Test { public class Test {
@ -63,6 +67,26 @@ namespace Tests
sts = StringTokenStream.generate(program); sts = StringTokenStream.generate(program);
p = new Parser(sts); p = new Parser(sts);
Assert.Equal(program, string.Format("{0}", p.parse())); Assert.Equal(program, string.Format("{0}", p.parse()));
program = "(abc '(1 2 3))";
sts = StringTokenStream.generate(program);
p = new Parser(sts);
Assert.Equal(program, string.Format("{0}", p.parse()));
program = "(abc \"'(1 2 3)\")";
sts = StringTokenStream.generate(program);
p = new Parser(sts);
Assert.Equal(program, string.Format("{0}", p.parse()));
program = """
(begin ;this too
;;; this is a comment
t
)
""";
sts = StringTokenStream.generate(program);
p = new Parser(sts);
Assert.Equal("(begin t)", string.Format("{0}", p.parse()));
} }
[Fact] [Fact]
@ -125,6 +149,7 @@ namespace Tests
(if (> 0 1) (car (quote (1 2 3))) (cdr (quote (2 3 4))))) (if (> 0 1) (car (quote (1 2 3))) (cdr (quote (2 3 4)))))
""").ToString()); """).ToString());
Assert.Equal("a", e.eval("'a").ToString());
} }
[Fact] [Fact]
@ -195,13 +220,22 @@ namespace Tests
public static void ObjectTest() { public static void ObjectTest() {
Executor e = new Executor(); Executor e = new Executor();
Expression r; Expression r;
e.environment.Set("o", new Lisp_Object(new O(5, false))); e.environment.Set("o", Lisp_Object.FromBase(new O(5, false)));
r = e.eval("""(haskeys o "i" "b")"""); r = e.eval("""(haskeys o "i" "b")""");
Assert.True(((Lisp_Boolean)r).Value()); Assert.True(((Lisp_Boolean)r).Value());
r = e.eval("""(getitems o "i" "b")"""); r = e.eval("""(getitems o "i" "b")""");
Assert.Equal("(5 nil)", string.Format("{0}", r)); Assert.Equal("(5 nil)", string.Format("{0}", r));
r = e.eval("""(invoke o "I" nil)"""); r = e.eval("""(invoke o "I" nil)""");
Assert.Equal("5", string.Format("{0}", r)); Assert.Equal("5", string.Format("{0}", r));
r = e.eval("""(invoke-generic o "G" nil ((lambda (. args) args) "System.String"))""");
Assert.Equal("\"System.String\"", string.Format("{0}", r));
}
[Fact]
public static void GlobalVariableTest() {
Executor e = new Executor();
e.environment.Set("*o*", new Lisp_Integer(5));
Assert.Equal("10", e.eval("(* *o* 2)").ToString());
} }
[Fact] [Fact]
@ -226,6 +260,13 @@ namespace Tests
Assert.Equal("nil", e.eval("(all (lambda (x) (= 1 (% x 2))) (list 1 3 4 5))").ToString()); Assert.Equal("nil", e.eval("(all (lambda (x) (= 1 (% x 2))) (list 1 3 4 5))").ToString());
Assert.Equal("nil", e.eval("(all (lambda (x) (= x 2)) nil)").ToString()); Assert.Equal("nil", e.eval("(all (lambda (x) (= x 2)) nil)").ToString());
Assert.Equal("10", e.eval("(fold (lambda (a b) (+ a b)) 0 (list 1 2 3 4))").ToString()); Assert.Equal("10", e.eval("(fold (lambda (a b) (+ a b)) 0 (list 1 2 3 4))").ToString());
Assert.Equal("(2 3 4 5 6 7)", e.eval("(append (list 2 3 4) (list 5 6 7))").ToString());
Assert.Equal("(1 2 3 4 5 6 7)", e.eval("(qsort (lambda (a b) (> a b)) (list 5 4 7 3 2 6 1))").ToString());
//Assert.Equal("", e.eval("(rand)").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 > (list 5 4 7 3 2 6 1))").ToString());
} }
} }
} }

View file

@ -10,20 +10,20 @@ resources:
- name: source - name: source
type: git type: git
source: source:
uri: https://gitea.redxef.at/redxef/jellyfin-smart-playlist uri: https://git.redxef.at/redxef/jellyfin-smart-playlist
branch: main branch: main
fetch_tags: true fetch_tags: true
tag_regex: 'v.*' tag_regex: 'v.*'
- name: manifest - name: manifest
type: git type: git
source: source:
uri: ssh://git@gitea.redxef.at:8022/redxef/jellyfin-smart-playlist.git uri: ssh://git@git.redxef.at:8022/redxef/jellyfin-smart-playlist.git
branch: manifest branch: manifest
private_key: ((gitea.id_ed25519)) private_key: ((gitea.id_ed25519))
- name: releases - name: releases
type: http-resource type: http-resource
source: source:
url: https://gitea.redxef.at/api/v1/repos/redxef/jellyfin-smart-playlist/releases url: https://git.redxef.at/api/v1/repos/redxef/jellyfin-smart-playlist/releases
method: get method: get
auth: auth:
basic: basic:
@ -32,7 +32,7 @@ resources:
- name: artifact - name: artifact
type: http-resource type: http-resource
source: source:
url: https://gitea.redxef.at/api/v1/repos/redxef/jellyfin-smart-playlist/releases/((.:release_id))/assets url: https://git.redxef.at/api/v1/repos/redxef/jellyfin-smart-playlist/releases/((.:release_id))/assets
method: get method: get
auth: auth:
basic: basic:

View file

@ -25,3 +25,75 @@
(is-genre "electro" g) (is-genre "electro" g)
(is-genre "swing" g))) (is-genre "swing" g)))
``` ```
- `All of Seeed`: A playlist containing all items by artist `Seeed`,
useless, since you also can just navigate to the artist to play all
their songs, but a good example.
```
Id: Seeed
Name: Seeed
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"))))
```
or simplified with definitions contained in the preamble:
```
Id: Seeed
Name: Seeed
Program: |
(let
(parent
(find-parent "MediaBrowser.Controller.Entities.Audio.MusicArtist, MediaBrowser.Controller"))
(cond
((null parent) nil)
(t (string= (car (getitems parent "Name")) "Seeed"))))
```
- `Artists`: A playlist including everything from a list of artists.
```
Id: Artists
Name: Artists
Program: >-
(begin
(define *include-artists* '(
"seeed"
"juli"
"falco"
"peter fox"
"alligatoah"
"kraftklub"
"wanda"
"annennaykantereit"
"feine sahne fischfilet"
"madsen"
"wiener blond"
"die toten hosen"
"die ärzte"
"wir sind helden"
))
(define my-prefilter-only-tracks
(lambda nil
(and
(is-type "Audio")
(not (name-contains "live"))
(not (name-contains "acoustic"))
(not (name-contains "instrumental"))
(not (name-contains "remix")))))
(and
(my-prefilter-only-tracks)
(is-favourite)
(any
(lambda
(i)
;; the `(and (find-artist)` is here to prevent null violations.
(and (find-artist) (string= i (lower (get-name (find-artist))))))
*include-artists*)))
SortProgram: (begin (shuf *items*))
Filename: /config/data/smartplaylists/German.yaml
Enabled: true
```

129
lisp.md Normal file
View file

@ -0,0 +1,129 @@
# The lisp interpreter
The interpreter is a lisp-like language used to build the filter
expressions.
## Builtins
**atom**: check if a receive value is a atom. `(atom 1)`
**eq**: check if two values are equal. `(eq (quote a) (quote a))`
**car**: get the first item of the cons.
**cdr**: get the remainder of the list.
**cons**: create a new cons. `(cons 1 2)`
**begin**: evaluate a series of statements, returning the result of the
last one. `(begin t nil)`
**+-\*/%**: arithmetic operations for integers. `(+ 1 2)`
**=**, **!=**, **<**, **<=**, **>**, **>=**: compare two integers. `(> 1 2)`
**not**: negate the given value. `nil -> t`, everything else will be `nil`. `(not (quote a))`
**string=**, **string!=**, **string<**, **string<=**, **string>**, **string>=**: compare two strings. `(> "1" "2")`
**haskeys**: takes any object and a variadic number of arguments (strings) and
returns a list with either `t` or `nil` describing if a corresponding property/field/method
with that name exists on the object. `(haskeys mystring "Length")`
**getitems**: takes any object and a variadic number of arguments
(strings) and returns the values of the fields/properties. `(getitems mystring "Length" "Chars")`
**invoke**: takes 3 arguments and invokes the method defined on the
object.
The first argument is the object on which to invoke the method, the
second one is the name of the method and the third one is a list of
arguments to pass to the method. `(invoke mystring "Lower" nil)`
**invoke-generic**: the same as **invoke**, but takes a fourth
argument, a list of string describing the types for the generic method.
`(invoke-generic mybaseitem "FindParent" nil (list "MediaBrowser.Controller.Entities.Audio.MusicArtist, MediaBrowser.Controller"))`
**random**: gives a random integer. `(random)`
**shuffle**: shuffles a list. `(shuffle (list 1 2 3 4))`
**quote**: quotes a value. `(quote a)`
**eval**: evaluates a expression. `(eval (quote a))`
**cond**: checks conditions and evaluates the corresponding expression.
```
(cond
((> 1 2) t)
(t f))
```
**if**: a conditional. `(if t 1 2)`
**define**: defines a new symbol. `(define foo 1)`, `(define add (lambda (a b) (+ a b)))`
**let**: define variables in the let context and evaluate the last
expression. `(let (a 1) (b 2) (+ a b))`
**let\***: the same as **let**, but allows to reference variables
defined earlier in the let statement. `(let* (a 1) (b (+ 2 a)) (+ a b))`
**apply**: call a function with the specified arguments. `(apply + (list 1 2))`
**and**: evaluate the given expressions in order, if any one of them
evaluates to `nil` return early with that value, otherwise return the
last value. `(and 1 2 nil 3)`
**or**: return `nil` if all arguments evaluate to `nil` otherwise the
first non-nil value.
## Derived builtins
**null**: the same as **not**. Can be useful to indicate semantics of a
program.
**list**: create a list from the given arguments. `(list 1 2 3)`
**find**: find if an item is in the given list and return it, otherwise
return `nil`. `(find 4 (list 1 2 3 4 5 6 7))`
**map**: apply a function to every item in the list. `(map (lambda (x) (* 2 x)) (list 1 2 3))`
**fold**: also known as reduce. Apply the function to a sequence of
values, reducing the sequence to a single item. It takes a initial value
which is returned for empty lists. `(fold (lambda (a b) (+ a b)) 0 (list 1 2 3 4))`
**any**: equivalent to `(apply or (map function list))`.
`(any (lambda (a) (% a 2)) (list 2 4 6 7 8))`
**all**: equivalent to `(apply and (map function list))`.
`(all (lambda (a) (% a 2)) (list 2 4 6 7 8))`
**append**: append an item to the given list. `(append (list 1 2 3) 4)`
**qsort**: quicksort, takes a comparison function and the list.
`(qsort (lambda (a b) (> a b)) (list 1 2 6 4 9 1 19 0))`
**rand**: get a random integer. Takes either zero, one or two arguments.
If zero arguments are given it gives a random integer from all possibly
representable integers. If one argument is given it gives a integer
between `0` (inclusive) and `n` (exclusive). If two arguments are given
it gives a integer between `a` (inclusive) and `b` (exclusive).
**shuf**: same as **shuffle**.
## Pre-defined convenience functions
- **lower**: lowercases a string (`(eq (lower "SomeString") "somestring")`)
- **is-genre**: check if the item is of this genre, partial matches
allowed, the example filter would match the genre "Nu-Metal" (`(is-genre "metal" (genre-list))`)
- **is-genre-exact**: the same as `is-genre`, but does not match paritally
- **is-favorite**: matches a favorite item (`(is-favorite)`)
- **is-type**: matches the type of item look at
[BaseItemKind.cs](https://github.com/jellyfin/jellyfin/blob/master/Jellyfin.Data/Enums/BaseItemKind.cs)
for a list of items. The plugin has enabled support for `Audio, MusicAlbum, Playlist` (`(is-type "Audio")`)
- **logd**, **logi**, **logw**, **loge**: write to the logger with the respective levels (`debug`, `information`, `warning`, `error`).
Takes the same arguments as `Logger.LogInformation(...)`.
And many more.