liquidsoap-prettier 1.8.2 → 1.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.github/workflows/check-formatting.yml +32 -0
  2. package/README.md +31 -5
  3. package/package.json +1 -1
  4. package/src/cli.js +104 -9
  5. package/tests/liq/audio.liq +460 -0
  6. package/tests/liq/autocue.liq +1081 -0
  7. package/tests/liq/clock.liq +14 -0
  8. package/tests/liq/cron.liq +74 -0
  9. package/tests/liq/error.liq +48 -0
  10. package/tests/liq/extra/audio.liq +677 -0
  11. package/tests/liq/extra/audioscrobbler.liq +482 -0
  12. package/tests/liq/extra/deprecations.liq +976 -0
  13. package/tests/liq/extra/externals.liq +196 -0
  14. package/tests/liq/extra/fades.liq +260 -0
  15. package/tests/liq/extra/file.liq +66 -0
  16. package/tests/liq/extra/http.liq +160 -0
  17. package/tests/liq/extra/interactive.liq +917 -0
  18. package/tests/liq/extra/metadata.liq +75 -0
  19. package/tests/liq/extra/native.liq +201 -0
  20. package/tests/liq/extra/openai.liq +150 -0
  21. package/tests/liq/extra/server.liq +177 -0
  22. package/tests/liq/extra/source.liq +476 -0
  23. package/tests/liq/extra/spinitron.liq +272 -0
  24. package/tests/liq/extra/telnet.liq +266 -0
  25. package/tests/liq/extra/video.liq +59 -0
  26. package/tests/liq/extra/visualization.liq +68 -0
  27. package/tests/liq/fades.liq +941 -0
  28. package/tests/liq/ffmpeg.liq +605 -0
  29. package/tests/liq/file.liq +387 -0
  30. package/tests/liq/getter.liq +74 -0
  31. package/tests/liq/hls.liq +329 -0
  32. package/tests/liq/http.liq +1048 -0
  33. package/tests/liq/http_codes.liq +447 -0
  34. package/tests/liq/icecast.liq +58 -0
  35. package/tests/liq/io.liq +106 -0
  36. package/tests/liq/liquidsoap.liq +31 -0
  37. package/tests/liq/list.liq +440 -0
  38. package/tests/liq/log.liq +47 -0
  39. package/tests/liq/lufs.liq +295 -0
  40. package/tests/liq/math.liq +23 -0
  41. package/tests/liq/medialib.liq +752 -0
  42. package/tests/liq/metadata.liq +253 -0
  43. package/tests/liq/nfo.liq +258 -0
  44. package/tests/liq/null.liq +71 -0
  45. package/tests/liq/playlist.liq +1347 -0
  46. package/tests/liq/predicate.liq +106 -0
  47. package/tests/liq/process.liq +93 -0
  48. package/tests/liq/profiler.liq +5 -0
  49. package/tests/liq/protocols.liq +1139 -0
  50. package/tests/liq/ref.liq +28 -0
  51. package/tests/liq/replaygain.liq +135 -0
  52. package/tests/liq/request.liq +467 -0
  53. package/tests/liq/resolvers.liq +33 -0
  54. package/tests/liq/runtime.liq +70 -0
  55. package/tests/liq/server.liq +99 -0
  56. package/tests/liq/settings.liq +41 -0
  57. package/tests/liq/socket.liq +33 -0
  58. package/tests/liq/source.liq +362 -0
  59. package/tests/liq/sqlite.liq +161 -0
  60. package/tests/liq/stdlib.liq +172 -0
  61. package/tests/liq/string.liq +476 -0
  62. package/tests/liq/switches.liq +197 -0
  63. package/tests/liq/testing.liq +37 -0
  64. package/tests/liq/thread.liq +161 -0
  65. package/tests/liq/tracks.liq +100 -0
  66. package/tests/liq/utils.liq +81 -0
  67. package/tests/liq/video.liq +918 -0
