Compare commits

..

25 commits

Author SHA1 Message Date
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
0ccefa3b58
ci: prepare release for v0.2.1.0. 2024-11-11 18:17:32 +01:00
1f961ccb0c
chore: fix more warnings. 2024-11-11 18:00:55 +01:00
12d98c46cb
chore: fix warnings. 2024-11-11 17:53:47 +01:00
3c0d8a3809
feat: format default program for readability. 2024-11-11 14:50:09 +01:00
8f832ed224
fix!: make configuration program monospaced. 2024-11-11 14:43:19 +01:00
67cffd98ff
fix!: default program configuration.
also add new name-contains definition.
2024-11-11 14:17:18 +01:00
df2e07e519
fix: change initial program config to textarea. 2024-11-11 14:09:57 +01:00
74486640d8
docs: add instructions for releasing, so that I don't forget a step. 2024-11-08 22:58:43 +01:00
af63a8a696
docs: itemkind behavior and filtering warning, also extend examples. 2024-11-08 22:50:38 +01:00
f39633d7c5
feat: add is-type convenience definition. 2024-11-08 22:41:24 +01:00
b23587d721
docs: Fix typos in release notes. 2024-11-08 22:40:38 +01:00
7bf2923ad1
fix: allow all types to call "haskeys". 2024-11-08 22:39:56 +01:00
5cfb35a239
docs: update readme to include a notice for the correct release. 2024-11-08 22:38:46 +01:00
13 changed files with 263 additions and 47 deletions

View file

@ -126,6 +126,12 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
public static Boolean operator <=(Integer a, Integer b) { public static Boolean operator <=(Integer a, Integer b) {
return (a._value <= b._value) ? Boolean.TRUE : Boolean.FALSE; return (a._value <= b._value) ? Boolean.TRUE : Boolean.FALSE;
} }
public override int GetHashCode() {
return base.GetHashCode();
}
public override bool Equals(object? other) {
return base.Equals(other);
}
public static Boolean operator ==(Integer a, Integer b) { public static Boolean operator ==(Integer a, Integer b) {
return (a._value == b._value) ? Boolean.TRUE : Boolean.FALSE; return (a._value == b._value) ? Boolean.TRUE : Boolean.FALSE;
} }
@ -151,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;
@ -218,7 +248,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
} }
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

