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.
- package/.github/workflows/check-formatting.yml +32 -0
- package/README.md +31 -5
- package/package.json +1 -1
- package/src/cli.js +104 -9
- package/tests/liq/audio.liq +460 -0
- package/tests/liq/autocue.liq +1081 -0
- package/tests/liq/clock.liq +14 -0
- package/tests/liq/cron.liq +74 -0
- package/tests/liq/error.liq +48 -0
- package/tests/liq/extra/audio.liq +677 -0
- package/tests/liq/extra/audioscrobbler.liq +482 -0
- package/tests/liq/extra/deprecations.liq +976 -0
- package/tests/liq/extra/externals.liq +196 -0
- package/tests/liq/extra/fades.liq +260 -0
- package/tests/liq/extra/file.liq +66 -0
- package/tests/liq/extra/http.liq +160 -0
- package/tests/liq/extra/interactive.liq +917 -0
- package/tests/liq/extra/metadata.liq +75 -0
- package/tests/liq/extra/native.liq +201 -0
- package/tests/liq/extra/openai.liq +150 -0
- package/tests/liq/extra/server.liq +177 -0
- package/tests/liq/extra/source.liq +476 -0
- package/tests/liq/extra/spinitron.liq +272 -0
- package/tests/liq/extra/telnet.liq +266 -0
- package/tests/liq/extra/video.liq +59 -0
- package/tests/liq/extra/visualization.liq +68 -0
- package/tests/liq/fades.liq +941 -0
- package/tests/liq/ffmpeg.liq +605 -0
- package/tests/liq/file.liq +387 -0
- package/tests/liq/getter.liq +74 -0
- package/tests/liq/hls.liq +329 -0
- package/tests/liq/http.liq +1048 -0
- package/tests/liq/http_codes.liq +447 -0
- package/tests/liq/icecast.liq +58 -0
- package/tests/liq/io.liq +106 -0
- package/tests/liq/liquidsoap.liq +31 -0
- package/tests/liq/list.liq +440 -0
- package/tests/liq/log.liq +47 -0
- package/tests/liq/lufs.liq +295 -0
- package/tests/liq/math.liq +23 -0
- package/tests/liq/medialib.liq +752 -0
- package/tests/liq/metadata.liq +253 -0
- package/tests/liq/nfo.liq +258 -0
- package/tests/liq/null.liq +71 -0
- package/tests/liq/playlist.liq +1347 -0
- package/tests/liq/predicate.liq +106 -0
- package/tests/liq/process.liq +93 -0
- package/tests/liq/profiler.liq +5 -0
- package/tests/liq/protocols.liq +1139 -0
- package/tests/liq/ref.liq +28 -0
- package/tests/liq/replaygain.liq +135 -0
- package/tests/liq/request.liq +467 -0
- package/tests/liq/resolvers.liq +33 -0
- package/tests/liq/runtime.liq +70 -0
- package/tests/liq/server.liq +99 -0
- package/tests/liq/settings.liq +41 -0
- package/tests/liq/socket.liq +33 -0
- package/tests/liq/source.liq +362 -0
- package/tests/liq/sqlite.liq +161 -0
- package/tests/liq/stdlib.liq +172 -0
- package/tests/liq/string.liq +476 -0
- package/tests/liq/switches.liq +197 -0
- package/tests/liq/testing.liq +37 -0
- package/tests/liq/thread.liq +161 -0
- package/tests/liq/tracks.liq +100 -0
- package/tests/liq/utils.liq +81 -0
- 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
|