Compare commits

...

16 commits

Author SHA1 Message Date
5944686d1d
feat: allow deleting via the ui. 2025-03-09 20:44:23 +01:00
7a590ade7e
feat: allow deleting of smart playlists. 2025-03-09 20:27:45 +01:00
f3a6b4a225
fix: print no longer prints newline, but added println. 2025-03-02 18:36:38 +01:00
2624b83a79
fix: make the parser more robust and no longer accept invalid input silently. 2025-03-02 18:35:36 +01:00
1d472ed61f
fix: make input smaller than program window.
Refs: #6
2025-02-26 23:46:15 +01:00
9bb68f6fac
docs: improve description.
Refs: #6
2025-02-26 23:44:25 +01:00
a00b01c577
feat: allow specifying items as inputs for the lisp playground.
Refs: #6
2025-02-26 23:36:52 +01:00
b35df95d86
feat: also log failing item id. 2025-02-26 22:50:40 +01:00
54a50ef04b
feat: add logging for crashed programs, don't crash the whole process. 2025-02-26 22:43:57 +01:00
b0593585df
fix: move FilterCollectionItems from Common.cs to GenerateCollection.cs.
It had no place being in common.
2025-02-26 22:31:18 +01:00
4db98eb1c8
docs: lowercase all artists in artist examples. 2025-02-24 23:04:13 +01:00
80803cd048
fix: pipeline version check for releases list. 2025-02-18 18:52:47 +01:00
571895bef8
fix: version in build.yaml. 2025-02-18 18:51:14 +01:00
2c6972cf86
ci: prepare for release. 2025-02-18 18:38:58 +01:00
feaf1b0e31
chore: bump jellyfin and yamldotnet versions. 2025-02-18 18:38:13 +01:00
64ae51ba71
fix: qsort ran into an infinite loop when encountering duplicate values. 2025-01-26 22:38:52 +01:00
21 changed files with 256 additions and 80 deletions

View file