@@ -0,0 +1,1347 @@
1
+ let settings.playlist.mime_types =
2
+ settings.make.void(
3
+ "Mime-types used for guessing playlist formats."
4
+ )
5
+
6
+ let playlist.parse.cue = ()
7
+
8
+ # Parse a cue file
9
+ # @category Liquidsoap
10
+ # @param ~pwd Path to use for relative path resolution
11
+ def playlist.parse.cue.full(~pwd=null, content) =
12
+ content = string.split(separator="[\r\n]+", content)
13
+
14
+ def parse_file(s) =
15
+ matches = r/^FILE (.+)$/.exec(string.trim(s))
16
+ try
17
+ match = list.assoc(1, matches)
18
+ match_chars = string.chars(match)
19
+
20
+ def rec get_last(~char, cur, chars) =
21
+ let [...chars, last] = chars
22
+ if
23
+ last == char
24
+ then
25
+ filename = string.concat(separator="", chars)
26
+ file_type = string.trim(string.concat(separator="", cur))
27
+ file_type =
28
+ if
29
+ file_type == ""
30
+ then
31
+ null
32
+ else
33
+ string.case(lower=true, file_type)
34
+ end
35
+ (filename, file_type)
36
+ else
37
+ get_last(char=char, [last, ...cur], chars)
38
+ end
39
+ end
40
+
41
+ let (filename, file_type) =
42
+ if
43
+ list.hd(match_chars) == '"'
44
+ then
45
+ let [_, ...chars] = match_chars
46
+ let (filename, file_type) = get_last(char='"', [], chars)
47
+ (string.unquote('"#{filename}"'), file_type)
48
+ else
49
+ get_last(
50
+ char=" ",
51
+ [],
52
+ match_chars
53
+ )
54
+ end
55
+
56
+ filename = playlist.parse.get_file(pwd=pwd, filename)
57
+ {filename = filename, file_type = file_type}
58
+ catch _ do
59
+ null
60
+ end
61
+ end
62
+
63
+ def parse_track_attribute(s) =
64
+ matches = r/^TRACK ([^\s]+)\s([^\s]+)?$/.exec(string.trim(s))
65
+ try
66
+ position = int_of_string(list.assoc(1, matches))
67
+ type = list.assoc(2, matches)
68
+
69
+ {position = position, track_type = string.case(lower=true, type)}
70
+ catch _ : [error.not_found] do
71
+ null
72
+ end
73
+ end
74
+
75
+ def parse_rem(s) =
76
+ matches = r/^REM ([^\s]+) (.+)$/.exec(string.trim(s))
77
+ try
78
+ name = string.case(lower=true, list.assoc(1, matches))
79
+ value = string.unquote(list.assoc(2, matches))
80
+ (name, value)
81
+ catch _ : [error.not_found] do
82
+ null
83
+ end
84
+ end
85
+
86
+ def parse_timecode(s) =
87
+ matches = r/^([\d]+):([\d]+):([\d]+)/.exec(string.trim(s))
88
+ try
89
+ minutes = int_of_string(list.assoc(1, matches))
90
+ seconds = int_of_string(list.assoc(2, matches))
91
+ frames = int_of_string(list.assoc(3, matches))
92
+
93
+ {minutes = minutes, seconds = seconds, frames = frames}
94
+ catch _ : [error.not_found] do
95
+ null
96
+ end
97
+ end
98
+
99
+ def parse_index(s) =
100
+ matches = r/^INDEX ([\d]+) ([\d:]+)/.exec(string.trim(s))
101
+ try
102
+ index = int_of_string(list.assoc(1, matches))
103
+ timecode = list.assoc(2, matches)
104
+
105
+ (index, null.get(parse_timecode(timecode)))
106
+ catch _ do
107
+ null
108
+ end
109
+ end
110
+
111
+ def parse_optional(~label, s) =
112
+ matches =
113
+ regexp(
114
+ "^#{string.case(lower=false, label)} (.+)$"
115
+ ).exec(string.trim(s))
116
+ null.map(string.unquote, list.assoc.nullable(1, matches))
117
+ end
118
+
119
+ def end_parse_track(content) =
120
+ content == [] or null.defined(parse_track_attribute(list.hd(content)))
121
+ end
122
+
123
+ def rec parse_track(~file, ~track, content) =
124
+ track =
125
+ (track :
126
+ {
127
+ position: int,
128
+ track_type?: string,
129
+ performer?: string,
130
+ title?: string,
131
+ album?: string,
132
+ isrc?: string,
133
+ postgap?: {minutes: int, seconds: int, frames: int},
134
+ pregap?: {minutes: int, seconds: int, frames: int},
135
+ indexes: [
136
+ (
137
+ int
138
+ *
139
+ {
140
+ filename?: string,
141
+ file_type?: string,
142
+ minutes: int,
143
+ seconds: int,
144
+ frames: int
145
+ }
146
+ )
147
+ ]
148
+ }
149
+ )
150
+
151
+ if
152
+ end_parse_track(content)
153
+ then
154
+ (file, track, content)
155
+ else
156
+ let [s, ...content] = content
157
+
158
+ index = parse_index(s)
159
+ new_file = parse_file(s)
160
+
161
+ let (file, track) =
162
+ if
163
+ null.defined(index)
164
+ then
165
+ let (idx, timecode) = null.get(index)
166
+ (
167
+ file,
168
+ {
169
+ ...track,
170
+ indexes = [...track.indexes, (idx, {...file, ...timecode})]
171
+ }
172
+ )
173
+ elsif
174
+ null.defined(new_file)
175
+ then
176
+ (new_file, track)
177
+ else
178
+ track_attributes =
179
+ [
180
+ ("title", fun (title) -> {...track, title = title}),
181
+ (
182
+ "performer",
183
+ fun (performer) -> {...track, performer = performer}
184
+ ),
185
+ (
186
+ "pregap",
187
+ fun (pregap) ->
188
+ {...track, pregap = null.get(parse_timecode(pregap))}
189
+ ),
190
+ (
191
+ "postgap",
192
+ fun (postgap) ->
193
+ {...track, postgap = null.get(parse_timecode(postgap))}
194
+ ),
195
+ (
196
+ "rem",
197
+ fun (rem) ->
198
+ begin
199
+ parsed =
200
+ parse_rem(
201
+ "REM #{rem}"
202
+ )
203
+ if
204
+ null.defined(parsed)
205
+ then
206
+ let (label, value) = null.get(parsed)
207
+ if
208
+ label == "album"
209
+ then
210
+ {...track, album = value}
211
+ else
212
+ log.important(
213
+ label="playlist.parse.cue.full",
214
+ "Unknown track attribute REM #{rem}, please file a bug \
215
+ report!"
216
+ )
217
+ track
218
+ end
219
+ end
220
+ end
221
+ ),
222
+ ("isrc", fun (isrc) -> {...track, isrc = isrc})
223
+ ]
224
+ attribute_names = list.map(fst, track_attributes)
225
+
226
+ def rec check_attribute(attribute_names) =
227
+ if
228
+ attribute_names == []
229
+ then
230
+ log.important(
231
+ label="playlist.parse.cue.full",
232
+ "Could not parse track attribute: #{s}"
233
+ )
234
+ track
235
+ else
236
+ let [name, ...attribute_names] = attribute_names
237
+ let fn = list.assoc(name, track_attributes)
238
+ parsed = parse_optional(label=name, s)
239
+
240
+ if
241
+ null.defined(parsed)
242
+ then
243
+ fn(null.get(parsed))
244
+ else
245
+ check_attribute(attribute_names)
246
+ end
247
+ end
248
+ end
249
+
250
+ (file, check_attribute(attribute_names))
251
+ end
252
+ parse_track(file=file, track=track, content)
253
+ end
254
+ end
255
+
256
+ def rec parse_content(~file, ~sheet, content) =
257
+ sheet =
258
+ (sheet :
259
+ {
260
+ catalog?: string,
261
+ performer?: string,
262
+ title?: string,
263
+ rem: [(string * string)],
264
+ tracks: [
265
+ {
266
+ position: int,
267
+ track_type?: string,
268
+ performer?: string,
269
+ title?: string,
270
+ album?: string,
271
+ isrc?: string,
272
+ postgap?: {minutes: int, seconds: int, frames: int},
273
+ pregap?: {minutes: int, seconds: int, frames: int},
274
+ indexes: [
275
+ (
276
+ int
277
+ *
278
+ {
279
+ filename?: string,
280
+ file_type?: string,
281
+ minutes: int,
282
+ seconds: int,
283
+ frames: int
284
+ }
285
+ )
286
+ ]
287
+ }
288
+ ]
289
+ }
290
+ )
291
+
292
+ if
293
+ content == []
294
+ then
295
+ sheet
296
+ else
297
+ let [s, ...content] = content
298
+
299
+ new_file = parse_file(s)
300
+ new_rem = parse_rem(s)
301
+ track = parse_track_attribute(s)
302
+
303
+ if
304
+ null.defined(new_file)
305
+ then
306
+ parse_content(file=new_file, sheet=sheet, content)
307
+ elsif
308
+ null.defined(new_rem)
309
+ then
310
+ parse_content(
311
+ file=file,
312
+ sheet={...sheet, rem = [null.get(new_rem), ...sheet.rem]},
313
+ content
314
+ )
315
+ elsif
316
+ null.defined(track)
317
+ then
318
+ let (file, track, content) =
319
+ parse_track(
320
+ file=file,
321
+ track={...null.get(track), indexes = []},
322
+ content
323
+ )
324
+ parse_content(
325
+ file=file,
326
+ sheet={...sheet, tracks = [...sheet.tracks, track]},
327
+ content
328
+ )
329
+ else
330
+ sheet_attributes =
331
+ [
332
+ ("catalog", fun (catalog) -> {...sheet, catalog = catalog}),
333
+ ("performer", fun (performer) -> {...sheet, performer = performer}),
334
+ ("title", fun (title) -> {...sheet, title = title})
335
+ ]
336
+
337
+ attribute_names = list.map(fst, sheet_attributes)
338
+
339
+ def rec check_attribute(attribute_names) =
340
+ if
341
+ attribute_names == []
342
+ then
343
+ log.important(
344
+ label="playlist.parse.cue.full",
345
+ "Could not parse attribute: #{string.quote(s)}"
346
+ )
347
+ sheet
348
+ else
349
+ let [name, ...attribute_names] = attribute_names
350
+ let fn = list.assoc(name, sheet_attributes)
351
+ let parsed = parse_optional(label=name, s)
352
+
353
+ if
354
+ null.defined(parsed)
355
+ then
356
+ fn(null.get(parsed))
357
+ else
358
+ check_attribute(attribute_names)
359
+ end
360
+ end
361
+ end
362
+
363
+ parse_content(
364
+ file=file,
365
+ sheet=check_attribute(attribute_names),
366
+ content
367
+ )
368
+ end
369
+ end
370
+ end
371
+
372
+ parse_content(file=null, sheet={tracks = [], rem = []}, content)
373
+ end
374
+
375
+ let settings.playlist.cue =
376
+ settings.make.void(
377
+ "Settings for parsing cue files"
378
+ )
379
+
380
+ let settings.playlist.cue.pregap_metadata =
381
+ settings.make(
382
+ description="Metadata used to pass pre-gap cue metadata",
383
+ "liq_pregap"
384
+ )
385
+
386
+ let settings.playlist.cue.index_zero_metadata =
387
+ settings.make(
388
+ description="Metadata used to pass index 0 cue metadata",
389
+ "liq_index_zero"
390
+ )
391
+
392
+ let settings.playlist.cue.index_zero_filename_metadata =
393
+ settings.make(
394
+ description="Metadata used to pass index 0 filename metadata",
395
+ "liq_index_zero_filename"
396
+ )
397
+
398
+ let settings.playlist.cue.postgap_metadata =
399
+ settings.make(
400
+ description="Metadata used to pass pre-gap cue metadata",
401
+ "liq_postgap"
402
+ )
403
+
404
+ # Parse a cue file and return a value suitable for playlist parser registration.
405
+ # @category Liquidsoap
406
+ # @param ~pwd Path to use for relative path resolution
407
+ def replaces playlist.parse.cue(~pwd=null, content) =
408
+ # Simple test: playlist should have at least one file..
409
+ if
410
+ r/FILE/.test(content)
411
+ then
412
+ let {catalog?, title?, performer?, rem, tracks} =
413
+ playlist.parse.cue.full(pwd=pwd, content)
414
+ let playlist_album = title
415
+ let album_performer = performer
416
+ rem =
417
+ list.map(
418
+ fun (el) ->
419
+ begin
420
+ let (name, value) = el
421
+ if name == "date" then ("year", value) else (name, value) end
422
+ end,
423
+ rem
424
+ )
425
+
426
+ def seconds_of_timecode(timecode) =
427
+ let {minutes, seconds, frames} = timecode
428
+ float(minutes) * 60. + float(seconds) + float(frames) / 75.
429
+ end
430
+
431
+ def string_of_timecode(timecode) =
432
+ string(seconds_of_timecode(timecode))
433
+ end
434
+
435
+ def add_track(tracks, track) =
436
+ let {
437
+ performer?,
438
+ title?,
439
+ album?,
440
+ isrc?,
441
+ pregap?,
442
+ postgap?,
443
+ position,
444
+ indexes
445
+ } = track
446
+ index_zero = list.assoc.nullable(0, indexes)
447
+ index_zero_filename = null.map(fun (m) -> m?.filename, index_zero)
448
+ index = list.assoc.nullable(1, indexes)
449
+ filename = null.map(fun (m) -> m?.filename, index)
450
+ cue_in_metadata = settings.playlist.cue_in_metadata()
451
+ cue_out_metadata = settings.playlist.cue_out_metadata()
452
+ pregap_metadata = settings.playlist.cue.pregap_metadata()
453
+ index_zero_metadata = settings.playlist.cue.index_zero_metadata()
454
+ index_zero_filename_metadata =
455
+ settings.playlist.cue.index_zero_filename_metadata()
456
+ postgap_metadata = settings.playlist.cue.postgap_metadata()
457
+
458
+ if
459
+ not null.defined(filename) or (not null.defined(index))
460
+ then
461
+ log.important(
462
+ label="playlist.cue.parse",
463
+ "Track without filename or index: #{track}"
464
+ )
465
+ tracks
466
+ else
467
+ let timecode = null.get(index)
468
+ let filename = null.get(filename)
469
+
470
+ timecode = seconds_of_timecode(timecode)
471
+ cue_in = timecode == 0. ? [] : [(cue_in_metadata, string(timecode))]
472
+
473
+ cue_out =
474
+ try
475
+ let (old_meta, old_filename) = list.hd(tracks)
476
+ if
477
+ old_filename == filename
478
+ then
479
+ index_zero = list.assoc(default="", index_zero_metadata, old_meta)
480
+
481
+ cue_out =
482
+ if
483
+ index_zero != ""
484
+ then
485
+ index_zero
486
+ else
487
+ list.assoc(cue_in_metadata, old_meta)
488
+ end
489
+
490
+ [(cue_out_metadata, cue_out)]
491
+ else
492
+ []
493
+ end
494
+ catch _ do
495
+ []
496
+ end
497
+
498
+ meta =
499
+ list.fold(
500
+ fun (meta, el) ->
501
+ begin
502
+ let (name, value) = el
503
+ if
504
+ null.defined(value)
505
+ then
506
+ [(name, null.get(value)), ...meta]
507
+ else
508
+ meta
509
+ end
510
+ end,
511
+ [],
512
+ [
513
+ (pregap_metadata, null.map(string_of_timecode, pregap)),
514
+ (postgap_metadata, null.map(string_of_timecode, postgap)),
515
+ (index_zero_metadata, null.map(string_of_timecode, index_zero)),
516
+ (index_zero_filename_metadata, index_zero_filename),
517
+ ("tracknumber", string(position)),
518
+ ("catalog", catalog),
519
+ ...(
520
+ null.defined(performer)
521
+ ? [("artist", performer), ("albumartist", album_performer)]
522
+ : [("artist", album_performer)]
523
+ ),
524
+ ("title", title),
525
+ ("isrc", isrc),
526
+ ("album", album ?? playlist_album)
527
+ ]
528
+ )
529
+
530
+ [([...cue_in, ...cue_out, ...meta, ...rem], filename), ...tracks]
531
+ end
532
+ end
533
+
534
+ list.fold(add_track, [], list.rev(tracks))
535
+ else
536
+ []
537
+ end
538
+ end
539
+
540
+ let settings.playlist.mime_types.basic =
541
+ settings.make(
542
+ description="Mime-types used for guessing text-based playlists.",
543
+ [
544
+ {
545
+ name =
546
+ "scpls format",
547
+ mimes = ["audio/x-scpls"],
548
+ strict = true,
549
+ parser = playlist.parse.scpls
550
+ },
551
+ {
552
+ name =
553
+ "cue sheet",
554
+ mimes = ["application/x-cue"],
555
+ strict = true,
556
+ parser = playlist.parse.cue
557
+ },
558
+ {
559
+ name =
560
+ "m3u Format",
561
+ mimes = ["audio/x-mpegurl", "audio/mpegurl", "application/x-mpegURL"],
562
+ strict = false,
563
+ parser = playlist.parse.m3u
564
+ }
565
+ ]
566
+ )
567
+
568
+ %ifdef playlist.parse.xml
569
+ let settings.playlist.mime_types.xml =
570
+ settings.make(
571
+ description="Mime-types used for guessing xml-based playlists.",
572
+ [
573
+ {
574
+ name =
575
+ "xmlplaylist format",
576
+ mimes =
577
+ [
578
+ "video/x-ms-asf",
579
+ "audio/x-ms-asx",
580
+ "text/xml",
581
+ "application/xml",
582
+ "application/smil",
583
+ "application/smil+xml",
584
+ "application/xspf+xml",
585
+ "application/rss+xml"
586
+ ],
587
+ strict = true,
588
+ parser = playlist.parse.xml
589
+ }
590
+ ]
591
+ )
592
+ %endif
593
+
594
+ # @flag hidden
595
+ let register_playlist_parsers =
596
+ begin
597
+ registered = ref(false)
598
+ fun () ->
599
+ begin
600
+ if
601
+ not registered()
602
+ then
603
+ parsers = settings.playlist.mime_types.basic()
604
+ %ifdef playlist.parse.xml
605
+ parsers = [...parsers, ...settings.playlist.mime_types.xml()]
606
+ %endif
607
+ list.iter(
608
+ fun ({name, mimes, strict, parser}) ->
609
+ playlist.parse.register(
610
+ name=name,
611
+ mimes=mimes,
612
+ strict=strict,
613
+ parser
614
+ ),
615
+ parsers
616
+ )
617
+ end
618
+ registered := true
619
+ end
620
+ end
621
+ on_start(register_playlist_parsers)
622
+
623
+ # @docof playlist.parse
624
+ def replaces playlist.parse(%argsof(playlist.parse), uri) =
625
+ register_playlist_parsers()
626
+ playlist.parse(%argsof(playlist.parse), uri)
627
+ end
628
+
629
+ # Default id assignment for playlists (the identifier is generated from the
630
+ # filename).
631
+ # @category Liquidsoap
632
+ # @flag hidden
633
+ # @param ~default Default name pattern when no useful name can be extracted from `uri`
634
+ # @param uri Playlist uri
635
+ def playlist.id(~default, uri) =
636
+ basename = path.basename(uri)
637
+ basename =
638
+ if
639
+ basename == "."
640
+ then
641
+ let l = r/\//g.split(uri)
642
+ if l == [] then path.dirname(uri) else list.hd(list.rev(l)) end
643
+ else
644
+ basename
645
+ end
646
+
647
+ if
648
+ basename == "."
649
+ then
650
+ string.id.default(default=default, null)
651
+ else
652
+ basename
653
+ end
654
+ end
655
+
656
+ # Retrieve the list of files contained in a playlist.
657
+ # @category File
658
+ # @param ~mime_type Default MIME type for the playlist. `null` means automatic detection.
659
+ # @param ~timeout Timeout for resolving the playlist
660
+ # @param uri Path to the playlist
661
+ def playlist.files(~id=null, ~mime_type=null, ~timeout=null, uri) =
662
+ id = id ?? playlist.id(default="playlist.files", uri)
663
+
664
+ if
665
+ file.is_directory(uri)
666
+ then
667
+ log.info(
668
+ label=id,
669
+ "Playlist is a directory."
670
+ )
671
+ files = file.ls(absolute=true, recursive=true, sorted=true, uri)
672
+ files = list.filter(fun (f) -> not (file.is_directory(f)), files)
673
+ files
674
+ else
675
+ pl = request.create(resolve_metadata=false, uri)
676
+ result =
677
+ if
678
+ request.resolve(timeout=timeout, pl)
679
+ then
680
+ pl = request.filename(pl)
681
+ files = playlist.parse(mime=mime_type, pl)
682
+
683
+ def file_request(el) =
684
+ let (meta, file) = el
685
+ s =
686
+ string.concat(
687
+ separator=",",
688
+ list.map(fun (el) -> "#{fst(el)}=#{string.quote(snd(el))}", meta)
689
+ )
690
+
691
+ if s == "" then file else "annotate:#{s}:#{file}" end
692
+ end
693
+
694
+ list.map.right(file_request, files)
695
+ else
696
+ log.important(
697
+ label=id,
698
+ "Couldn't read playlist: request resolution failed."
699
+ )
700
+ request.destroy(pl)
701
+
702
+ error.raise(
703
+ error.invalid,
704
+ "Could not resolve uri: #{uri}"
705
+ )
706
+ end
707
+
708
+ request.destroy(pl)
709
+ result
710
+ end
711
+ end
712
+
713
+ %ifdef native
714
+ let stdlib_native = native
715
+ %endif
716
+
717
+ # Play a list of files.
718
+ # @category Source / Input / Passive
719
+ # @param ~id Force the value of the source ID.
720
+ # @param ~check_next Function used to filter next tracks. A candidate track is \
721
+ # only validated if the function returns true on it. The function is called \
722
+ # before resolution, hence metadata will only be available for requests \
723
+ # corresponding to local files. This is typically used to avoid repetitions, \
724
+ # but be careful: if the function rejects all attempts, the playlist will \
725
+ # enter into a consuming loop and stop playing anything.
726
+ # @param ~prefetch How many requests should be queued in advance.
727
+ # @param ~loop Loop on the playlist.
728
+ # @param ~mode Play the files in the playlist either in the order ("normal" mode), \
729
+ # or shuffle the playlist each time it is loaded, and play it in this order for a \
730
+ # whole round ("randomize" mode), or pick a random file in the playlist each time \
731
+ # ("random" mode).
732
+ # @param ~native Use native implementation, when available.
733
+ # @param ~on_loop Function executed when the playlist is about to loop.
734
+ # @param ~on_done Function executed when the playlist is finished.
735
+ # @param ~max_fail When this number of requests fail to resolve, the whole playlists is considered as failed and `on_fail` is called.
736
+ # @param ~on_fail Function executed when too many requests failed and returning the contents of a fixed playlist.
737
+ # @param ~timeout Timeout (in sec.) to resolve the request. Defaults to `settings.request.timeout` when `null`.
738
+ # @param ~cue_in_metadata Metadata for cue in points. Disabled if `null`.
739
+ # @param ~cue_out_metadata Metadata for cue out points. Disabled if `null`.
740
+ # @param playlist Playlist.
741
+ # @method reload Reload the playlist with given list of songs.
742
+ # @method remaining_files Songs remaining to be played.
743
+ def playlist.list(
744
+ ~id=null,
745
+ ~check_next=null,
746
+ ~prefetch=null,
747
+ ~loop=true,
748
+ ~mode="normal",
749
+ ~native=false,
750
+ ~on_loop={()},
751
+ ~on_done={()},
752
+ ~max_fail=10,
753
+ ~on_fail=null,
754
+ ~timeout=null,
755
+ ~cue_in_metadata=null("liq_cue_in"),
756
+ ~cue_out_metadata=null("liq_cue_out"),
757
+ playlist
758
+ ) =
759
+ ignore(native)
760
+ id = string.id.default(default="playlist.list", id)
761
+ mode =
762
+ if
763
+ not list.mem(mode, ["normal", "random", "randomize"])
764
+ then
765
+ log.severe(
766
+ label=id,
767
+ "Invalid mode: #{mode}"
768
+ )
769
+ "randomize"
770
+ else
771
+ mode
772
+ end
773
+
774
+ check_next = check_next ?? fun (_) -> true
775
+ should_stop = ref(false)
776
+ on_shutdown({should_stop.set(true)})
777
+ on_fail =
778
+ null.map(
779
+ fun (on_fail) -> {if not should_stop() then on_fail() else [] end},
780
+ on_fail
781
+ )
782
+
783
+ # Original playlist when loaded
784
+ playlist_orig = ref(playlist)
785
+
786
+ # Randomize the playlist if necessary
787
+ def randomize(p) =
788
+ if mode == "randomize" then list.shuffle(p) else p end
789
+ end
790
+
791
+ # Current remaining playlist
792
+ playlist = ref(randomize(playlist))
793
+
794
+ # A reference to know if the source has been stopped
795
+ has_stopped = ref(false)
796
+
797
+ # Delay the creation of next after the source because we need it to resolve
798
+ # requests at the right content type.
799
+ next_fun = ref(fun () -> null)
800
+
801
+ def next() =
802
+ f = next_fun()
803
+ f()
804
+ end
805
+
806
+ # Instantiate the source
807
+ default =
808
+ fun () ->
809
+ request.dynamic(
810
+ id=id,
811
+ prefetch=prefetch,
812
+ timeout=timeout,
813
+ retry_delay=1.,
814
+ available={not has_stopped()},
815
+ next
816
+ )
817
+
818
+ s =
819
+ %ifdef native
820
+ if native then stdlib_native.request.dynamic(id=id, next) else default() end
821
+ %else
822
+ default()
823
+ %endif
824
+
825
+ # Prevent concurrent reload and next()
826
+ is_reloading = ref(false)
827
+ pending_next = ref(false)
828
+
829
+ # The reload function
830
+ def reload(~empty_queue=true, p) =
831
+ is_reloading := true
832
+
833
+ log.debug(
834
+ label=id,
835
+ "Reloading playlist."
836
+ )
837
+ playlist_orig := p
838
+ playlist := randomize(playlist_orig())
839
+ has_stopped := false
840
+ if
841
+ empty_queue
842
+ then
843
+ q = s.queue()
844
+ s.set_queue([])
845
+ list.iter(request.destroy, q)
846
+ pending_next := true
847
+ end
848
+
849
+ if pending_next() then s.fetch() end
850
+
851
+ pending_next := false
852
+ is_reloading := false
853
+ end
854
+
855
+ # When we have more than max_fail failures in a row, we wait for 1 second
856
+ # before trying again in order to avoid infinite loops.
857
+ failed_count = ref(0)
858
+ failed_time = ref(0.)
859
+
860
+ # The (real) next function
861
+ def rec next() =
862
+ if
863
+ is_reloading()
864
+ then
865
+ pending_next := true
866
+ elsif
867
+ loop and list.is_empty(playlist())
868
+ then
869
+ on_loop()
870
+
871
+ # The above function might have reloaded the playlist
872
+ if
873
+ list.is_empty(playlist())
874
+ then
875
+ playlist := randomize(playlist_orig())
876
+ end
877
+ end
878
+
879
+ file =
880
+ if
881
+ list.length(playlist()) > 0
882
+ then
883
+ if
884
+ mode == "random"
885
+ then
886
+ n = random.int(min=0, max=list.length(playlist()))
887
+ list.nth(default="", playlist(), n)
888
+ else
889
+ ret = list.hd(default="", playlist())
890
+ playlist := list.tl(playlist())
891
+ ret
892
+ end
893
+ else
894
+ # Playlist finished
895
+ if
896
+ not has_stopped()
897
+ then
898
+ has_stopped := true
899
+ log.info(
900
+ label=id,
901
+ "Playlist stopped."
902
+ )
903
+ on_done()
904
+ end
905
+
906
+ ""
907
+ end
908
+
909
+ if
910
+ file == "" or (failed_count() >= max_fail and time() < failed_time() + 1.)
911
+ then
912
+ # Playlist failed too many times recently, don't try next for now.
913
+ null
914
+ else
915
+ log.debug(
916
+ label=id,
917
+ "Next song will be \"#{file}\"."
918
+ )
919
+
920
+ r =
921
+ request.create(
922
+ cue_in_metadata=cue_in_metadata,
923
+ cue_out_metadata=cue_out_metadata,
924
+ file
925
+ )
926
+
927
+ if
928
+ check_next(r)
929
+ then
930
+ if
931
+ not request.resolve(r)
932
+ then
933
+ log.info(
934
+ label=id,
935
+ "Could not resolve request: #{request.uri(r)}."
936
+ )
937
+ request.destroy(r)
938
+ ref.incr(failed_count)
939
+
940
+ # Playlist failed, call handler.
941
+ if
942
+ failed_count() < max_fail
943
+ then
944
+ log.info(
945
+ label=id,
946
+ "Playlist failed."
947
+ )
948
+ if
949
+ null.defined(on_fail)
950
+ then
951
+ f = null.get(on_fail)
952
+ reload(f())
953
+ end
954
+ end
955
+
956
+ failed_time := time()
957
+ (next() : request?)
958
+ else
959
+ failed_count := 0
960
+ r
961
+ end
962
+ else
963
+ log.info(
964
+ label=id,
965
+ "Request #{request.uri(r)} rejected by check_next."
966
+ )
967
+
968
+ request.destroy(r)
969
+ next()
970
+ end
971
+ end
972
+ end
973
+
974
+ next_fun := next
975
+
976
+ # List of songs remaining to be played
977
+ def remaining_files() =
978
+ playlist()
979
+ end
980
+
981
+ # Return
982
+ s.{reload = reload, remaining_files = remaining_files}
983
+ end
984
+
985
+ # Read a playlist or a directory and play all files.
986
+ # @category Source / Input / Passive
987
+ # @param ~id Force the value of the source ID.
988
+ # @param ~check_next Function used to filter next tracks. A candidate track is \
989
+ # only validated if the function returns true on it. The function is called \
990
+ # before resolution, hence metadata will only be available for requests \
991
+ # corresponding to local files. This is typically used to avoid repetitions, \
992
+ # but be careful: if the function rejects all attempts, the playlist will \
993
+ # enter into a consuming loop and stop playing anything.
994
+ # @param ~prefetch How many requests should be queued in advance.
995
+ # @param ~loop Loop on the playlist.
996
+ # @param ~mime_type Default MIME type for the playlist. `null` means automatic \
997
+ # detection.
998
+ # @param ~mode Play the files in the playlist either in the order ("normal" mode), \
999
+ # or shuffle the playlist each time it is loaded, and play it in this order for a \
1000
+ # whole round ("randomize" mode), or pick a random file in the playlist each time \
1001
+ # ("random" mode).
1002
+ # @param ~native Use native implementation.
1003
+ # @param ~max_fail When this number of requests fail to resolve, the whole playlists is considered as failed and `on_fail` is called.
1004
+ # @param ~on_done Function executed when the playlist is finished.
1005
+ # @param ~on_fail Function executed when too many requests failed and returning the contents of a fixed playlist.
1006
+ # @param ~on_reload Callback called after playlist has reloaded.
1007
+ # @param ~prefix Add a constant prefix to all requests. Useful for passing extra \
1008
+ # information using annotate, or for resolution through a particular protocol, \
1009
+ # such as replaygain.
1010
+ # @param ~reload Amount of time (in seconds or rounds), when applicable, before \
1011
+ # which the playlist is reloaded; 0 means never.
1012
+ # @param ~reload_mode Unit of the reload parameter, either "never" (never reload \
1013
+ # the playlist), "rounds", "seconds" or "watch" (reload the file whenever it is \
1014
+ # changed).
1015
+ # @param ~register_server_commands Register corresponding server commands
1016
+ # @param ~timeout Timeout (in sec.) to resolve the request. Defaults to `settings.request.timeout` when `null`.
1017
+ # @param ~cue_in_metadata Metadata for cue in points. Disabled if `null`.
1018
+ # @param ~cue_out_metadata Metadata for cue out points. Disabled if `null`.
1019
+ # @param uri Playlist URI.
1020
+ # @method reload Reload the playlist.
1021
+ # @method length Length of the of the playlist (the number of songs it contains).
1022
+ # @method remaining_files Songs remaining to be played.
1023
+ def replaces playlist(
1024
+ ~id=null,
1025
+ ~check_next=null,
1026
+ ~prefetch=null,
1027
+ ~loop=true,
1028
+ ~max_fail=10,
1029
+ ~mime_type=null,
1030
+ ~mode="randomize",
1031
+ ~native=false,
1032
+ ~on_done={()},
1033
+ ~on_fail=null,
1034
+ ~on_reload=(fun (_) -> ()),
1035
+ ~prefix="",
1036
+ ~reload=0,
1037
+ ~reload_mode="seconds",
1038
+ ~timeout=null,
1039
+ ~cue_in_metadata=null("liq_cue_in"),
1040
+ ~cue_out_metadata=null("liq_cue_out"),
1041
+ ~register_server_commands=true,
1042
+ uri
1043
+ ) =
1044
+ id = id ?? playlist.id(default="playlist", uri)
1045
+ reload_mode =
1046
+ if
1047
+ not list.mem(reload_mode, ["never", "rounds", "seconds", "watch"])
1048
+ then
1049
+ log.severe(
1050
+ label=id,
1051
+ "Invalid reload mode: #{mode}"
1052
+ )
1053
+ "seconds"
1054
+ else
1055
+ reload_mode
1056
+ end
1057
+
1058
+ round = ref(0)
1059
+
1060
+ # URI of the current playlist
1061
+ playlist_uri = ref(uri)
1062
+
1063
+ # List of files in the current playlist
1064
+ files = ref([])
1065
+
1066
+ # The reload function
1067
+ reloader_ref = ref(fun (~empty_queue=true) -> ignore(empty_queue))
1068
+
1069
+ failed_loads = ref(0)
1070
+
1071
+ # The load function
1072
+ def load_playlist() =
1073
+ playlist_uri = path.home.unrelate(playlist_uri())
1074
+
1075
+ log.info(
1076
+ label=id,
1077
+ "Reloading playlist."
1078
+ )
1079
+
1080
+ files =
1081
+ try
1082
+ playlist.files(
1083
+ id=id,
1084
+ mime_type=mime_type,
1085
+ timeout=timeout,
1086
+ playlist_uri
1087
+ )
1088
+ catch err do
1089
+ log.info(
1090
+ label=id,
1091
+ "Playlist load failed: #{err}"
1092
+ )
1093
+
1094
+ ref.incr(failed_loads)
1095
+
1096
+ if
1097
+ failed_loads() < max_fail
1098
+ then
1099
+ []
1100
+ else
1101
+ log.info(
1102
+ label=id,
1103
+ "Maximum failures reached!"
1104
+ )
1105
+ on_fail = on_fail ?? fun () -> []
1106
+ on_fail()
1107
+ end
1108
+ end
1109
+
1110
+ list.map.right(fun (file) -> prefix ^ file, files)
1111
+ end
1112
+
1113
+ # Reload when the playlist is done
1114
+ def on_loop() =
1115
+ reloader = reloader_ref()
1116
+ if
1117
+ reload_mode == "rounds" and reload > 0
1118
+ then
1119
+ round := round() + 1
1120
+ if
1121
+ round() >= reload
1122
+ then
1123
+ round := 0
1124
+ reloader()
1125
+ end
1126
+ end
1127
+ end
1128
+
1129
+ watcher_reload_ref = ref(fun (_) -> ())
1130
+
1131
+ def on_reload(uri) =
1132
+ watcher_reload = watcher_reload_ref()
1133
+ watcher_reload(uri)
1134
+ on_reload(uri)
1135
+ end
1136
+
1137
+ s =
1138
+ playlist.list(
1139
+ id=id,
1140
+ check_next=check_next,
1141
+ prefetch=prefetch,
1142
+ loop=loop,
1143
+ max_fail=max_fail,
1144
+ mode=mode,
1145
+ native=native,
1146
+ on_done=on_done,
1147
+ on_loop=on_loop,
1148
+ on_fail=on_fail,
1149
+ timeout=timeout,
1150
+ cue_in_metadata=cue_in_metadata,
1151
+ cue_out_metadata=cue_out_metadata,
1152
+ files()
1153
+ )
1154
+
1155
+ is_loading = ref(false)
1156
+ def if_not_reloading(fn) =
1157
+ if
1158
+ not is_loading()
1159
+ then
1160
+ is_loading := true
1161
+ try
1162
+ fn()
1163
+ finally
1164
+ is_loading := false
1165
+ end
1166
+ end
1167
+ end
1168
+
1169
+ s.on_wake_up(
1170
+ synchronous=false,
1171
+ memoize(
1172
+ {
1173
+ if_not_reloading(
1174
+ fun () ->
1175
+ begin
1176
+ log(
1177
+ label=s.id(),
1178
+ "Initial load with URI #{playlist_uri()}."
1179
+ )
1180
+ files := load_playlist()
1181
+ s.reload(empty_queue=true, files())
1182
+ end
1183
+ )
1184
+ }
1185
+ )
1186
+ )
1187
+
1188
+ # The reload function
1189
+ def s.reload(~empty_queue=true, ~uri=null) =
1190
+ if_not_reloading(
1191
+ fun () ->
1192
+ if
1193
+ failed_loads() < max_fail
1194
+ then
1195
+ if null.defined(uri) then playlist_uri := null.get(uri) end
1196
+ log(
1197
+ label=s.id(),
1198
+ "Reloading playlist with URI #{playlist_uri()}."
1199
+ )
1200
+ files := load_playlist()
1201
+ s.reload(empty_queue=empty_queue, files())
1202
+ on_reload(playlist_uri())
1203
+ end
1204
+ )
1205
+ end
1206
+
1207
+ reloader_ref := s.reload
1208
+
1209
+ def s.length() =
1210
+ list.length(files())
1211
+ end
1212
+
1213
+ # Set up reloading for seconds and watch
1214
+ if
1215
+ reload_mode == "seconds" and reload > 0
1216
+ then
1217
+ n = float_of_int(reload)
1218
+ thread.run(delay=n, every=n, s.reload)
1219
+ elsif
1220
+ reload_mode == "watch"
1221
+ then
1222
+ watcher =
1223
+ if
1224
+ file.exists(playlist_uri())
1225
+ then
1226
+ ref(null(file.watch(playlist_uri(), s.reload)))
1227
+ else
1228
+ ref(null)
1229
+ end
1230
+
1231
+ watched_uri = ref(playlist_uri())
1232
+
1233
+ def watcher_reload(uri) =
1234
+ if
1235
+ uri != watched_uri()
1236
+ then
1237
+ w = watcher()
1238
+ if null.defined(w) then null.get(w).unwatch() end
1239
+ watched_uri := uri
1240
+ watcher :=
1241
+ if file.exists(uri) then file.watch(uri, s.reload) else null end
1242
+ end
1243
+ end
1244
+
1245
+ watcher_reload_ref := watcher_reload
1246
+
1247
+ def watcher_shutdown() =
1248
+ w = watcher()
1249
+ if null.defined(w) then null.get(w).unwatch() end
1250
+ end
1251
+
1252
+ s.on_shutdown(synchronous=true, watcher_shutdown)
1253
+ end
1254
+
1255
+ if
1256
+ register_server_commands
1257
+ then
1258
+ # Set up telnet commands
1259
+ s.register_command(
1260
+ description="Skip current song in the playlist.",
1261
+ usage="skip",
1262
+ "skip",
1263
+ fun (_) ->
1264
+ begin
1265
+ s.skip()
1266
+ "OK"
1267
+ end
1268
+ )
1269
+
1270
+ s.register_command(
1271
+ description="Return up to 10 next URIs to be played.",
1272
+ usage="next",
1273
+ "next",
1274
+ fun (n) ->
1275
+ begin
1276
+ n = max(10, int_of_string(default=10, n))
1277
+ requests =
1278
+ list.fold(
1279
+ (fun (cur, el) -> list.length(cur) < n ? [...cur, el] : cur),
1280
+ [],
1281
+ s.queue()
1282
+ )
1283
+
1284
+ string.concat(
1285
+ separator="\n",
1286
+ list.map(
1287
+ (
1288
+ fun (r) ->
1289
+ begin
1290
+ m = request.metadata(r)
1291
+ get = fun (lbl) -> list.assoc(default="?", lbl, m)
1292
+ status = get("status")
1293
+ uri = get("initial_uri")
1294
+ "[#{status}] #{uri}"
1295
+ end
1296
+ ),
1297
+ requests
1298
+ )
1299
+ )
1300
+ end
1301
+ )
1302
+
1303
+ s.register_command(
1304
+ description="Reload the playlist, unless already being loaded.",
1305
+ usage="reload",
1306
+ "reload",
1307
+ fun (_) ->
1308
+ begin
1309
+ s.reload()
1310
+ "OK"
1311
+ end
1312
+ )
1313
+
1314
+ def uri_cmd(uri') =
1315
+ if
1316
+ uri' == ""
1317
+ then
1318
+ playlist_uri()
1319
+ else
1320
+ if
1321
+ reload_mode == "watch"
1322
+ then
1323
+ log.important(
1324
+ label=id,
1325
+ "Warning: the watched file is not updated for now when changing the \
1326
+ uri!"
1327
+ )
1328
+ end
1329
+
1330
+ # TODO
1331
+ playlist_uri := uri'
1332
+ s.reload(uri=uri')
1333
+ "OK"
1334
+ end
1335
+ end
1336
+
1337
+ s.register_command(
1338
+ description="Print playlist URI if called without an argument, otherwise \
1339
+ set a new one and load it.",
1340
+ usage="uri [<uri>]",
1341
+ "uri",
1342
+ uri_cmd
1343
+ )
1344
+ end
1345
+
1346
+ s
1347
+ end