Repository navigation
Expand file tree
/
Copy pathafx.lua
More file actions
705 lines (577 loc) · 18.9 KB
/
Copy pathafx.lua
File metadata and controls
705 lines (577 loc) · 18.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
--[[
AFX.lua - (Audio inFormation eXtractor).lua
Copyright (c) 2025 lambits
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
]]
local insert = table.insert
local concat = table.concat
local char = string.char
local min = math.min
local floor = math.floor
--[[
cursor
Class for emulating built-in Lua file methods on strings.
]]
local cursormeta = {}
cursormeta.__index = cursormeta
function cursormeta:close()
self.string, self.pos, self.buffer, self.vbuf, self.vbufsize = nil, nil, nil, nil, nil
end
function cursormeta:write(...)
local args = {...}
for i = 1, #args do
insert(self.buffer, args[i])
if self.vbuf == "line" and args[i]:find("\n") then
self:flush()
elseif self.vbuf == "full" and #self.vbuf >= self.vbufsize then
self:flush()
end
end
if self.vbuf == "no" then
self:flush()
end
return self
end
function cursormeta:flush()
if #self.buffer <= 0 then return end
local buffer = concat(self.buffer)
self.string = self.string:sub(1, self.pos) .. buffer .. self.string:sub(self.pos + 1, self.pos + 1 + #buffer, #self.string)
self.pos = self.pos + #buffer
for i = 1, #self.buffer do self.buffer[i] = nil end
return self
end
function cursormeta:lines()
return self.string:gmatch("[^\n,]+")
end
function cursormeta:seek(whence, offset)
whence = whence or "cur"
offset = offset or 0
if whence == "set" then
self.pos = offset or self.pos
elseif whence == "cur" then
self.pos = self.pos + offset
elseif whence == "end" then
self.pos = #self.string + offset
end
return self.pos
end
function cursormeta:read(...)
self:flush()
local values, args = {}, {...}
if #args <= 0 then
insert(args, "*l")
end
if self.pos >= #self.string then return nil end
for i = 1, #args do
local arg = args[i]
local n = arg
if n == "*a" or n == "*all" then
n = #self.string
elseif n == "*l" or n == "*line" then
local posix = self.string:find("\n", self.pos)
local windows = self.string:find("\r\n", self.pos)
local newline = #self.string
if windows or posix then
newline = (windows or posix) - 1
end n = (newline or #self.string) - self.pos
end
local value
if n == "*n" or n == "*number" then
value = self.string:match("^%s*([%-%.%d]+)", self.pos)
n = #value
value = tonumber(value)
else
value = self.string:sub(self.pos + 1, self.pos + n)
end
insert(values, value)
self.pos = min(self.pos + n, #self.string)
end
return unpack(values)
end
function cursormeta:setvbuf(mode, size)
if mode ~= "no" and mode ~= "full" and mode ~= "line" then return end
self.vbuf = mode
self.vbufsize = tonumber(size) or self.vbufsize
end
-- cursor constructor
local function cursor(str)
return setmetatable({
pos = 0, string = str, buffer = {}, vbuf = "no", vbufsize = 8
}, cursormeta)
end
--[[
vararg saferead( File file, ... )
Reads the given patterns from the file and throws an error on end of file.
]]
local function saferead(file, ...)
local values = { file:read(...) }
if #values <= 0 then
error("Unexpected EOF", 2)
end
return unpack(values)
end
--[[
string gethtype( File file )
Returns the header type of the given file. It currently only supports 3 different types.
Returns "unknown" on failure to detect any of the supported formats.
]]
local function gethtype(file)
file:seek("end", -128)
if saferead(file, 3) == "TAG" then
return "id3v1"
end
file:seek("end", -10)
if saferead(file, 3) == "3DI" then
return "id3v2" -- ID3v2.4 footer
end
file:seek("set", 0)
local fc = saferead(file, 3)
if fc == "ID3" then
return "id3v2"
end fc = fc .. saferead(file, 1)
if fc == "fLaC" then
return "flac"
elseif fc == "OggS" then
return "ogg"
end
return "unknown"
end
--[[
number uint2num( boolean bigendian, string s, number i = 1, number j = -1 )
Returns an unsigned integer from the given string located at the given bytes.
A value of true indicates that the string should be treat as a big endian integer.
If no arguments are given, it parses the entire string as a number.
]]
local function uint2num(bigendian, s, i, j)
if not s then error("No string given", 2) end
local n = 0
i, j = i or 1, j or -1
local b = { s:byte(i, j) }
local start, finish, increment = #b, 1, -1
if bigendian then
start, finish, increment = 1, #b, 1
end
for k = start, finish, increment do
n = n * 2 ^ 8 + b[k]
end
return n
end
--[[
boolean getbool( boolean bigendian, string str, number bitnum )
Returns a boolean according to the given bit in the given number string.
A value of true indicates that the string should be treated as big endian.
]]
local function getbool(bigendian, str, bitnum)
local byte = floor(bitnum / 8) + 1
local character = str:byte(byte)
local bit = bitnum % 8
if bigendian then
bit = 7 - bit
end
return floor((character % 2 ^ (bit + 1)) / 2 ^ bit) == 1
end
--[[
string, string getkv( string str )
Splits a string into a key and a value at the first equal sign (=) it finds.
Returns the first string found before the equal and the one found after.
]]
local function getkv(str)
if not str then error("No string given", 2) end
local div = str:find("=")
if not div then return end
local key = str:sub(1, div - 1)
local value = str:sub(div + 1)
return key, value
end
--[[
table getvorbis( File file, table meta )
Parses the given file, extracts all Vorbis comments and adds them to "meta".
If no "meta" table is given, it creates one.
]]
local function getvorbis(file, meta)
if not file then error("No file given") end
meta = meta or {}
local _vendor = saferead(file, uint2num(false, saferead(file, 4)))
local uclength = uint2num(false, saferead(file, 4))
for i = 1, uclength do
local length = uint2num(false, saferead(file, 4))
local comment = saferead(file, length)
local key, value = getkv(comment)
key = key:upper()
if not meta[key] then
meta[key] = {}
end
insert(meta[key], value)
end
return meta
end
--[[
boolean, string getflac( File file, table meta )
Parses the given .flac file and extracts all metadata found as Vorbis comments.
Returns false and the error string on failure.
]]
local function getflac(file, meta)
-- error("FLAC is currently unsupported")
if not file then error("No file given") end
if saferead(file, 4) ~= "fLaC" then
return false, "File is not a valid FLAC file"
end
local streaminfo = false
local hasmeta = false
while true do
local header = saferead(file, 4)
local btype = uint2num(true, header, 1, 1) % 128
if btype == 0 then
if streaminfo then return false, "Multiple STREAMINFO metadata blocks found" end
streaminfo = true
elseif btype == 127 then
return false, "Forbidden metadata block type found"
end
if not streaminfo then return false, "STREAMINFO metadata block is not first" end
local bdata = saferead(file, uint2num(true, header, 2, 4))
if btype == 4 then -- Vorbis comments
hasmeta = true
getvorbis(cursor(bdata), meta)
end
if getbool(true, header, 0) then break end
end
return hasmeta
end
--[[
table, table, boolean oggheader( File file, table segments, boolean cont )
boolean, string oggheader( File file, table segments, boolean cont )
Retrieves the segments, header information and continuation of the next header.
Returns false and the error string on failure.
]]
local function oggheader(file, segments, cont)
if not file then error("No file given") end
local lastlen = cont and 255 or nil
segments = segments or {}
if saferead(file, 4) ~= "OggS" then
return false, "File is not a valid OGG file"
end
if saferead(file, 1):byte() ~= 0 then
return false, "Unsupported OGG container version"
end
local header = {
header_type_flag = saferead(file, 1):byte(),
absolute_granule_position = uint2num(false, saferead(file, 8)),
stream_serial_number = uint2num(false, saferead(file, 4)),
page_sequence_no = uint2num(false, saferead(file, 4)),
page_checksum = saferead(file, 4)
}
local page_segments = uint2num(false, saferead(file, 1))
local segment_table = { saferead(file, page_segments):byte(1, -1) }
for i = 1, #segment_table do
local read = saferead(file, segment_table[i]) or ""
if lastlen == 255 then
segments[#segments] = segments[#segments] .. read
else
insert(segments, read)
end lastlen = segment_table[i]
end
return segments, header, lastlen == 255
end
--[[
boolean, string getogg( File file, table meta )
Parses the given .ogg file and extracts all metadata found as Vorbis comments.
Returns false and the error string on failure.
]]
local function getogg(file, meta)
-- error("OGG is currently unsupported")
if not file then error("No file given") end
local hasmeta = false
while true do
local segments, header, more
while true do
segments, header, more = oggheader(file, segments, more)
if not segments and header then return false, header end
if not more then break end
end
local seg = segments[1]
if seg:sub(2, 7) ~= "vorbis" then
return false, "File is not an Ogg Vorbis file"
elseif seg:byte() == 3 then
hasmeta = true
local sd = cursor(seg)
sd:seek("set", 7)
getvorbis(sd, meta)
break
end
end
return hasmeta
end
--[[
string cstring( string str )
Takes a string and cuts it off at the first null byte.
]]
local function cstring(str)
if not str then error("No string given", 2) end
local nullpos = str:find("\0")
if not nullpos then return str end
return str:sub(1, nullpos - 1)
end
--[[
boolean, string getid3v1( File file, table meta )
Parses the given ID3v1 tags and extracts all metadata.
Returns false and the error string on failure.
]]
local function getid3v1(file, meta)
-- error("ID3v1 is currently unsupported")
if not file then error("No file given") end
file:seek("end", -128)
if file:read(3) ~= "TAG" then return false, "Invalid ID3v1 tags" end
meta["TITLE"] = { cstring(saferead(file, 30)) }
meta["ARTIST"] = { cstring(saferead(file, 30)) }
meta["ALBUM"] = { cstring(saferead(file, 30)) }
meta["YEAR"] = { saferead(file, 4) }
local comment = saferead(file, 28)
local comlast = saferead(file, 1)
if comlast == "\0" then
file:seek("end", -29)
meta["COMMENT"] = { cstring(comment) }
local track = saferead(file, 1):byte()
if track ~= 0 then meta.tracknumber = { track } end
else
meta["COMMENT"] = { cstring(comment .. comlast .. saferead(file, 1)) }
end
meta["GENRE"] = { saferead(file, 1):byte() }
return true -- If the tags even exist, always confirm their existence.
end
--[[
number syncsafe( string str )
Returns a number with every 7th byte zeroed out.
This is only used in ID3v2 tag parsing.
]]
local function syncsafe(str)
local a, b, c, d = str:byte(1, 4)
return (a * 2097152) + (b * 16384) + (c * 128) + d
end
--[[
string utf16to8( string str, boolean endian )
Converts the given UTF-16 string to a UTF-8 string.
]]
local function utf16to8(str, endian)
local utf8 = ""
local i = 1
if endian == nil and #str >= 2 then
local bom = uint2num(true, str, 1, 2)
if bom == 0xFEFF then
endian = true
i = 3
elseif bom == 0xFFFE then
endian = false
i = 3
end
end
for j = i, #str - 1, 2 do
local codepoint = uint2num(endian, str, j, j + 1)
if codepoint >= 0xD800 and codepoint <= 0xDBFF and j + 2 <= #str then
local low = uint2num(endian, str, j + 2, j + 3)
if low >= 0xDC00 and low <= 0xDFFF then
codepoint = 0x10000 + (codepoint - 0xD800) * 0x400 + (low - 0xDC00)
j = j + 2
end
end
if codepoint < 0x80 then
utf8 = utf8 .. char(codepoint)
elseif codepoint < 0x800 then
utf8 = utf8 .. char(0xC0 + floor(codepoint / 64)) .. char(0x80 + (codepoint % 64))
elseif codepoint < 0x10000 then
utf8 = utf8 .. char(0xE0 + floor(codepoint / 4096))
utf8 = utf8 .. char(0x80 + floor((codepoint % 4096) / 64))
utf8 = utf8 .. char(0x80 + (codepoint % 64))
else
utf8 = utf8 .. char(0xF0 + floor(codepoint / 262144))
utf8 = utf8 .. char(0x80 + floor((codepoint % 262144) / 4096))
utf8 = utf8 .. char(0x80 + floor((codepoint % 4096) / 64))
utf8 = utf8 .. char(0x80 + (codepoint % 64))
end
end
return utf8
end
--[[
string id3encode( string str, number encoding )
Encodes the given string in a different format.
]]
local function id3encode(str, encoding)
local result
if encoding == 0 then
result = str -- ISO-8859-1
elseif encoding == 1 then
result = utf16to8(str) -- UTF-16 with BOM
elseif encoding == 2 then
result = utf16to8(str, true) -- UTF-16
elseif encoding == 3 then
result = str -- UTF-8
end
if result and result:sub(-1) == "\0" then
result = result:sub(1, -2)
end return result
end
--[[
string zlibdecmp( string str, number length = #str )
Decompresses the given zlib compressed string.
Throws an error if not running under LOVE or is not implemented.
]]
local function zlibdecmp(str, length)
if love and love.data then
return love.data.decompress("string", "zlib", str:sub(1, length))
else
error("zlib decompression not implemented")
end
end
--[[
table getid3frame( File file, number version )
boolean, string getid3frame( File file, number version )
Parses a singular ID3 frame according to the given version and returns all relevant information.
Returns false and an error string on failure.
]]
local function getid3frame(file, version)
local result = {}
result.key = saferead(file, 3)
if version >= 3 then
result.key = result.key .. saferead(file, 1)
end
if version == 4 then
local size = syncsafe(saferead(file, 4))
local statusflags = saferead(file, 1)
result.readonly = getbool(true, statusflags, 3)
local formatflags = saferead(file, 1)
result.hasgroupinfo = getbool(true, formatflags, 1)
result.compressed = getbool(true, formatflags, 4)
result.encrypted = getbool(true, formatflags, 5)
result.unsynced = getbool(true, formatflags, 6)
result.hasdatalength = getbool(true, formatflags, 7)
if result.hasdatalength then
result.length = syncsafe(saferead(file, 4))
size = size - 4
end
if result.encrypted then
result.encryptmethod = saferead(file, 1):byte()
size = size - 1
end
if result.hasgroupinfo then
result.groupid = saferead(file, 1):byte()
size = size - 1
end
result.content = saferead(file, size)
elseif version == 3 then
local size = uint2num(true, saferead(file, 4))
local statusflags = saferead(file, 1)
result.readonly = getbool(true, statusflags, 2)
local formatflags = saferead(file, 1)
result.compressed = getbool(true, formatflags, 0)
result.encrypted = getbool(true, formatflags, 1)
result.hasgroupinfo = getbool(true, formatflags, 2)
if result.compressed then
result.length = uint2num(true, saferead(file, 4))
size = size - 4
end
if result.encrypted then
result.encryptmethod = saferead(file, 1):byte()
size = size - 1
end
if result.hasgroupinfo then
result.groupid = saferead(file, 1):byte()
size = size - 1
end
result.content = saferead(file, size)
elseif version == 2 then
local size = uint2num(true, saferead(file, 3))
result.content = saferead(file, size)
else
return false, "Invalid ID3v2 version"
end
if result.unsynced then
result.content = result.content:gsub("\255%z(.)" , "\255%1")
end
if result.compressed then
result.content = zlibdecmp(result.content, result.length)
end
return result
end
--[[
boolean, string getid3v2( File file, table meta )
Parses the given ID3v2 tags and extracts all metadata.
Returns false and the error string on failure.
]]
local function getid3v2(file, meta)
-- error("ID3v2 is currently unsupported")
if not file then error("No file given") end
if saferead(file, 3) ~= "ID3" then return false, "Invalid ID3v2 tags" end
local major, _revision = saferead(file, 2):byte(1, 2)
local flags = saferead(file, 1)
local size = syncsafe(saferead(file, 4))
if getbool(true, flags, 6) then -- Not parsing the extended header.
local headersize = syncsafe(saferead(file, 4))
if version >= 4 then
headersize = headersize - 4
end
if headersize >= 2 then return false, "Invalid ID3v2 extended header size" end
file:seek("cur", headersize)
size = size - headersize - 4
end
local _footer = getbool(true, flags, 4) -- Unused.
while file:seek() < size do
local frame = getid3frame(file, major)
local key, value = frame.key, frame.content
if key:sub(1, 1) == "T" and key ~= "TXX" and key ~= "TXXX" then
local encoding = value:byte()
value = id3encode(value:sub(2, -1), encoding)
meta[key] = { value }
end
end
return true
end
--[[
table ( File file, table meta, string htype )
table ( string file, table meta, string htype )
Returns the metadata from the given file.
If "file" is a string, it will be converted to a file automatically.
]]
return function(file, meta, htype)
local object = file
if type(object) == "string" then
if love and love.filesystem then
local exists = love.filesystem.getInfo(file, "file")
if not exists then error("afx.lua i/o error: File \"" .. file .. "\" not found") end
object = cursor(love.filesystem.read("data", file):getString())
else
local obj, err = io.open(file, "rb")
if not obj then error("afx.lua i/o error: " .. err) end
object = obj
end
end
htype = htype or gethtype(object)
object:seek("set", 0)
meta = meta or {}
local status, err = false
if htype == "id3v2" then
status, err = getid3v2(object, meta)
elseif htype == "id3v1" then
status, err = getid3v1(object, meta)
elseif htype == "ogg" then
status, err = getogg(object, meta)
elseif htype == "flac" then
status, err = getflac(object, meta)
else
error("afx.lua error: Unsupported metadata type")
end
if not status and err then error("afx.lua parsing error: " .. err) end
return meta
end