Compare commits

..

12 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
9 changed files with 149 additions and 23 deletions

View file

@ -157,11 +157,35 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
return new List<Expression>();
}
}
public class String: Scalar<string> {
public class String: Scalar<string>, ISortable<String, Boolean> {
public String(string value) : base(value) {}
public override string? 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 Expression Item1;
@ -224,7 +248,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
}
public class Object : Scalar<object> {
public Object(object value) : base(value) { }
internal Object(object value) : base(value) {}
public static Expression FromBase(object? o) {
if (o == null) {
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["any"] = e.eval("(lambda (fc l) (apply or (map fc l)))");
this["all"] = e.eval("(lambda (fc l) (apply and (map fc l)))");
this["append"] = e.eval("(lambda (l i) (if (null l) i (cons (car l) (append (cdr l) i))))");
this["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) => _cmp((Integer a, Integer b) => a == b, 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((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["not"] = (x) => {
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;

View file

@ -35,7 +35,9 @@ namespace Jellyfin.Plugin.SmartPlaylist {
(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; }

View file

@ -1,3 +1,4 @@
using System.Globalization;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
using MediaBrowser.Controller;
@ -94,25 +95,42 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
}
private IEnumerable<Guid> FilterPlaylistItems(IEnumerable<BaseItem> items, User user, SmartPlaylistDto smartPlaylist) {
List<Guid> results = new List<Guid>();
Expression expression = new Parser(StringTokenStream.generate(smartPlaylist.Program)).parse();
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 = 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) {
executor.eval(Plugin.Instance.Configuration.InitialProgram);
} else {
throw new ApplicationException("Plugin Instance is not yet initialized");
}
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);
_logger.LogTrace("Item {0} evaluated to {1}", i, r.ToString());
if ((r is not Lisp_Boolean r_bool) || (r_bool.Value())) {
_logger.LogDebug("Added '{0}' to Smart Playlist {1}", i, smartPlaylist.Name);
results.Add(i.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) {
@ -161,7 +179,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
await ClearPlaylist(playlist);
await _playlistManager.AddItemToPlaylistAsync(playlist.Id, insertItems, playlistLink.UserId);
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("");
}
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]
public class SmartPlaylistDto : ISerializable {
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 SmartPlaylistLinkDto[] Playlists { get; set; }
public string Name { get; set; }
public string Program { get; set; }
public string SortProgram { get; set; }
public string? Filename { get; set; }
public bool Enabled { get; set; }
@ -54,6 +56,7 @@ namespace Jellyfin.Plugin.SmartPlaylist {
Playlists = [];
Name = Id.ToString();
Program = DEFAULT_PROGRAM;
SortProgram = DEFAULT_SORT_PROGRAM;
Filename = null;
Enabled = true;
}
@ -79,6 +82,11 @@ namespace Jellyfin.Plugin.SmartPlaylist {
} else {
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) {
Filename = _Filename;
} else {

View file

@ -1,7 +1,7 @@
name: Smart Playlist
guid: dd2326e3-4d3e-4bfc-80e6-28502c1131df
version: 0.2.1.0
targetAbi: 10.10.0.0
version: 0.3.0.0
targetAbi: 10.10.3.0
framework: net8.0
owner: redxef
overview: Smart playlists with Lisp filter engine.
@ -14,6 +14,20 @@ artifacts:
- jellyfin-smart-playlist.dll
- YamlDotNet.dll
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`
@ -30,7 +44,7 @@ changelog: |
- Add configuration page with some default definitions for
the filter expressions.
**Breaking Changes:**
**Breaking Changes**:
- The lisp interpreter will now only detect strings in double quotes (`"`).
- The interpreter will also not allow specifying lists without quoting them.
`(1 2 3)` ... used to work but will no longer, replace by either specifying

View file

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

View file

@ -5,7 +5,7 @@ Smart playlists with Lisp filter engine.
This readme contains instructions for the most recent changes in
the development branch (`main`). To view the file appropriate
for your version select the tag corresponding to your version.
The latest version is [v0.2.1.0](https://gitea.redxef.at/redxef/jellyfin-smart-playlist/src/tag/v0.2.0.0).
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
@ -29,6 +29,7 @@ Playlists:
UserId: 6eec632a-ff0d-4d09-aad0-bf9e90b14bc6
Name: Rock
Program: (begin (invoke item "IsFavoriteOrLiked" (user)))
SortProgram: (begin items)
Filename: /config/data/smartplaylists/Rock.yaml
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
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
@ -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: `(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
- **lower**: lowercases a string (`(eq (lower "SomeString") "somestring")`)

View file

@ -195,7 +195,7 @@ namespace Tests
public static void ObjectTest() {
Executor e = new Executor();
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")""");
Assert.True(((Lisp_Boolean)r).Value());
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) (= x 2)) nil)").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());
}
}
}