Compare commits
16 commits
Author | SHA1 | Date | |
---|---|---|---|
5944686d1d | |||
7a590ade7e | |||
f3a6b4a225 | |||
2624b83a79 | |||
1d472ed61f | |||
9bb68f6fac | |||
a00b01c577 | |||
b35df95d86 | |||
54a50ef04b | |||
b0593585df | |||
4db98eb1c8 | |||
80803cd048 | |||
571895bef8 | |||
2c6972cf86 | |||
feaf1b0e31 | |||
64ae51ba71 |
21 changed files with 256 additions and 80 deletions
|
@ -1,15 +1,26 @@
|
||||||
using System.Net.Mime;
|
using System.Net.Mime;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Web;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using MediaBrowser.Common.Api;
|
using MediaBrowser.Common.Api;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
|
||||||
|
using YamlDotNet.Serialization;
|
||||||
|
|
||||||
using Jellyfin.Plugin.SmartPlaylist.Lisp;
|
using Jellyfin.Plugin.SmartPlaylist.Lisp;
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.SmartPlaylist.Api {
|
namespace Jellyfin.Plugin.SmartPlaylist.Api {
|
||||||
|
|
||||||
|
[Serializable]
|
||||||
|
public class ProgramInputDto {
|
||||||
|
public string InputLinks { get; set; }
|
||||||
|
public string Program { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
[Serializable]
|
[Serializable]
|
||||||
public class ProgramOutputDto {
|
public class ProgramOutputDto {
|
||||||
public string Output { get; set; }
|
public string Output { get; set; }
|
||||||
|
@ -23,16 +34,21 @@ namespace Jellyfin.Plugin.SmartPlaylist.Api {
|
||||||
[Produces(MediaTypeNames.Application.Json)]
|
[Produces(MediaTypeNames.Application.Json)]
|
||||||
public class LispPlaygroundController : ControllerBase {
|
public class LispPlaygroundController : ControllerBase {
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
|
||||||
public LispPlaygroundController(
|
public LispPlaygroundController(
|
||||||
ILogger<LispPlaygroundController> logger
|
ILogger<LispPlaygroundController> logger,
|
||||||
|
ILibraryManager libraryManager
|
||||||
) {
|
) {
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Executor SetupExecutor(StringBuilder sb) {
|
private Executor SetupExecutor(StringBuilder sb, IList<string> inputs) {
|
||||||
var env = new DefaultEnvironment();
|
var env = new DefaultEnvironment();
|
||||||
var executor = new Executor(env);
|
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) => {
|
executor.builtins["logd"] = (x) => {
|
||||||
_logger.LogDebug(((Lisp.String)x.First()).Value(), x.Skip(1).ToArray());
|
_logger.LogDebug(((Lisp.String)x.First()).Value(), x.Skip(1).ToArray());
|
||||||
return Lisp.Boolean.TRUE;
|
return Lisp.Boolean.TRUE;
|
||||||
|
@ -58,7 +74,7 @@ namespace Jellyfin.Plugin.SmartPlaylist.Api {
|
||||||
})));
|
})));
|
||||||
return Lisp.Boolean.TRUE;
|
return Lisp.Boolean.TRUE;
|
||||||
};
|
};
|
||||||
executor.builtins["print"] = (x) => {
|
executor.builtins["println"] = (x) => {
|
||||||
sb.Append(string.Join(" ", x.Select((i) => {
|
sb.Append(string.Join(" ", x.Select((i) => {
|
||||||
if (i is Lisp.String i_s) {
|
if (i is Lisp.String i_s) {
|
||||||
return i_s.Value();
|
return i_s.Value();
|
||||||
|
@ -76,17 +92,32 @@ namespace Jellyfin.Plugin.SmartPlaylist.Api {
|
||||||
return executor;
|
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]
|
[HttpPost]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<ProgramOutputDto>> SetPlaylist() {
|
public async Task<ActionResult<ProgramOutputDto>> SetPlaylist() {
|
||||||
try {
|
try {
|
||||||
string program;
|
string input;
|
||||||
using (StreamReader reader = new StreamReader(Request.Body)) {
|
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();
|
StringBuilder output = new StringBuilder();
|
||||||
var e = SetupExecutor(output);
|
var e = SetupExecutor(output, extractItemIds(dto.InputLinks));
|
||||||
var r = e.eval(program).ToString();
|
var r = e.eval(dto.Program).ToString();
|
||||||
return Ok(new ProgramOutputDto() {
|
return Ok(new ProgramOutputDto() {
|
||||||
FinalExpression = r,
|
FinalExpression = r,
|
||||||
Output = output.ToString(),
|
Output = output.ToString(),
|
||||||
|
|
|
@ -58,5 +58,13 @@ namespace Jellyfin.Plugin.SmartPlaylist.Api {
|
||||||
await _store.SaveSmartCollectionAsync(smartCollection);
|
await _store.SaveSmartCollectionAsync(smartCollection);
|
||||||
return Created();
|
return Created();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete]
|
||||||
|
[Route("{smartCollectionId}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
public async Task<ActionResult> DeleteCollection([FromRoute, Required] SmartCollectionId smartCollectionId) {
|
||||||
|
_store.DeleteSmartCollectionById(smartCollectionId);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -57,5 +57,13 @@ namespace Jellyfin.Plugin.SmartPlaylist.Api {
|
||||||
await _store.SaveSmartPlaylistAsync(smartPlaylist);
|
await _store.SaveSmartPlaylistAsync(smartPlaylist);
|
||||||
return Created();
|
return Created();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete]
|
||||||
|
[Route("{smartPlaylistId}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
public async Task<ActionResult> DeletePlaylist([FromRoute, Required] SmartPlaylistId smartPlaylistId) {
|
||||||
|
_store.DeleteSmartPlaylistById(smartPlaylistId);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ using Jellyfin.Plugin.SmartPlaylist.Lisp;
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
|
namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
|
||||||
public class Parser {
|
public class Parser {
|
||||||
private StringTokenStream _sts;
|
internal StringTokenStream _sts;
|
||||||
public Parser(StringTokenStream tokens) {
|
public Parser(StringTokenStream tokens) {
|
||||||
_sts = tokens;
|
_sts = tokens;
|
||||||
}
|
}
|
||||||
|
@ -29,13 +29,18 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
|
||||||
Debug.Assert(start.value == end.value);
|
Debug.Assert(start.value == end.value);
|
||||||
Debug.Assert("\"".Contains(start.value));
|
Debug.Assert("\"".Contains(start.value));
|
||||||
string r = "";
|
string r = "";
|
||||||
|
bool exit_ok = false;
|
||||||
while (_sts.Available() > 0) {
|
while (_sts.Available() > 0) {
|
||||||
Token<string> t = _sts.Get();
|
Token<string> t = _sts.Get();
|
||||||
if (t.value == end.value) {
|
if (t.value == end.value) {
|
||||||
|
exit_ok = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
r += t.value;
|
r += t.value;
|
||||||
}
|
}
|
||||||
|
if (!exit_ok) {
|
||||||
|
throw new ApplicationException($"Failed to parse string, are you missing the closing quotes? String is: {r}");
|
||||||
|
}
|
||||||
_sts.Commit();
|
_sts.Commit();
|
||||||
return new String(r);
|
return new String(r);
|
||||||
}
|
}
|
||||||
|
@ -69,11 +74,16 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
|
||||||
if (";".Contains(start.value)) {
|
if (";".Contains(start.value)) {
|
||||||
return parse_comment(start, end);
|
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>();
|
IList<Expression> expressions = new List<Expression>();
|
||||||
|
bool exit_ok = false;
|
||||||
while (_sts.Available() > 0) {
|
while (_sts.Available() > 0) {
|
||||||
Token<string> t = _sts.Get();
|
Token<string> t = _sts.Get();
|
||||||
if (t.value == end.value) {
|
if (t.value == end.value) {
|
||||||
|
exit_ok = true;
|
||||||
_sts.Commit();
|
_sts.Commit();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -87,7 +97,11 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp.Compiler {
|
||||||
_sts.Rewind(1);
|
_sts.Rewind(1);
|
||||||
expressions.Add(parse());
|
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) {
|
Expression parse_atom(AtomToken at) {
|
||||||
|
|
|
@ -90,25 +90,50 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
|
||||||
(lambda
|
(lambda
|
||||||
(list0)
|
(list0)
|
||||||
(car 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
|
(split
|
||||||
(lambda
|
(lambda
|
||||||
(list0 pivot fc h0 h1)
|
(list0 pivot fc h0 h1 heq)
|
||||||
(cond
|
(cond
|
||||||
((null list0) (list list0 pivot fc h0 h1))
|
((null list0) (list list0 pivot fc h0 h1 heq))
|
||||||
((fc (car list0) pivot) (split (cdr list0) pivot fc h0 (cons (car list0) h1)))
|
((and
|
||||||
(t (split (cdr list0) pivot fc (cons (car list0) h0) h1)))))
|
(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
|
(sort
|
||||||
(lambda
|
(lambda
|
||||||
(fc list0)
|
(fc list0)
|
||||||
(cond
|
(begin t (cond
|
||||||
((null list0) nil)
|
((null list0) nil)
|
||||||
((null (cdr list0)) list0)
|
((null (cdr list0)) list0)
|
||||||
(t
|
(t
|
||||||
(let*
|
(let*
|
||||||
(halves (split list0 (getpivot list0) fc nil nil))
|
(halves (split list0 (getpivot list0) fc nil nil nil))
|
||||||
(h0 (car (cdr (cdr (cdr halves)))))
|
(h0 (car (cdr (cdr (cdr halves)))))
|
||||||
(h1 (car (cdr (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)))
|
(sort fc list00)))
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
@ -576,7 +601,11 @@ namespace Jellyfin.Plugin.SmartPlaylist.Lisp {
|
||||||
throw new ApplicationException($"Not handled case '{expression}'");
|
throw new ApplicationException($"Not handled case '{expression}'");
|
||||||
}
|
}
|
||||||
public Expression eval(Parser p) {
|
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) {
|
public Expression eval(StringTokenStream sts) {
|
||||||
return eval(new Parser(sts));
|
return eval(new Parser(sts));
|
||||||
|
|
|
@ -9,20 +9,34 @@
|
||||||
<div data-role="content">
|
<div data-role="content">
|
||||||
<div class="content-primary">
|
<div class="content-primary">
|
||||||
<form id="LispPlaygroundConfigForm">
|
<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">
|
<div class="inputContainer">
|
||||||
<label class="inputLabel inputLabelUnfocused" for="LispPlaygroundEditProgram">Program</label>
|
<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>
|
<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>
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
<label class="inputLabel inputLabelUnfocused" for="LispPlaygroundOutput">Output</label>
|
<label class="inputLabel inputLabelUnfocused" for="LispPlaygroundOutput">Output</label>
|
||||||
<div class="fieldDescription">The output of the program.</div>
|
<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>
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
<label class="inputLabel inputLabelUnfocused" for="LispPlaygroundReturn">Final expression</label>
|
<label class="inputLabel inputLabelUnfocused" for="LispPlaygroundReturn">Final expression</label>
|
||||||
<div class="fieldDescription">The final expression the program has been reduced to.</div>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||||
|
@ -33,7 +47,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<style>
|
<style>
|
||||||
.smartcollection-monospace {
|
.smartplayground-monospace {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -6,14 +6,17 @@ function fillForm(o) {
|
||||||
return_.value = (o.hasOwnProperty("FinalExpression")) ? o.FinalExpression : '';
|
return_.value = (o.hasOwnProperty("FinalExpression")) ? o.FinalExpression : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiClient.runLispProgram = function (program) {
|
ApiClient.runLispProgram = function (inputLinks, program) {
|
||||||
const url = ApiClient.getUrl('LispPlayground');
|
const url = ApiClient.getUrl('LispPlayground');
|
||||||
return this.ajax({
|
return this.ajax({
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
url: url,
|
url: url,
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
contentType: 'text/plain; charset=UTF-8',
|
contentType: 'application/json; charset=UTF-8',
|
||||||
data: program,
|
data: JSON.stringify({
|
||||||
|
InputLinks: inputLinks,
|
||||||
|
Program: program,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function initial_load() {
|
function initial_load() {
|
||||||
|
@ -37,8 +40,9 @@ export default function (view, params) {
|
||||||
.addEventListener('submit', function (e) {
|
.addEventListener('submit', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
Dashboard.showLoadingMsg();
|
Dashboard.showLoadingMsg();
|
||||||
|
const editInputLinks = document.querySelector('#LispPlaygroundInputLinks');
|
||||||
const editProgram = document.querySelector('#LispPlaygroundEditProgram');
|
const editProgram = document.querySelector('#LispPlaygroundEditProgram');
|
||||||
ApiClient.runLispProgram(editProgram.value).then(function (r) {
|
ApiClient.runLispProgram(editInputLinks.value, editProgram.value).then(function (r) {
|
||||||
fillForm(r);
|
fillForm(r);
|
||||||
Dashboard.hideLoadingMsg();
|
Dashboard.hideLoadingMsg();
|
||||||
});
|
});
|
||||||
|
|
|
@ -37,6 +37,9 @@
|
||||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||||
<span>Save</span>
|
<span>Save</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button is="emby-button" type="reset" class="raised button-reset block emby-button">
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -73,6 +73,15 @@ ApiClient.setSmartCollection = function (c) {
|
||||||
data: JSON.stringify(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) {
|
function initial_load(selectedId) {
|
||||||
Dashboard.showLoadingMsg();
|
Dashboard.showLoadingMsg();
|
||||||
ApiClient.getSmartCollections().then(function (collections) {
|
ApiClient.getSmartCollections().then(function (collections) {
|
||||||
|
@ -112,4 +121,14 @@ export default function (view, params) {
|
||||||
});
|
});
|
||||||
e.preventDefault();
|
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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,6 +43,9 @@
|
||||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||||
<span>Save</span>
|
<span>Save</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button is="emby-button" type="reset" class="raised button-reset block emby-button">
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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) {
|
function initial_load(selectedId) {
|
||||||
Dashboard.showLoadingMsg();
|
Dashboard.showLoadingMsg();
|
||||||
ApiClient.getSmartPlaylists().then(function (playlists) {
|
ApiClient.getSmartPlaylists().then(function (playlists) {
|
||||||
|
@ -139,4 +147,14 @@ export default function (view, params) {
|
||||||
});
|
});
|
||||||
e.preventDefault();
|
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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,39 +77,5 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
|
||||||
}
|
}
|
||||||
return executor;
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,10 @@ using MediaBrowser.Controller.Providers;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.Entities.Movies;
|
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 {
|
namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
|
||||||
public class GenerateCollection: Common, IScheduledTask {
|
public class GenerateCollection: Common, IScheduledTask {
|
||||||
|
@ -61,6 +64,47 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
|
||||||
)).Id;
|
)).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() {
|
private IEnumerable<BaseItem> GetAllMedia() {
|
||||||
var req = new InternalItemsQuery() {
|
var req = new InternalItemsQuery() {
|
||||||
IncludeItemTypes = AvailableFilterItems,
|
IncludeItemTypes = AvailableFilterItems,
|
||||||
|
|
|
@ -78,12 +78,18 @@ namespace Jellyfin.Plugin.SmartPlaylist.ScheduledTasks {
|
||||||
executor.environment.Set("*user*", Lisp.Object.FromBase(user));
|
executor.environment.Set("*user*", Lisp.Object.FromBase(user));
|
||||||
foreach (var i in items) {
|
foreach (var i in items) {
|
||||||
executor.environment.Set("*item*", Lisp.Object.FromBase(i));
|
executor.environment.Set("*item*", Lisp.Object.FromBase(i));
|
||||||
|
try {
|
||||||
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);
|
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 = SetupExecutor();
|
||||||
executor.environment.Set("*user*", Lisp.Object.FromBase(user));
|
executor.environment.Set("*user*", Lisp.Object.FromBase(user));
|
||||||
|
|
|
@ -56,6 +56,9 @@ namespace Jellyfin.Plugin.SmartPlaylist.Util {
|
||||||
public IStream<T> Copy() {
|
public IStream<T> Copy() {
|
||||||
return new Stream<T>(_items, _cursor, _ephemeralCursor);
|
return new Stream<T>(_items, _cursor, _ephemeralCursor);
|
||||||
}
|
}
|
||||||
|
public IList<T> Remainder() {
|
||||||
|
return _items.Skip(_cursor).ToList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
name: Smart Playlist
|
name: Smart Playlist
|
||||||
guid: dd2326e3-4d3e-4bfc-80e6-28502c1131df
|
guid: dd2326e3-4d3e-4bfc-80e6-28502c1131df
|
||||||
version: 0.5.2.0
|
version: 0.5.3.0
|
||||||
targetAbi: 10.10.5.0
|
targetAbi: 10.10.6.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,8 +14,9 @@ artifacts:
|
||||||
- jellyfin-smart-playlist.dll
|
- jellyfin-smart-playlist.dll
|
||||||
- YamlDotNet.dll
|
- YamlDotNet.dll
|
||||||
changelog: |
|
changelog: |
|
||||||
## v0.5.2.0
|
## v0.5.3.0
|
||||||
- bump Jellyfin ABI version to 10.10.5
|
- bump Jellyfin ABI version to 10.10.6
|
||||||
|
- bump yamldotnet to 16.3.0
|
||||||
|
|
||||||
**Fixes**:
|
**Fixes**:
|
||||||
- the config pages will always load, not only the first time
|
- qsort doesn't loop endlessly with duplicate values in import anymore
|
||||||
|
|
|
@ -5,13 +5,13 @@
|
||||||
<RootNamespace>Jellyfin.Plugin.SmartPlaylist</RootNamespace>
|
<RootNamespace>Jellyfin.Plugin.SmartPlaylist</RootNamespace>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<Version>0.5.2.0</Version>
|
<Version>0.5.3.0</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Jellyfin.Controller" Version="10.10.5" />
|
<PackageReference Include="Jellyfin.Controller" Version="10.10.6" />
|
||||||
<PackageReference Include="Jellyfin.Model" Version="10.10.5" />
|
<PackageReference Include="Jellyfin.Model" Version="10.10.6" />
|
||||||
<PackageReference Include="YamlDotNet" Version="16.2.1" />
|
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
@ -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.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).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
|
@ -267,6 +267,10 @@ namespace Tests
|
||||||
//Assert.Equal("", e.eval("(shuf (list 0 1 2 3 4 5 6))").ToString());
|
//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 (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("(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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,7 @@ resources:
|
||||||
basic:
|
basic:
|
||||||
username: ((gitea.username))
|
username: ((gitea.username))
|
||||||
password: ((gitea.token))
|
password: ((gitea.token))
|
||||||
|
version_check_method: none
|
||||||
- name: artifact
|
- name: artifact
|
||||||
type: http-resource
|
type: http-resource
|
||||||
source:
|
source:
|
||||||
|
|
10
examples.md
10
examples.md
|
@ -26,19 +26,19 @@
|
||||||
(is-genre "swing" g)))
|
(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
|
useless, since you also can just navigate to the artist to play all
|
||||||
their songs, but a good example.
|
their songs, but a good example.
|
||||||
```
|
```
|
||||||
Id: Seeed
|
Id: Haller
|
||||||
Name: Seeed
|
Name: Haller
|
||||||
Program: |
|
Program: |
|
||||||
(let
|
(let
|
||||||
(parent
|
(parent
|
||||||
(invoke-generic *item* "FindParent" nil (list "MediaBrowser.Controller.Entities.Audio.MusicArtist, MediaBrowser.Controller")))
|
(invoke-generic *item* "FindParent" nil (list "MediaBrowser.Controller.Entities.Audio.MusicArtist, MediaBrowser.Controller")))
|
||||||
(cond
|
(cond
|
||||||
((null parent) nil)
|
((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:
|
or simplified with definitions contained in the preamble:
|
||||||
```
|
```
|
||||||
|
@ -91,7 +91,7 @@
|
||||||
(lambda
|
(lambda
|
||||||
(i)
|
(i)
|
||||||
;; the `(and (find-artist)` is here to prevent null violations.
|
;; 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*)))
|
*include-artists*)))
|
||||||
SortProgram: (begin (shuf *items*))
|
SortProgram: (begin (shuf *items*))
|
||||||
Filename: /config/data/smartplaylists/German.yaml
|
Filename: /config/data/smartplaylists/German.yaml
|
||||||
|
|
Loading…
Add table
Reference in a new issue