Compare commits

...

11 commits

16 changed files with 208 additions and 63 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

@ -601,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

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