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