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,253 @@
1
+ let metadata.getter = ()
2
+
3
+ # Create a getter from a metadata.
4
+ # @category Metadata
5
+ # @flag hidden
6
+ # @param init Initial value.
7
+ # @param map Function to apply to the metadata value to obtain the new value.
8
+ # @param metadata Metadata on which the value should be updated.
9
+ # @param s Source containing the metadata.
10
+ def metadata.getter.base(init, map, metadata, s) =
11
+ x = ref(init)
12
+
13
+ def f(m) =
14
+ v = m[metadata]
15
+ if v != "" then x := map(v) end
16
+ end
17
+
18
+ s.on_metadata(synchronous=true, f)
19
+ ref.getter(x)
20
+ end
21
+
22
+ let metadata.getter.source = ()
23
+
24
+ # Variant of `metadata.getter.base` which also returns the source. Using this
25
+ # variant is a bit more complex, but safer this it does not involve a global
26
+ # state, which might unexpectedly change the metadata if a source is used at
27
+ # various places.
28
+ # @flag hidden
29
+ def metadata.getter.source.base(init, map, metadata, s) =
30
+ x = ref(init)
31
+
32
+ def f(m) =
33
+ v = m[metadata]
34
+ if v != "" then x := map(v) end
35
+ end
36
+
37
+ s.on_metadata(synchronous=true, f)
38
+ (s, ref.getter(x))
39
+ end
40
+
41
+ # Create a getter from a metadata: this is a string, whose value can be changed
42
+ # with a metadata.
43
+ # @category Metadata
44
+ # @param init Initial value.
45
+ # @param m Metadata on which the value should be updated.
46
+ # @param s Source containing the metadata.
47
+ def replaces metadata.getter(init, m, s) =
48
+ metadata.getter.base(init, fun (v) -> v, m, s)
49
+ end
50
+
51
+ # Create a float getter from a metadata: this is a float, whose value can be
52
+ # changed with a metadata.
53
+ # @category Metadata
54
+ # @param init Initial value.
55
+ # @param m Metadata on which the value should be updated.
56
+ # @param s Source containing the metadata.
57
+ def metadata.getter.float(init, m, s) =
58
+ metadata.getter.base(init, float_of_string, m, s)
59
+ end
60
+
61
+ # Create a float getter from a metadata: this is a float, whose value can be
62
+ # changed with a metadata. This function also returns the source.
63
+ # @category Metadata
64
+ # @param init Initial value.
65
+ # @param m Metadata on which the value should be updated.
66
+ # @param s Source containing the metadata.
67
+ def metadata.getter.source.float(init, m, s) =
68
+ metadata.getter.source.base(init, float_of_string, m, s)
69
+ end
70
+
71
+ # Extract filename from metadata.
72
+ # @category Metadata
73
+ def metadata.filename(m) =
74
+ m["filename"]
75
+ end
76
+
77
+ # Extract title from metadata.
78
+ # @category Metadata
79
+ def metadata.title(m) =
80
+ m["title"]
81
+ end
82
+
83
+ # Extract artist from metadata.
84
+ # @category Metadata
85
+ def metadata.artist(m) =
86
+ m["artist"]
87
+ end
88
+
89
+ # Extract comment from metadata.
90
+ # @category Metadata
91
+ def metadata.comment(m) =
92
+ m["comment"]
93
+ end
94
+
95
+ # Extract cover from metadata. This function implements cover extraction
96
+ # for the following formats: coverart (ogg), apic (flac, mp3) and pic (mp3).
97
+ # @category Metadata
98
+ # @param m Metadata from which the cover should be extracted.
99
+ # @param ~coverart_mime Mime type to use for `"coverart"` metadata. Support disabled if `null`.
100
+ # @method mime MIME type for the cover.
101
+ def metadata.cover(~coverart_mime=null, m) =
102
+ fname = metadata.filename(m)
103
+ if
104
+ list.assoc.mem("coverart", m) and null.defined(coverart_mime)
105
+ then
106
+ cover = list.assoc(default="", "coverart", m)
107
+ string.base64.decode(cover).{mime = null.get(coverart_mime)}
108
+ elsif
109
+ list.assoc.mem("metadata_block_picture", m)
110
+ then
111
+ # See https://xiph.org/flac/format.html#metadata_block_picture
112
+ cover = list.assoc(default="", "metadata_block_picture", m)
113
+ cover = file.metadata.flac.cover.decode(cover)
114
+ if
115
+ not null.defined(cover)
116
+ then
117
+ log.info(
118
+ "Failed to read cover metadata for #{fname}."
119
+ )
120
+ null
121
+ else
122
+ null.get(cover)
123
+ end
124
+ else
125
+ # Assume we have an mp3 file
126
+ m =
127
+ if
128
+ list.assoc.mem("apic", m) or list.assoc.mem("pic", m)
129
+ then
130
+ m
131
+ else
132
+ # Try the builtin tag reader because APIC tags are not read by default,
133
+ log.debug(
134
+ label="metadata.cover",
135
+ "APIC or PIC not found for #{fname}, trying builtin tag reader."
136
+ )
137
+
138
+ file.metadata.id3v2(fname)
139
+ end
140
+
141
+ pic = list.assoc(default="", "pic", m)
142
+ apic = list.assoc(default="", "apic", m)
143
+ if
144
+ apic != ""
145
+ then
146
+ log.debug(
147
+ label="metadata.cover",
148
+ "Found APIC for #{fname}."
149
+ )
150
+
151
+ # TODO: we could use file type in order to select cover if there are many
152
+ string.apic.parse(apic)
153
+ elsif
154
+ pic != ""
155
+ then
156
+ log.debug(
157
+ label="metadata.cover",
158
+ "Found APIC for #{fname}."
159
+ )
160
+
161
+ # TODO: we could use file type in order to select cover if there are many
162
+ pic = string.pic.parse(pic)
163
+ mime =
164
+ if
165
+ pic.format == "JPG"
166
+ then
167
+ "image/jpeg"
168
+ elsif
169
+ pic.format == "PNG"
170
+ then
171
+ "image/png"
172
+ else
173
+ "application/octet-stream"
174
+ end
175
+
176
+ pic.{mime = mime}
177
+ else
178
+ log.info(
179
+ "No cover found for #{fname}."
180
+ )
181
+ null
182
+ end
183
+ end
184
+ end
185
+
186
+ # Obtain cover-art for a file. `null` is returned in case there is no
187
+ # such information.
188
+ # @category Metadata
189
+ # @param file File from which the cover should be obtained
190
+ def file.cover(fname) =
191
+ metadata.cover(file.metadata(fname))
192
+ end
193
+
194
+ # Remove cover metadata.
195
+ # @category Metadata
196
+ def metadata.cover.remove(m) =
197
+ list.assoc.filter(
198
+ fun (k, (_:string)) -> not list.mem(k, settings.encoder.metadata.cover()),
199
+ m
200
+ )
201
+ end
202
+
203
+ # Cleanup metadata for export. This is used to remove Liquidsoap's internal
204
+ # metadata entries before sending them. List of exported metadata is set using
205
+ # `settings.encoder.metadata.export.set`.
206
+ # @category Metadata
207
+ def metadata.export(m) =
208
+ exported_keys = settings.encoder.metadata.export()
209
+ list.assoc.filter((fun (k, (_:string)) -> list.mem(k, exported_keys)), m)
210
+ end
211
+
212
+ let metadata.json = ()
213
+
214
+ # Export metadata as JSON object. Cover art, if found, is extracted using
215
+ # `metadata.cover` and exported with key `"cover"` and exported using
216
+ # `string.data_uri.encode`.
217
+ # @category Metadata
218
+ # @param ~coverart_mime Mime type to use for `"coverart"` metadata. Support disasbled if `null`.
219
+ # @param ~compact Output compact text.
220
+ # @param ~json5 Use json5 extended spec.
221
+ def metadata.json.stringify(
222
+ ~coverart_mime=null,
223
+ ~base64=true,
224
+ ~compact=false,
225
+ ~json5=false,
226
+ m
227
+ ) =
228
+ c = metadata.cover(coverart_mime=coverart_mime, m)
229
+ m = metadata.cover.remove(m)
230
+ m = metadata.export(m)
231
+ m =
232
+ if
233
+ null.defined(c)
234
+ then
235
+ c = null.get(c)
236
+ [("cover", string.data_uri.encode(base64=base64, mime=c.mime, c)), ...m]
237
+ else
238
+ m
239
+ end
240
+
241
+ data = json.object()
242
+ list.iter(fun (v) -> data.add(fst(v), snd(v)), m)
243
+ json.stringify(json5=json5, compact=compact, data)
244
+ end
245
+
246
+ # Parse metadata from JSON object.
247
+ # @category Metadata
248
+ def metadata.json.parse(json_string) =
249
+ let json.parse (metadata_list : [(string*string)] as json.object) =
250
+ json_string
251
+
252
+ metadata_list
253
+ end
@@ -0,0 +1,258 @@
1
+ # Parse XML `.nfo` sidecar files (Kodi/Emby/Jellyfin style).
2
+ #
3
+ # This is meant to be used either directly via `file.nfo.metadata(...)` or as a
4
+ # metadata resolver through `enable_nfo_metadata(...)`.
5
+
6
+ # @category File
7
+ # @flag hidden
8
+ let file.nfo = ()
9
+
10
+ # @flag hidden
11
+ def file.nfo._normalize_tag(name) =
12
+ name = string.trim(name)
13
+
14
+ # Strip XML namespace prefix, if any (e.g. "x:title" -> "title").
15
+ # This also tolerates a leading ":" (malformed XML) by stripping it.
16
+ name = r/^[^:]*:/.replace(fun (_) -> "", name)
17
+ string.case(name)
18
+ end
19
+
20
+ # Accumulate values by key.
21
+ # @flag hidden
22
+ def file.nfo._accum_add(key, value, acc) =
23
+ # Values are stored in reverse order for cheap cons.
24
+ # Assoc-list update is O(n) in distinct keys (NFOs are typically small).
25
+ let (rev, found) =
26
+ list.fold(
27
+ fun (st, kv) ->
28
+ begin
29
+ let (rev, found) = st
30
+ if
31
+ fst(kv) == key
32
+ then
33
+ ([(key, value::snd(kv)), ...rev], true)
34
+ else
35
+ ([kv, ...rev], found)
36
+ end
37
+ end,
38
+ ([], false),
39
+ acc
40
+ )
41
+
42
+ if found then list.rev(rev) else list.rev([(key, [value]), ...rev]) end
43
+ end
44
+
45
+ # Build a tag rule table for `.nfo` parsing.
46
+ #
47
+ # Returned rule tables can be reused across multiple calls to `file.nfo.metadata`.
48
+ #
49
+ # @category File
50
+ # @param ~extra_rules Additional tag rules (prepended to built-ins).
51
+ def file.nfo.rules(~extra_rules=[]) =
52
+ extra_rules =
53
+ list.map(
54
+ fun (r) ->
55
+ begin
56
+ let (tag, rule_fn) = r
57
+ (file.nfo._normalize_tag(tag), rule_fn)
58
+ end,
59
+ extra_rules
60
+ )
61
+
62
+ def uniqueid_rule(node, txt) =
63
+ typ = list.assoc.nullable("type", node.xml_params)
64
+ typ = null.map(fun (t) -> string.case(string.trim(t)), typ)
65
+
66
+ is_default =
67
+ null.case(
68
+ list.assoc.nullable("default", node.xml_params),
69
+ {false},
70
+ fun (d) -> string.case(string.trim(d)) == "true"
71
+ )
72
+
73
+ def without_type() =
74
+ # Missing or invalid `type` (legacy / malformed input).
75
+ is_default
76
+ ? [("uniqueid.default", txt), ("uniqueid.untyped", txt)]
77
+ : [("uniqueid.untyped", txt)]
78
+ end
79
+
80
+ def with_type(t) =
81
+ # Standard case: typed ID.
82
+ kvs = [("uniqueid." ^ t, txt)]
83
+ if
84
+ is_default
85
+ then
86
+ [("uniqueid.default", txt), ("uniqueid.default.type", t), ...kvs]
87
+ else
88
+ kvs
89
+ end
90
+ end
91
+
92
+ null.case(
93
+ typ,
94
+ {without_type()},
95
+ fun (t) ->
96
+ # Some scrapers incorrectly emit `type="default"`. Kodi warns against it,
97
+ # so we treat it as untyped.
98
+ (t == "" or t == "default") ? without_type() : with_type(t)
99
+ )
100
+ end
101
+
102
+ [...extra_rules, ("uniqueid", uniqueid_rule)]
103
+ end
104
+
105
+ # Extract metadata from an `.nfo` file.
106
+ #
107
+ # Returned metadata keys are namespaced under `nfo.*`.
108
+ #
109
+ # Currently, only direct children of the root element that contain textual
110
+ # content are extracted.
111
+ #
112
+ # Special case:
113
+ # - `<uniqueid type="X">VALUE</uniqueid>` is exported as `nfo.uniqueid.X=VALUE`.
114
+ # - If `default="true"`, the ID is also exported as `nfo.uniqueid.default=VALUE` and
115
+ # the type as `nfo.uniqueid.default.type=X`.
116
+ # If multiple IDs are marked default, values are joined with `;`.
117
+ # - Typeless `<uniqueid>` (or `type="default"`, a scraper quirk) is exported as
118
+ # `nfo.uniqueid.untyped=VALUE`.
119
+ # - Repeated tags are joined with `;` (in order of appearance).
120
+ # - Tag names and unique ID types are lowercased; namespace prefixes are ignored.
121
+ #
122
+ # @category File
123
+ # @param ~rules Tag rules. Use `file.nfo.rules(...)` to build them.
124
+ # @param nfo_file Path to the `.nfo` file.
125
+ def file.nfo.metadata(~rules=file.nfo.rules(), nfo_file) =
126
+ if
127
+ not (file.exists(nfo_file))
128
+ then
129
+ []
130
+ else
131
+ try
132
+ s = file.contents(nfo_file)
133
+ let xml.parse ((root_name, root) :
134
+ (
135
+ string
136
+ *
137
+ {
138
+ xml_params: [(string * string)],
139
+ xml_children: [
140
+ (
141
+ string
142
+ *
143
+ {
144
+ xml_params: [(string * string)],
145
+ xml_children: [(string * {xml_text: string})]
146
+ }
147
+ )
148
+ ]
149
+ }
150
+ )
151
+ ) = s
152
+
153
+ root_name = file.nfo._normalize_tag(root_name)
154
+
155
+ def collect(fields, child) =
156
+ let (name, node) = child
157
+ name = file.nfo._normalize_tag(name)
158
+ txts =
159
+ list.filter_map(
160
+ fun (child) ->
161
+ begin
162
+ let (name, node) = child
163
+ if
164
+ name != "xml_text"
165
+ then
166
+ null
167
+ else
168
+ txt = string.trim(node.xml_text)
169
+ txt == "" ? null : txt
170
+ end
171
+ end,
172
+ node.xml_children
173
+ )
174
+
175
+ txt = list.case(txts, null, fun (txt, _) -> txt)
176
+
177
+ null.case(
178
+ txt,
179
+ {fields},
180
+ fun (txt) ->
181
+ begin
182
+ rule = list.assoc.nullable(name, rules)
183
+
184
+ kvs =
185
+ null.case(
186
+ rule,
187
+ {[(name, txt)]},
188
+ fun (rule_fn) -> rule_fn(node, txt)
189
+ )
190
+
191
+ def add(fields, kv) =
192
+ let (k, v) = kv
193
+ file.nfo._accum_add(k, v, fields)
194
+ end
195
+
196
+ list.fold(add, fields, kvs)
197
+ end
198
+ )
199
+ end
200
+
201
+ fields_by_key = list.fold(collect, [], root.xml_children)
202
+
203
+ fields_md =
204
+ list.map(
205
+ fun (kv) ->
206
+ begin
207
+ let (k, vs) = kv
208
+ ("nfo." ^ k, string.concat(separator=";", list.rev(vs)))
209
+ end,
210
+ fields_by_key
211
+ )
212
+
213
+ [("nfo.root", root_name), ...fields_md]
214
+ catch e do
215
+ log.important(
216
+ label="file.nfo.metadata",
217
+ "Failed to parse nfo file #{string.quote(nfo_file)}: #{e.kind}: #{
218
+ e.message
219
+ }"
220
+ )
221
+ []
222
+ end
223
+ end
224
+ end
225
+
226
+ # Enable `.nfo` sidecar metadata resolver.
227
+ #
228
+ # For a media file `.../Foo.ext`, this looks for a sibling `.../Foo.nfo`.
229
+ #
230
+ # @category Liquidsoap
231
+ # @param ~priority Resolver priority. Default is low to avoid overriding media tags.
232
+ # @param ~mime_types Restrict to these MIME types (or `null` for any).
233
+ # @param ~file_extensions Restrict to these file extensions (or `null` for any).
234
+ # @param ~extra_rules Additional tag rules (prepended to built-ins).
235
+ # @param ~rules Pre-built tag rule table. When provided, `extra_rules` is ignored.
236
+ def enable_nfo_metadata(
237
+ ~priority={0},
238
+ ~mime_types=null,
239
+ ~file_extensions=null,
240
+ ~extra_rules=[],
241
+ ~rules=null
242
+ ) =
243
+ rules =
244
+ null.case(rules, {file.nfo.rules(extra_rules=extra_rules)}, fun (r) -> r)
245
+
246
+ def resolver(~metadata:_, file_name) =
247
+ nfo_file = path.remove_extension(file_name) ^ ".nfo"
248
+ file.nfo.metadata(rules=rules, nfo_file)
249
+ end
250
+
251
+ decoder.metadata.add(
252
+ priority=priority,
253
+ mime_types=mime_types,
254
+ file_extensions=file_extensions,
255
+ "nfo",
256
+ resolver
257
+ )
258
+ end
@@ -0,0 +1,71 @@
1
+ # Determine whether a nullable value is not null.
2
+ # @category Programming
3
+ def _null.defined(x) =
4
+ null.case(x, {false}, fun (_) -> true)
5
+ end
6
+
7
+ # Get the value of a nullable. Raises `error.not_found` if the value is `null`
8
+ # and no default value was specified.
9
+ # @category Programming
10
+ # @param ~default Returned value when the value is `null`.
11
+ def _null.get(~default=null, x) =
12
+ null.case(
13
+ x,
14
+ {
15
+ default
16
+ ?? error.raise(
17
+ error.not_found,
18
+ "no default value for null.get"
19
+ )
20
+ },
21
+ fun (x) -> x
22
+ )
23
+ end
24
+
25
+ # Convert a nullable value to a list containing zero or one element depending on
26
+ # whether the value is null or not.
27
+ # @category Programming
28
+ def _null.to_list(x) =
29
+ null.case(x, {[]}, fun (x) -> [x])
30
+ end
31
+
32
+ # Apply a function on a nullable value if it is not null, and return null
33
+ # otherwise.
34
+ # @category Programming
35
+ def _null.map(f, x) =
36
+ null.case(x, {null}, fun (x) -> f(x))
37
+ end
38
+
39
+ # Find the first element of a list for which the image of the function is not
40
+ # `null`. Raises `error.not_found` if not element is found and no default value
41
+ # was specified.
42
+ # @category Programming
43
+ # @param ~default Returned value when no element is found.
44
+ # @param f Function.
45
+ # @param l List.
46
+ def _null.find(~default=null, f, l) =
47
+ def rec aux(l) =
48
+ f =
49
+ list.case(
50
+ l,
51
+ {
52
+ default
53
+ ?? error.raise(
54
+ error.not_found,
55
+ "no default value for list.find_defined"
56
+ )
57
+ },
58
+ fun (x, l) ->
59
+ {
60
+ begin
61
+ y = f(x)
62
+ if null.defined(y) then y else aux(l) end
63
+ end
64
+ }
65
+ )
66
+
67
+ f()
68
+ end
69
+
70
+ aux(l)
71
+ end