@ -7,7 +7,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
using FunctionLater = Func<Executor, IEnumerable<Expression>, Expression>; using FunctionLater = Func<Executor, IEnumerable<Expression>, Expression>;
public interface IEnvironment<K, V> { public interface IEnvironment<K, V> {
public V Get(K k); public V? Get(K k);
public void Set(K k, V v); public void Set(K k, V v);
public IEnvironment<K, V>? Find(K k); public IEnvironment<K, V>? Find(K k);
public IEnvironment<K, V> Parent(bool recursive); public IEnvironment<K, V> Parent(bool recursive);
@ -46,6 +46,38 @@ 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)))
"""
);
} }
} }
@ -95,16 +127,21 @@ 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;
@ -141,7 +178,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
return args.Last(); return args.Last();
} }
private static Expression _haskeys(IEnumerable<Expression> args) { private static Expression _haskeys(IEnumerable<Expression> args) {
Object o = (Object) args.First(); Object o = new Object(((IInner) args.First()).Inner());
foreach (var e in args.Skip(1)) { foreach (var e in args.Skip(1)) {
String s = (String) e; String s = (String) e;
PropertyInfo? pi = o.Value().GetType().GetProperty(s.Value()); PropertyInfo? pi = o.Value().GetType().GetProperty(s.Value());
@ -377,7 +414,11 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
if (_environment.Find(s.Name()) is not IEnvironment<string, Expression> env) { if (_environment.Find(s.Name()) is not IEnvironment<string, Expression> env) {
throw new ApplicationException($"Could not find '{s.Name()}'"); throw new ApplicationException($"Could not find '{s.Name()}'");
} }
return env.Get(s.Name()); var r_ = env.Get(s.Name());
if (r_ is null) {
throw new ApplicationException($"Could not find '{s.Name()}'");
}
return r_;
case Boolean b: case Boolean b:
return b; return b;
case Integer i: case Integer i:

View file

@ -1,5 +1,3 @@
using System;
using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Plugins;

View file

@ -5,14 +5,39 @@ namespace Jellyfin.Plugin.SmartPlaylist {
public PluginConfiguration() { public PluginConfiguration() {
InitialProgram = """ InitialProgram = """
(begin (begin
(define lower (lambda (s) (invoke s "ToLower" nil))) (define lower
(define is-genre (lambda (g g-list) (any (lambda (x) (invoke (lower x) "Contains" (list (lower g)))) g-list))) (lambda (s)
(define is-genre-exact (lambda (g g-list) (find g g-list))) (invoke s "ToLower" nil)))
(define genre-list (lambda nil (let (_g (getitems item "Genres")) (if (null _g) nil (car _g))))) (define is-genre
(define is-favorite (lambda nil (invoke item "IsFavoriteOrLiked" (list user))))) (lambda (g g-list)
(any
(lambda (x)
(invoke (lower x) "Contains" (list (lower g))))
g-list)))
(define is-genre-exact
(lambda (g g-list)
(find g g-list)))
(define genre-list
(lambda nil
(let
(_g (getitems item "Genres"))
(if (null _g)
nil
(car _g)))))
(define is-favorite
(lambda nil
(invoke item "IsFavoriteOrLiked" (list user))))
(define is-type
(lambda (x)
(and
(haskeys item "GetClientTypeName")
(invoke (invoke item "GetClientTypeName" nil) "Equals" (list x)))))
(define name-contains
(lambda (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))))
"""; """;
} }
public string InitialProgram { get; set; } public string InitialProgram { get; 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;
@ -94,25 +95,42 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
} }
private IEnumerable<Guid> FilterPlaylistItems(IEnumerable<BaseItem> items, User user, SmartPlaylistDto smartPlaylist) { private IEnumerable<Guid> FilterPlaylistItems(IEnumerable<BaseItem> items, User user, SmartPlaylistDto smartPlaylist) {
List<Guid> results = new List<Guid>(); List<BaseItem> results = new List<BaseItem>();
Expression expression = new Parser(StringTokenStream.generate(smartPlaylist.Program)).parse(); Expression expression = new Parser(StringTokenStream.generate(smartPlaylist.Program)).parse(); // parse here, so that we don't repeat the work for each item
Executor executor = new Executor(new DefaultEnvironment()); Executor executor = new Executor(new DefaultEnvironment());
executor.environment.Set("user", new Lisp_Object(user)); executor.environment.Set("user", Lisp_Object.FromBase(user));
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");
} }
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 = new Executor(new DefaultEnvironment());
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) {
@ -161,7 +179,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
await ClearPlaylist(playlist); await ClearPlaylist(playlist);
await _playlistManager.AddItemToPlaylistAsync(playlist.Id, insertItems, playlistLink.UserId); await _playlistManager.AddItemToPlaylistAsync(playlist.Id, insertItems, playlistLink.UserId);
i += 1; i += 1;
progress.Report(((double)i)/dto.Playlists.Count()); progress.Report(100 * ((double)i)/dto.Playlists.Count());
} }
} }
} }
@ -172,7 +190,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

@ -42,10 +42,12 @@ 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

@ -14,7 +14,7 @@ namespace Jellyfin.Plugin.SmartPlaylist {
_fileSystem = fileSystem; _fileSystem = fileSystem;
} }
private async Task<SmartPlaylistDto> LoadPlaylistAsync(string filename) { private async Task<SmartPlaylistDto> LoadPlaylistAsync(string filename) {
var r = File.ReadAllText(filename); var r = await File.ReadAllTextAsync(filename);
if (r.Equals("")) { if (r.Equals("")) {
r = "{}"; r = "{}";
} }
@ -46,7 +46,7 @@ namespace Jellyfin.Plugin.SmartPlaylist {
public async Task SaveSmartPlaylistAsync(SmartPlaylistDto smartPlaylist) { public async Task SaveSmartPlaylistAsync(SmartPlaylistDto smartPlaylist) {
string filename = _fileSystem.GetSmartPlaylistFilePath(smartPlaylist.Id); string filename = _fileSystem.GetSmartPlaylistFilePath(smartPlaylist.Id);
var text = new SerializerBuilder().Build().Serialize(smartPlaylist); var text = new SerializerBuilder().Build().Serialize(smartPlaylist);
File.WriteAllText(filename, text); await File.WriteAllTextAsync(filename, text);
} }
private void DeleteSmartPlaylistById(SmartPlaylistId smartPlaylistId) { private void DeleteSmartPlaylistById(SmartPlaylistId smartPlaylistId) {
try { try {

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.0.0 version: 0.3.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,29 @@ artifacts:
- jellyfin-smart-playlist.dll - jellyfin-smart-playlist.dll
- YamlDotNet.dll - YamlDotNet.dll
changelog: | changelog: |
## 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 ## v0.2.0.0
- Switch to yaml loading, old json files are still accepted - Switch to yaml loading, old json files are still accepted
- Rework lisp interpreter to be more conventional - Rework lisp interpreter to be more conventional
@ -21,11 +44,11 @@ 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 no 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
the list as `(list 1 2 3)` or (quote (1 2 3)). the list as `(list 1 2 3)` or `(quote (1 2 3))`.
## v0.1.1.0 ## v0.1.1.0
- Initial Alpha release. - Initial Alpha release.

View file

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Template</title> <title>SmartPlaylist</title>
</head> </head>
<body> <body>
<div id="SmartPlaylistConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox"> <div id="SmartPlaylistConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
@ -12,7 +12,7 @@
<div class="inputContainer"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="InitialProgram">Initial Program</label> <label class="inputLabel inputLabelUnfocused" for="InitialProgram">Initial Program</label>
<div class="fieldDescription">A program which can set up the environment</div> <div class="fieldDescription">A program which can set up the environment</div>
<input id="InitialProgram" name="InitialProgram" type="text" is="emby-input" /> <textarea id="InitialProgram" class="emby-input smartplaylist-monospace" name="InitialProgram" rows="16" cols="120"></textarea>
</div> </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">
@ -22,6 +22,11 @@
</form> </form>
</div> </div>
</div> </div>
<style>
.smartplaylist-monospace {
font-family: monospace;
}
</style>
<script type="text/javascript"> <script type="text/javascript">
var SmartPlaylistConfig = { var SmartPlaylistConfig = {
pluginUniqueId: 'dd2326e3-4d3e-4bfc-80e6-28502c1131df' pluginUniqueId: 'dd2326e3-4d3e-4bfc-80e6-28502c1131df'

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.0.0</Version> <Version>0.3.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.1.3" /> <PackageReference Include="YamlDotNet" Version="16.2.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -2,6 +2,11 @@
Smart playlists with Lisp filter engine. 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.3.0.0](https://gitea.redxef.at/redxef/jellyfin-smart-playlist/src/tag/v0.3.0.0).
## How to use ## How to use
After [installing](#installation) the plugin and restarting Jellyfin After [installing](#installation) the plugin and restarting Jellyfin
@ -24,6 +29,7 @@ Playlists:
UserId: 6eec632a-ff0d-4d09-aad0-bf9e90b14bc6 UserId: 6eec632a-ff0d-4d09-aad0-bf9e90b14bc6
Name: Rock Name: Rock
Program: (begin (invoke item "IsFavoriteOrLiked" (user))) Program: (begin (invoke item "IsFavoriteOrLiked" (user)))
SortProgram: (begin items)
Filename: /config/data/smartplaylists/Rock.yaml Filename: /config/data/smartplaylists/Rock.yaml
Enabled: true Enabled: true
``` ```
@ -79,12 +85,40 @@ 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
not specify the kind of item to include/exclude all of them will be
added. Should you allow a playlist to be included all of it's items
will be added to the generated playlist !!!**
It's best to be explicit and always specify the item kinds you want to
include: `(and (or (is-type "MusicAlbum") (is-type "Audio")) . rest of filter)`.
The configuration page defines some useful functions to make it easier 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)`.
### SortProgram
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:
```lisp
(qsort
(lambda
(a b)
(string>
(car (getitems a "Name"))
(car (getitems b "Name"))))
items)
```
#### Available definitions #### Available definitions
- **lower**: lowercases a string (`(eq (lower "SomeString") "somestring")`) - **lower**: lowercases a string (`(eq (lower "SomeString") "somestring")`)
@ -92,6 +126,9 @@ to: `(is-favourite)`.
allowed, the example filter would match the genre "Nu-Metal" (`(is-genre "metal" (genre-list))`) 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-genre-exact**: the same as `is-genre`, but does not match paritally
- **is-favorite**: matches a favorite item (`(is-favorite)`) - **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 ### Filename
@ -111,3 +148,17 @@ to Jellyfin:
Go to `Dashboard>Catalog>(Gear)>(Plus)` and paste the provided link into Go to `Dashboard>Catalog>(Gear)>(Plus)` and paste the provided link into
the field labeled `Repository URL`, give the plugin a descriptive name the field labeled `Repository URL`, give the plugin a descriptive name
too. too.
## Releasing a new version
1. Write the changelog: `git log --oneline $prev_version..`
2. Update the following files to include up-to-date version numbers
and changelogs, if applicable:
- `README.md`
- `Jellyfin.Plugin.SmartPlaylist/build.yaml`
- `Jellyfin.Plugin.SmartPlaylist/jellyfin-smart-playlist.csproj`
Don't forget to also bump the ABI version of Jellyfin.
3. Push the changes
4. Create a new release with the changelog, mark as pre-release if
applicable.
5. Done! The build pipeline will do the rest.

View file

@ -1,5 +1,3 @@
using Xunit;
using Lisp_Environment = Jellyfin.Plugin.SmartPlaylist.Lisp.Environment;
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 Jellyfin.Plugin.SmartPlaylist.Lisp; using Jellyfin.Plugin.SmartPlaylist.Lisp;
@ -197,13 +195,13 @@ 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.Equal(((Lisp_Boolean)r).Value(), true); Assert.True(((Lisp_Boolean)r).Value());
r = e.eval("""(getitems o "i" "b")"""); r = e.eval("""(getitems o "i" "b")""");
Assert.Equal(string.Format("{0}", r), "(5 nil)"); Assert.Equal("(5 nil)", string.Format("{0}", r));
r = e.eval("""(invoke o "I" nil)"""); r = e.eval("""(invoke o "I" nil)""");
Assert.Equal(string.Format("{0}", r), "5"); Assert.Equal("5", string.Format("{0}", r));
} }
[Fact] [Fact]
@ -228,6 +226,8 @@ 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());
} }
} }
} }

View file

@ -1,10 +1,27 @@
# Examples # Examples
* `Favourite Pop`: A Playlist - `Favourite Pop`: A playlist
containing all favourite items of the genre pop. containing all favourite items of the genre pop.
``` ```
Id: Favourite Pop Id: Favourite Pop
Name: Favourite Pop Name: Favourite Pop
Program: | Program: |
(and (is-favorite) (is-genre "pop" (genre-list))) (and (is-type "Audio") (is-favorite) (is-genre "pop" (genre-list)))
```
- `Electro Swing`: A playlist containing all items
which have a genre that contains "electro" and a
genre that contains "swing". It will only include
albums and single tracks.
```
Id: Electro Swing
Name: Electro Swing
Program: |
(let
(g (genre-list))
(and
(or
(is-type "Audio")
(is-type "MusicAlbum"))
(is-genre "electro" g)
(is-genre "swing" g)))
``` ```