Compare commits

...

10 commits

Author SHA1 Message Date
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
8 changed files with 134 additions and 21 deletions

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

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

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

@ -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.2.2.0
targetAbi: 10.10.0.0 targetAbi: 10.10.2.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,9 @@ artifacts:
- jellyfin-smart-playlist.dll - jellyfin-smart-playlist.dll
- YamlDotNet.dll - YamlDotNet.dll
changelog: | changelog: |
## 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`

View file

@ -5,12 +5,12 @@
<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.2.2.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.0" />
</ItemGroup> </ItemGroup>

View file

@ -5,7 +5,7 @@ Smart playlists with Lisp filter engine.
This readme contains instructions for the most recent changes in This readme contains instructions for the most recent changes in
the development branch (`main`). To view the file appropriate the development branch (`main`). To view the file appropriate
for your version select the tag corresponding to your version. for your version select the tag corresponding to your version.
The latest version is [v0.2.1.0](https://gitea.redxef.at/redxef/jellyfin-smart-playlist/src/tag/v0.2.0.0). The latest version is [v0.2.2.0](https://gitea.redxef.at/redxef/jellyfin-smart-playlist/src/tag/v0.2.2.0).
## How to use ## How to use
@ -29,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
``` ```
@ -84,7 +85,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,6 +102,23 @@ 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")`)

View file

@ -195,7 +195,7 @@ 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")""");
@ -226,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());
} }
} }
} }