Compare commits

..

No commits in common. "main" and "v0.2.0.0" have entirely different histories.

13 changed files with 47 additions and 250 deletions

View file

@ -126,12 +126,6 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
public static Boolean operator <=(Integer a, Integer b) {
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) {
return (a._value == b._value) ? Boolean.TRUE : Boolean.FALSE;
}
@ -157,35 +151,11 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
return new List<Expression>();
}
}
public class String: Scalar<string>, ISortable<String, Boolean> {
public class String: Scalar<string> {
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;
@ -248,7 +218,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
}
public class Object : Scalar<object> {
internal Object(object value) : base(value) {}
public Object(object value) : base(value) { }
public static Expression FromBase(object? o) {
if (o == null) {
return Boolean.FALSE;

View file

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

View file

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

View file

@ -5,37 +5,14 @@ namespace Jellyfin.Plugin.SmartPlaylist {
public PluginConfiguration() {
InitialProgram = """
(begin
(define lower
(lambda (s)
(invoke s "ToLower" nil)))
(define is-genre
(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 lower (lambda (s) (invoke s "ToLower" nil)))
(define is-genre (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-favourite is-favorite)
""";
}
public string InitialProgram { get; set; }

View file

@ -1,4 +1,3 @@
using System.Globalization;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
using MediaBrowser.Controller;
@ -95,42 +94,25 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
}
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
List<Guid> results = new List<Guid>();
Expression expression = new Parser(StringTokenStream.generate(smartPlaylist.Program)).parse();
Executor executor = new Executor(new DefaultEnvironment());
executor.environment.Set("user", Lisp_Object.FromBase(user));
executor.environment.Set("user", new Lisp_Object(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", Lisp_Object.FromBase(i));
executor.environment.Set("item", new Lisp_Object(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);
results.Add(i.Id);
}
}
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);
return results;
}
private IEnumerable<BaseItem> GetAllUserMedia(User user) {
@ -179,7 +161,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
await ClearPlaylist(playlist);
await _playlistManager.AddItemToPlaylistAsync(playlist.Id, insertItems, playlistLink.UserId);
i += 1;
progress.Report(100 * ((double)i)/dto.Playlists.Count());
progress.Report(((double)i)/dto.Playlists.Count());
}
}
}
@ -190,7 +172,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.ItemId?.ToString("N", CultureInfo.InvariantCulture)));
await _playlistManager.RemoveItemFromPlaylistAsync(playlist.Id.ToString(), existingItems.Select(x => x.Item1.Id));
}
}
}

View file

@ -42,12 +42,10 @@ 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; }
@ -56,7 +54,6 @@ namespace Jellyfin.Plugin.SmartPlaylist {
Playlists = [];
Name = Id.ToString();
Program = DEFAULT_PROGRAM;
SortProgram = DEFAULT_SORT_PROGRAM;
Filename = null;
Enabled = true;
}
@ -82,11 +79,6 @@ 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

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

View file

@ -1,7 +1,7 @@
name: Smart Playlist
guid: dd2326e3-4d3e-4bfc-80e6-28502c1131df
version: 0.2.2.0
targetAbi: 10.10.2.0
version: 0.2.0.0
targetAbi: 10.10.0.0
framework: net8.0
owner: redxef
overview: Smart playlists with Lisp filter engine.
@ -14,18 +14,6 @@ artifacts:
- jellyfin-smart-playlist.dll
- YamlDotNet.dll
changelog: |
## v0.2.2.0
- Update Jellyfin to v 10.10.2
## v0.2.1.0
- Make default program configuration a textarea in the settings page
- Add convinience definitions: `is-type`, `name-contains`
- Update YamlDotNet to v 16.2.0
**Fixes**:
- The default program was malformed, a closing bracket was at the wrong position
- The `haskeys` function could only be called on Objects
## v0.2.0.0
- Switch to yaml loading, old json files are still accepted
- Rework lisp interpreter to be more conventional
@ -34,10 +22,10 @@ changelog: |
the filter expressions.
**Breaking Changes:**
- The lisp interpreter will now only detect strings in double quotes (`"`).
- The lisp interpreter will no only detect strings in double quotes (`"`).
- The interpreter will also not allow specifying lists without quoting them.
`(1 2 3)` ... used to work but will no longer, replace by either specifying
the list as `(list 1 2 3)` or `(quote (1 2 3))`.
the list as `(list 1 2 3)` or (quote (1 2 3)).
## v0.1.1.0
- Initial Alpha release.

View file

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

View file

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

View file

@ -2,11 +2,6 @@
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.2.0](https://gitea.redxef.at/redxef/jellyfin-smart-playlist/src/tag/v0.2.2.0).
## How to use
After [installing](#installation) the plugin and restarting Jellyfin
@ -29,7 +24,6 @@ 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
```
@ -85,40 +79,12 @@ 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. 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)`.
to include them.
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")`)
@ -126,9 +92,6 @@ the items by name you could use the following program:
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
@ -148,17 +111,3 @@ to Jellyfin:
Go to `Dashboard>Catalog>(Gear)>(Plus)` and paste the provided link into
the field labeled `Repository URL`, give the plugin a descriptive name
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,3 +1,5 @@
using Xunit;
using Lisp_Environment = Jellyfin.Plugin.SmartPlaylist.Lisp.Environment;
using Lisp_Boolean = Jellyfin.Plugin.SmartPlaylist.Lisp.Boolean;
using Lisp_Object = Jellyfin.Plugin.SmartPlaylist.Lisp.Object;
using Jellyfin.Plugin.SmartPlaylist.Lisp;
@ -195,13 +197,13 @@ namespace Tests
public static void ObjectTest() {
Executor e = new Executor();
Expression r;
e.environment.Set("o", Lisp_Object.FromBase(new O(5, false)));
e.environment.Set("o", new Lisp_Object(new O(5, false)));
r = e.eval("""(haskeys o "i" "b")""");
Assert.True(((Lisp_Boolean)r).Value());
Assert.Equal(((Lisp_Boolean)r).Value(), true);
r = e.eval("""(getitems o "i" "b")""");
Assert.Equal("(5 nil)", string.Format("{0}", r));
Assert.Equal(string.Format("{0}", r), "(5 nil)");
r = e.eval("""(invoke o "I" nil)""");
Assert.Equal("5", string.Format("{0}", r));
Assert.Equal(string.Format("{0}", r), "5");
}
[Fact]
@ -226,8 +228,6 @@ 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());
}
}
}

View file

@ -1,27 +1,10 @@
# Examples
- `Favourite Pop`: A playlist
* `Favourite Pop`: A Playlist
containing all favourite items of the genre pop.
```
Id: Favourite Pop
Name: Favourite Pop
Program: |
(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)))
(and (is-favorite) (is-genre "pop" (genre-list)))
```