@ -1,15 +1,26 @@
using System.Net.Mime;
using System.Text;
using System.Web;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using System.ComponentModel.DataAnnotations;
using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Library;
using YamlDotNet.Serialization;
using Jellyfin.Plugin.SmartPlaylist.Lisp;
namespace Jellyfin.Plugin.SmartPlaylist.Api {
[Serializable]
public class ProgramInputDto {
public string InputLinks { get; set; }
public string Program { get; set; }
}
[Serializable]
public class ProgramOutputDto {
public string Output { get; set; }
@ -23,16 +34,21 @@ namespace Jellyfin.Plugin.SmartPlaylist.Api {
[Produces(MediaTypeNames.Application.Json)]
public class LispPlaygroundController : ControllerBase {
private readonly ILogger _logger;
private readonly ILibraryManager _libraryManager;
public LispPlaygroundController(
ILogger<LispPlaygroundController> logger
ILogger<LispPlaygroundController> logger,
ILibraryManager libraryManager
) {
_logger = logger;
_libraryManager = libraryManager;
}
private Executor SetupExecutor(StringBuilder sb) {
private Executor SetupExecutor(StringBuilder sb, IList<string> inputs) {
var env = new DefaultEnvironment();
var executor = new Executor(env);
env["*items*"] = Lisp.Cons.FromList(inputs.Select(x => _libraryManager.GetItemById(x)).Select(x => Lisp.Object.FromBase(x)).ToList());
executor.builtins["logd"] = (x) => {
_logger.LogDebug(((Lisp.String)x.First()).Value(), x.Skip(1).ToArray());
return Lisp.Boolean.TRUE;
@ -58,7 +74,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.Api {
})));
return Lisp.Boolean.TRUE;
};
executor.builtins["print"] = (x) => {
executor.builtins["println"] = (x) => {
sb.Append(string.Join(" ", x.Select((i) => {
if (i is Lisp.String i_s) {
return i_s.Value();
@ -76,17 +92,32 @@ namespace Jellyfin.Plugin.SmartPlaylist.Api {
return executor;
}
private List<string> extractItemIds(string s) {
List<string> r = new List<string>();
foreach (string line in s.Split("\n".ToCharArray(), StringSplitOptions.RemoveEmptyEntries)) {
var uri = new Uri(line);
uri = new Uri("http://some-domain.tld" + uri.Fragment.Substring(1));
var id = HttpUtility.ParseQueryString(uri.Query).Get("id");
if (id == null) {
continue;
}
r.Add(id);
}
return r;
}
[HttpPost]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<ProgramOutputDto>> SetPlaylist() {
try {
string program;
string input;
using (StreamReader reader = new StreamReader(Request.Body)) {
program = await reader.ReadToEndAsync();
input = await reader.ReadToEndAsync();
}
var dto = new DeserializerBuilder().Build().Deserialize<ProgramInputDto>(input);
StringBuilder output = new StringBuilder();
var e = SetupExecutor(output);
var r = e.eval(program).ToString();
var e = SetupExecutor(output, extractItemIds(dto.InputLinks));
var r = e.eval(dto.Program).ToString();
return Ok(new ProgramOutputDto() {
FinalExpression = r,
Output = output.ToString(),

View file

@ -58,5 +58,13 @@ namespace Jellyfin.Plugin.SmartPlaylist.Api {
await _store.SaveSmartCollectionAsync(smartCollection);
return Created();
}
[HttpDelete]
[Route("{smartCollectionId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> DeleteCollection([FromRoute, Required] SmartCollectionId smartCollectionId) {
_store.DeleteSmartCollectionById(smartCollectionId);
return NoContent();
}
}
}

View file

@ -57,5 +57,13 @@ namespace Jellyfin.Plugin.SmartPlaylist.Api {
await _store.SaveSmartPlaylistAsync(smartPlaylist);
return Created();
}
[HttpDelete]
[Route("{smartPlaylistId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> DeletePlaylist([FromRoute, Required] SmartPlaylistId smartPlaylistId) {
_store.DeleteSmartPlaylistById(smartPlaylistId);
return NoContent();
}
}
}

View file

@ -3,7 +3,7 @@ using Jellyfin.Plugin.SmartPlaylist.Lisp;
namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
public class Parser {
private StringTokenStream _sts;
internal StringTokenStream _sts;
public Parser(StringTokenStream tokens) {
_sts = tokens;
}
@ -29,13 +29,18 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
Debug.Assert(start.value == end.value);
Debug.Assert("\"".Contains(start.value));
string r = "";
bool exit_ok = false;
while (_sts.Available() > 0) {
Token<string> t = _sts.Get();
if (t.value == end.value) {
exit_ok = true;
break;
}
r += t.value;
}
if (!exit_ok) {
throw new ApplicationException($"Failed to parse string, are you missing the closing quotes? String is: {r}");
}
_sts.Commit();
return new String(r);
}
@ -69,11 +74,16 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
if (";".Contains(start.value)) {
return parse_comment(start, end);
}
Debug.Assert(end != null);
if (end == null) {
throw new ApplicationException($"Don't know how to parse grouping starting with token '{start.value}'");
}
IList<Expression> expressions = new List<Expression>();
bool exit_ok = false;
while (_sts.Available() > 0) {
Token<string> t = _sts.Get();
if (t.value == end.value) {
exit_ok = true;
_sts.Commit();
break;
}
@ -87,7 +97,11 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
_sts.Rewind(1);
expressions.Add(parse());
}
return Cons.FromList(expressions);
var r = Cons.FromList(expressions);
if (!exit_ok) {
throw new ApplicationException($"Failed to parse grouping, are you missing some closing braces? Parsed expressions: {r}");
}
return r;
}
Expression parse_atom(AtomToken at) {

View file

@ -90,25 +90,50 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
(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)))))
(split
(lambda
(list0 pivot fc h0 h1)
(list0 pivot fc h0 h1 heq)
(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)))))
((null list0) (list list0 pivot fc h0 h1 heq))
((and
(fc (car list0) pivot)
(fc pivot (car list0))) (split (cdr list0) pivot fc h0 h1 (cons (car list0) heq)))
((fc (car list0) pivot) (split (cdr list0) pivot fc h0 (cons (car list0) h1) heq))
((fc pivot (car list0)) (split (cdr list0) pivot fc (cons (car list0) h0) h1 heq))
((= (car list0) pivot) (split (cdr list0) pivot fc h0 h1 (cons (car list0) heq)))
(t (split (cdr list0) pivot fc h0 h1 heq)))))
;(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
(lambda
(fc list0)
(cond
(begin t (cond
((null list0) nil)
((null (cdr list0)) list0)
(t
(let*
(halves (split list0 (getpivot list0) fc nil nil))
(halves (split list0 (getpivot list0) fc nil nil nil))
(h0 (car (cdr (cdr (cdr halves)))))
(h1 (car (cdr (cdr (cdr (cdr halves))))))
(append (sort fc h0) (sort fc h1)))))))
(heq (car (cdr (cdr (cdr (cdr (cdr halves)))))))
(append (append (sort fc h0) heq) (sort fc h1))))))))
(sort fc list00)))
"""
);
@ -576,7 +601,11 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
throw new ApplicationException($"Not handled case '{expression}'");
}
public Expression eval(Parser p) {
return eval(p.parse());
Expression r = eval(p.parse());
if (p._sts.Available() > 0) {
throw new ApplicationException($"Did not consume all tokens, remaining program is {string.Join(" ", p._sts.Remainder())}");
}
return r;
}
public Expression eval(StringTokenStream sts) {
return eval(new Parser(sts));

View file

@ -9,20 +9,34 @@
<div data-role="content">
<div class="content-primary">
<form id="LispPlaygroundConfigForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="LispPlaygroundInputLinks">Input Items</label>
<div class="fieldDescription">
Items which should be given to the filter program, one link per line.
This list is provided to the program in the
<code>*items*</code> variable.<br>
Hint: Just copy the link to the media item,
it should contain a <code>id</code> in
the query part of the URL's
fragment.<br>
Example:
<code>https://jellyfin.example.com/web/#/details?id=9f63ee779963588ed2ad94d37d6abe4e&serverId=dc0683d7a413473eb8246d21eaea2f99</code><br>
<textarea id="LispPlaygroundInputLinks" class="emby-input smartplayground-monospace" name="InputLinks" rows="6" cols="120"></textarea>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="LispPlaygroundEditProgram">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="LispPlaygroundEditProgram" class="emby-input smartcollection-monospace" name="Program" rows="16" cols="120"></textarea>
<textarea id="LispPlaygroundEditProgram" class="emby-input smartplayground-monospace" name="Program" rows="16" cols="120"></textarea>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="LispPlaygroundOutput">Output</label>
<div class="fieldDescription">The output of the program.</div>
<textarea id="LispPlaygroundOutput" class="emby-input smartcollection-monospace" name="Output" rows="16" cols="120"></textarea>
<textarea id="LispPlaygroundOutput" class="emby-input smartplayground-monospace" name="Output" rows="16" cols="120"></textarea>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="LispPlaygroundReturn">Final expression</label>
<div class="fieldDescription">The final expression the program has been reduced to.</div>
<textarea id="LispPlaygroundReturn" class="emby-input smartcollection-monospace" name="Output" rows="1" cols="120"></textarea>
<textarea id="LispPlaygroundReturn" class="emby-input smartplayground-monospace" name="Output" rows="1" cols="120"></textarea>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
@ -33,7 +47,7 @@
</div>
</div>
<style>
.smartcollection-monospace {
.smartplayground-monospace {
font-family: monospace;
}
</style>

View file

@ -6,14 +6,17 @@ function fillForm(o) {
return_.value = (o.hasOwnProperty("FinalExpression")) ? o.FinalExpression : '';
}
ApiClient.runLispProgram = function (program) {
ApiClient.runLispProgram = function (inputLinks, program) {
const url = ApiClient.getUrl('LispPlayground');
return this.ajax({
type: 'POST',
url: url,
dataType: 'json',
contentType: 'text/plain; charset=UTF-8',
data: program,
contentType: 'application/json; charset=UTF-8',
data: JSON.stringify({
InputLinks: inputLinks,
Program: program,
}),
});
}
function initial_load() {
@ -37,8 +40,9 @@ export default function (view, params) {
.addEventListener('submit', function (e) {
e.preventDefault();
Dashboard.showLoadingMsg();
const editInputLinks = document.querySelector('#LispPlaygroundInputLinks');
const editProgram = document.querySelector('#LispPlaygroundEditProgram');
ApiClient.runLispProgram(editProgram.value).then(function (r) {
ApiClient.runLispProgram(editInputLinks.value, editProgram.value).then(function (r) {
fillForm(r);
Dashboard.hideLoadingMsg();
});

View file

@ -37,6 +37,9 @@
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
<button is="emby-button" type="reset" class="raised button-reset block emby-button">
<span>Delete</span>
</button>
</div>
</form>
</div>

View file

@ -73,6 +73,15 @@ ApiClient.setSmartCollection = function (c) {
data: JSON.stringify(c),
});
}
ApiClient.deleteSmartCollection = function (id) {
const url = ApiClient.getUrl(`SmartCollection/${id}`)
return this.ajax({
type: 'DELETE',
url: url,
});
}
function initial_load(selectedId) {
Dashboard.showLoadingMsg();
ApiClient.getSmartCollections().then(function (collections) {
@ -112,4 +121,14 @@ export default function (view, params) {
});
e.preventDefault();
});
document.querySelector('#SmartCollectionConfigForm')
.addEventListener('reset', function (e) {
Dashboard.showLoadingMsg();
const selection = document.querySelector('#SmartcollectionSelection');
const selectedId = COLLECTIONS[selection.selectedIndex].Id;
ApiClient.deleteSmartCollection(selectedId).then(function () {
initial_load(null);
});
e.preventDefault();
});
}

View file

@ -43,6 +43,9 @@
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
<button is="emby-button" type="reset" class="raised button-reset block emby-button">
<span>Delete</span>
</button>
</div>
</form>
</div>

View file

@ -97,6 +97,14 @@ ApiClient.setSmartPlaylist = function (p) {
});
}
ApiClient.deleteSmartPlaylist = function (id) {
const url = ApiClient.getUrl(`SmartPlaylist/${id}`)
return this.ajax({
type: 'DELETE',
url: url,
});
}
function initial_load(selectedId) {
Dashboard.showLoadingMsg();
ApiClient.getSmartPlaylists().then(function (playlists) {
@ -139,4 +147,14 @@ export default function (view, params) {
});
e.preventDefault();
});
document.querySelector('#SmartPlaylistConfigForm')
.addEventListener('reset', function (e) {
Dashboard.showLoadingMsg();
const selection = document.querySelector('#SmartplaylistSelection');
const selectedId = PLAYLISTS[selection.selectedIndex].Id;
ApiClient.deleteSmartPlaylist(selectedId).then(function () {
initial_load(null);
});
e.preventDefault();
});
}

View file

@ -77,39 +77,5 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
}
return executor;
}
public IEnumerable<Guid> FilterCollectionItems(IEnumerable<BaseItem> items, User? user, string name, string program, string sortProgram) {
List<BaseItem> results = new List<BaseItem>();
Expression expression = new Parser(StringTokenStream.generate(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) {
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 Collection {1}", i, name);
results.Add(i);
}
}
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(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);
}
}
}

View file

@ -7,7 +7,10 @@ using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using Jellyfin.Data.Entities;
using Jellyfin.Plugin.SmartPlaylist.Lisp;
using Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler;
namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
public class GenerateCollection: Common, IScheduledTask {
@ -61,6 +64,47 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
)).Id;
}
public IEnumerable<Guid> FilterCollectionItems(IEnumerable<BaseItem> items, User? user, string name, string program, string sortProgram) {
List<BaseItem> results = new List<BaseItem>();
Expression expression = new Parser(StringTokenStream.generate(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) {
executor.environment.Set("*item*", Lisp.Object.FromBase(i));
try {
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 Collection {1}", i, name);
results.Add(i);
}
} catch (Exception e) {
_logger.LogError("Program crashed on item {0}:\n{1}", i.Id, expression.ToString());
_logger.LogError("Environment:\n{0}", executor.environment);
_logger.LogError("Traceback:\n{0}", e.ToString());
}
}
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(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> GetAllMedia() {
var req = new InternalItemsQuery() {
IncludeItemTypes = AvailableFilterItems,

View file

@ -78,11 +78,17 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
executor.environment.Set("*user*", Lisp.Object.FromBase(user));
foreach (var i in items) {
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);
try {
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);
}
} catch (Exception e) {
_logger.LogError("Program crashed on item {0}:\n{1}", i.Id, expression.ToString());
_logger.LogError("Environment:\n{0}", executor.environment);
_logger.LogError("Traceback:\n{0}", e.ToString());
}
}
executor = SetupExecutor();

View file

@ -56,6 +56,9 @@ namespace Jellyfin.Plugin.SmartPlaylist.Util {
public IStream<T> Copy() {
return new Stream<T>(_items, _cursor, _ephemeralCursor);
}
public IList<T> Remainder() {
return _items.Skip(_cursor).ToList();
}
}
}

View file

@ -1,7 +1,7 @@
name: Smart Playlist
guid: dd2326e3-4d3e-4bfc-80e6-28502c1131df
version: 0.5.2.0
targetAbi: 10.10.5.0
version: 0.5.3.0
targetAbi: 10.10.6.0
framework: net8.0
owner: redxef
overview: Smart playlists with Lisp filter engine.
@ -14,8 +14,9 @@ artifacts:
- jellyfin-smart-playlist.dll
- YamlDotNet.dll
changelog: |
## v0.5.2.0
- bump Jellyfin ABI version to 10.10.5
## v0.5.3.0
- bump Jellyfin ABI version to 10.10.6
- bump yamldotnet to 16.3.0
**Fixes**:
- the config pages will always load, not only the first time
- qsort doesn't loop endlessly with duplicate values in import anymore

View file

@ -5,13 +5,13 @@
<RootNamespace>Jellyfin.Plugin.SmartPlaylist</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>0.5.2.0</Version>
<Version>0.5.3.0</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.10.5" />
<PackageReference Include="Jellyfin.Model" Version="10.10.5" />
<PackageReference Include="YamlDotNet" Version="16.2.1" />
<PackageReference Include="Jellyfin.Controller" Version="10.10.6" />
<PackageReference Include="Jellyfin.Model" Version="10.10.6" />
<PackageReference Include="YamlDotNet" Version="16.3.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
the development branch (`main`). To view the file appropriate
for your version select the tag corresponding to your version.
The latest version is [v0.5.2.0](https://gitea.redxef.at/redxef/jellyfin-smart-playlist/src/tag/v0.5.2.0).
The latest version is [v0.5.3.0](https://gitea.redxef.at/redxef/jellyfin-smart-playlist/src/tag/v0.5.3.0).
![configuration page](config.png)

View file

@ -267,6 +267,10 @@ namespace Tests
//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());
Assert.Equal("(0 1 2 4 5 5)", e.eval("(qsort > '(2 4 1 5 5 0))").ToString());
Assert.Equal("(5 5 4 2 1 0)", e.eval("(qsort < '(2 4 1 5 5 0))").ToString());
Assert.Equal("(5 5 4 2 1 0)", e.eval("(qsort <= '(2 4 1 5 5 0))").ToString());
Assert.Equal("(0 1 2 4 5 5)", e.eval("(qsort >= '(2 4 1 5 5 0))").ToString());
}
}
}

View file

@ -29,6 +29,7 @@ resources:
basic:
username: ((gitea.username))
password: ((gitea.token))
version_check_method: none
- name: artifact
type: http-resource
source:

View file

@ -26,19 +26,19 @@
(is-genre "swing" g)))
```
- `All of Seeed`: A playlist containing all items by artist `Seeed`,
- `All of Haller`: A playlist containing all items by artist `Haller`,
useless, since you also can just navigate to the artist to play all
their songs, but a good example.
```
Id: Seeed
Name: Seeed
Id: Haller
Name: Haller
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"))))
(t (string= (lower (car (getitems parent "Name"))) (lower "Haller")))))
```
or simplified with definitions contained in the preamble:
```
@ -91,7 +91,7 @@
(lambda
(i)
;; the `(and (find-artist)` is here to prevent null violations.
(and (find-artist) (string= i (lower (get-name (find-artist))))))
(and (find-artist) (string= (lower i) (lower (get-name (find-artist))))))
*include-artists*)))
SortProgram: (begin (shuf *items*))
Filename: /config/data/smartplaylists/German.yaml