A modern Music Player Daemon based on Rockbox open source high quality audio player
libadwaita
audio
rust
zig
deno
mpris
rockbox
mpd
1--[[ Lua RB Random Playlist -- random_playlist.lua V 1.0
2/***************************************************************************
3 * __________ __ ___.
4 * Open \______ \ ____ ____ | | _\_ |__ _______ ___
5 * Source | _// _ \_/ ___\| |/ /| __ \ / _ \ \/ /
6 * Jukebox | | ( <_> ) \___| < | \_\ ( <_> > < <
7 * Firmware |____|_ /\____/ \___ >__|_ \|___ /\____/__/\_ \
8 * \/ \/ \/ \/ \/
9 * $Id$
10 *
11 * Copyright (C) 2021 William Wilgus
12 *
13 * This program is free software; you can redistribute it and/or
14 * modify it under the terms of the GNU General Public License
15 * as published by the Free Software Foundation; either version 2
16 * of the License, or (at your option) any later version.
17 *
18 * This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
19 * KIND, either express or implied.
20 *
21 ****************************************************************************/
22]]
23--[[ random_playlist
24 This script opens the users database file containg track path + filenames
25 first it reads the database file making an index of tracks
26 [for large playlists it only saves an index every [10|100|1000] tracks.
27 tracks will be incrementally loaded along with the results of the entries
28 traversed but the garbage collector will erase them when needed]
29
30 next tracks are choosen at random and added either to an in-ram playlist
31 using plugin functions OR
32 to a on disk playlist using a table as a write buffer
33 the user can also choose to play the playlist in either case
34]]
35
36require ("actions")
37require("dbgettags")
38get_tags = nil -- unneeded
39
40-- User defaults
41local playlistpath = "/Playlists"
42local max_tracks = 500; -- size of playlist to create
43local min_repeat = 500; -- this many songs before a repeat
44local play_on_success = true;
45local playlist_name = "random_playback.m3u8"
46--program vars
47local playlist_handle
48local t_playlistbuf -- table for playlist write buffer
49
50-- Random integer function
51local random = math.random; -- ref random(min, max)
52math.randomseed(rb.current_tick()); -- some kind of randomness
53
54-- Button definitions
55local CANCEL_BUTTON = rb.actions.PLA_CANCEL
56local OK_BUTTON = rb.actions.PLA_SELECT
57local ADD_BUTTON = rb.actions.PLA_UP
58local ADD_BUTTON_RPT = rb.actions.PLA_UP_REPEAT or ADD_BUTTON
59local SUB_BUTTON = rb.actions.PLA_DOWN
60local SUB_BUTTON_RPT = rb.actions.PLA_DOWN_REPEAT or SUB_BUTTON
61-- remove action and context tables to free some ram
62rb.actions = nil
63rb.contexts = nil
64-- Program strings
65local sINITDATABASE = "Initialize Database"
66local sHEADERTEXT = "Random Playlist"
67local sPLAYLISTERROR = "Playlist Error!"
68local sSEARCHINGFILES = "Searching for Files.."
69local sERROROPENFMT = "Error Opening %s"
70local sINVALIDDBFMT = "Invalid Database %s"
71local sPROGRESSHDRFMT = "%d \\ %d Tracks"
72local sGOODBYE = "Goodbye"
73
74-- Gets size of text
75local function text_extent(msg, font)
76 font = font or rb.FONT_UI
77 return rb.font_getstringsize(msg, font)
78end
79
80local function _setup_random_playlist(tag_entries, play, savepl, min_repeat, trackcount)
81 -- Setup string tables
82 local tPLAYTEXT = {"Play? [ %s ] (up/dn)", "true = play tracks on success"}
83 local tSAVETEXT = {"Save to disk? [ %s ] (up/dn)",
84 "true = tracks saved to",
85 playlist_name};
86 local tREPEATTEXT = {"Repeat hist? [ %d ] (up/dn)","higher = less repeated songs"}
87 local tPLSIZETEXT = {"Find [ %d ] tracks? (up/dn)",
88 "Warning may overwrite dynamic playlist",
89 "Press back to cancel"};
90 -- how many lines can we fit on the screen?
91 local res, w, h = text_extent("I")
92 h = h + 5 -- increase spacing in the setup menu
93 local max_w = rb.LCD_WIDTH / w
94 local max_h = rb.LCD_HEIGHT - h
95 local y = 0
96
97 -- User Setup Menu
98 local action, ask, increment
99 local t_desc = {scroll = true} -- scroll the setup items
100
101 -- Clears screen and adds title and icon, called first..
102 function show_setup_header()
103 local desc = {icon = 2, show_icons = true, scroll = true} -- 2 == Icon_Playlist
104 rb.lcd_clear_display()
105 rb.lcd_put_line(1, 0, sHEADERTEXT, desc)
106 end
107
108 -- Display up to 3 items and waits for user action -- returns action
109 function ask_user_action(desc, ln1, ln2, ln3)
110 if ln1 then rb.lcd_put_line(1, h, ln1, desc) end
111 if ln2 then rb.lcd_put_line(1, h + h, ln2, desc) end
112 if ln3 then rb.lcd_put_line(1, h + h + h, ln3, desc) end
113 rb.lcd_hline(1,rb.LCD_WIDTH - 1, h - 5);
114 rb.lcd_update()
115
116 local act = rb.get_plugin_action(-1); -- Blocking wait for action
117 -- handle magnitude of the increment here so consumer fn doesn't need to
118 if act == ADD_BUTTON_RPT and act ~= ADD_BUTTON then
119 increment = increment + 1
120 if increment > 1000 then increment = 1000 end
121 act = ADD_BUTTON
122 elseif act == SUB_BUTTON_RPT and act ~= SUB_BUTTON then
123 increment = increment + 1
124 if increment > 1000 then increment = 1000 end
125 act = SUB_BUTTON
126 else
127 increment = 1;
128 end
129
130 return act
131 end
132
133 -- Play the playlist on successful completion true/false?
134 function setup_get_play()
135 action = ask_user_action(tdesc,
136 string.format(tPLAYTEXT[1], tostring(play)),
137 tPLAYTEXT[2]);
138 if action == ADD_BUTTON then
139 play = true
140 elseif action == SUB_BUTTON then
141 play = false
142 end
143 end
144
145 -- Save the playlist to disk true/false?
146 function setup_get_save()
147 action = ask_user_action(tdesc,
148 string.format(tSAVETEXT[1], tostring(savepl)),
149 tSAVETEXT[2], tSAVETEXT[3]);
150 if action == ADD_BUTTON then
151 savepl = true
152 elseif action == SUB_BUTTON then
153 savepl = false
154 elseif action == OK_BUTTON then
155 ask = setup_get_play;
156 setup_get_save = nil
157 action = 0
158 end
159 end
160
161 -- Repeat song buffer list of previously added tracks 0-??
162 function setup_get_repeat()
163 if min_repeat >= trackcount then min_repeat = trackcount - 1 end
164 if min_repeat >= tag_entries then min_repeat = tag_entries - 1 end
165 action = ask_user_action(t_desc,
166 string.format(tREPEATTEXT[1],min_repeat),
167 tREPEATTEXT[2]);
168 if action == ADD_BUTTON then
169 min_repeat = min_repeat + increment
170 elseif action == SUB_BUTTON then -- MORE REPEATS LESS RAM USED
171 if min_repeat < increment then increment = 1 end
172 min_repeat = min_repeat - increment
173 if min_repeat < 0 then min_repeat = 0 end
174 elseif action == OK_BUTTON then
175 ask = setup_get_save;
176 setup_get_repeat = nil
177 action = 0
178 end
179 end
180
181 -- How many tracks to find
182 function setup_get_playlist_size()
183 action = ask_user_action(t_desc,
184 string.format(tPLSIZETEXT[1], trackcount),
185 tPLSIZETEXT[2],
186 tPLSIZETEXT[3]);
187 if action == ADD_BUTTON then
188 trackcount = trackcount + increment
189 elseif action == SUB_BUTTON then
190 if trackcount < increment then increment = 1 end
191 trackcount = trackcount - increment
192 if trackcount < 1 then trackcount = 1 end
193 elseif action == OK_BUTTON then
194 ask = setup_get_repeat;
195 setup_get_playlist_size = nil
196 action = 0
197 end
198 end
199 ask = setup_get_playlist_size; -- \!FIRSTRUN!/
200
201 repeat -- SETUP MENU LOOP
202 show_setup_header()
203 ask()
204 rb.lcd_scroll_stop() -- I'm still wary of not doing this..
205 collectgarbage("collect")
206 if action == CANCEL_BUTTON then rb.lcd_scroll_stop(); return nil end
207 until (action == OK_BUTTON)
208
209 return play, savepl, min_repeat, trackcount;
210end
211--[[ manually create a playlist
212playlist is created initially by creating a new file (or erasing old)
213and adding the BOM]]
214--deletes existing file and creates a new playlist
215local function playlist_create(filename)
216 local filehandle = io.open(filename, "w+") --overwrite
217 if not filehandle then
218 rb.splash(rb.HZ, "Error opening " .. filename)
219 return false
220 end
221 t_playlistbuf = {}
222 filehandle:write("\239\187\191") -- Write BOM --"\xEF\xBB\xBF"
223 playlist_handle = filehandle
224 return true
225end
226
227-- writes track path to a buffer must be later flushed to playlist file
228local function playlist_insert(trackpath)
229 local bufp = #t_playlistbuf + 1
230 t_playlistbuf[bufp] = trackpath
231 bufp = bufp + 1
232 t_playlistbuf[bufp] = "\n"
233 return bufp
234end
235
236-- flushes playlist buffer to file
237local function playlist_flush()
238 playlist_handle:write(table.concat(t_playlistbuf))
239 t_playlistbuf = {}
240end
241
242-- closes playlist file descriptor
243local function playlist_finalize()
244 playlist_handle:close()
245 return true
246end
247
248--[[ Given the filenameDB file [database]
249 creates a random dynamic playlist with a default savename of [playlist]
250 containing [trackcount] tracks, played on completion if [play] is true]]
251local function create_random_playlist(database, playlist, trackcount, play, savepl)
252 if not database or not playlist or not trackcount then return end
253 if not play then play = false end
254 if not savepl then savepl = false end
255
256 local playlist_handle
257 local playlistisfinalized = false
258 local file = io.open('/' .. database or "", "r") --read
259 if not file then rb.splash(100, string.format(sERROROPENFMT, database)) return end
260
261 local fsz = file:seek("end")
262 local fbegin
263 local posln = 0
264 local tag_len = TCHSIZE
265
266 local anchor_index
267 local ANCHOR_INTV
268 local track_index = setmetatable({},{__mode = "v"}) --[[ weak table values
269 this allows them to be garbage collected as space is needed / rebuilt as needed ]]
270
271 -- Read character function sets posln as file position
272 function readchrs(count)
273 if posln >= fsz then return nil end
274 file:seek("set", posln)
275 posln = posln + count
276 return file:read(count)
277 end
278
279 -- Check the header and get size + #entries
280 local tagcache_header = readchrs(DATASZ) or ""
281 local tagcache_sz = readchrs(DATASZ) or ""
282 local tagcache_entries = readchrs(DATASZ) or ""
283
284 if tagcache_header ~= sTCHEADER or
285 bytesLE_n(tagcache_sz) ~= (fsz - TCHSIZE) then
286 rb.splash(100, string.format(sINVALIDDBFMT, database))
287 return
288 end
289
290 local tag_entries = bytesLE_n(tagcache_entries)
291
292 play, savepl, min_repeat, trackcount = _setup_random_playlist(
293 tag_entries, play, savepl, min_repeat, trackcount);
294 _setup_random_playlist = nil
295
296 if savepl == false then
297 -- Use the rockbox playlist functions to add tracks to in-ram playlist
298 playlist_create = function(filename)
299 return (rb.playlist("create", playlistpath .. "/", playlist) >= 0)
300 end
301 playlist_insert = function(str)
302 return rb.playlist("insert_track", str)
303 end
304 playlist_flush = function() end
305 playlist_finalize = function()
306 return (rb.playlist("amount") >= trackcount)
307 end
308 end
309 if not playlist_create(playlistpath .. "/" .. playlist) then return end
310 collectgarbage("collect")
311
312 -- how many lines can we fit on the screen?
313 local res, w, h = text_extent("I")
314 local max_w = rb.LCD_WIDTH / w
315 local max_h = rb.LCD_HEIGHT - h
316 local y = 0
317 rb.lcd_clear_display()
318
319 function get_tracks_random()
320 local tries, idxp
321
322 local tracks = 0
323 local str = ""
324 local t_lru = {}
325 local lru_widx = 1
326 local lru_max = min_repeat
327 if lru_max >= tag_entries then lru_max = tag_entries / 2 + 1 end
328
329 function do_progress_header()
330 rb.lcd_put_line(1, 0, string.format(sPROGRESSHDRFMT,tracks, trackcount))
331 rb.lcd_update()
332 --rb.sleep(300)
333 end
334
335 function show_progress()
336 local sdisp = str:match("([^/]+)$") or "?" --just the track name
337 rb.lcd_put_line(1, y, sdisp:sub(1, max_w));-- limit string length
338 y = y + h
339 if y >= max_h then
340 do_progress_header()
341 rb.lcd_clear_display()
342 playlist_flush(playlist_handle)
343 rb.yield()
344 y = h
345 end
346 end
347
348 -- check for repeated tracks
349 function check_lru(val)
350 if lru_max <= 0 or val == nil then return 0 end --user wants all repeats
351 local rv
352 local i = 1
353 repeat
354 rv = t_lru[i]
355 if rv == nil then
356 break;
357 elseif rv == val then
358 return i
359 end
360 i = i + 1
361 until (i == lru_max)
362 return 0
363 end
364
365 -- add a track to the repeat list (overwrites oldest if full)
366 function push_lru(val)
367 t_lru[lru_widx] = val
368 lru_widx = lru_widx + 1
369 if lru_widx > lru_max then lru_widx = 1 end
370 end
371
372 function get_index()
373 if ANCHOR_INTV > 1 then
374 get_index =
375 function(plidx)
376 local p = track_index[plidx]
377 if p == nil then
378 parse_database_offsets(plidx)
379 end
380 return track_index[plidx][1]
381 end
382 else -- all tracks are indexed
383 get_index =
384 function(plidx)
385 return track_index[plidx]
386 end
387 end
388 end
389
390 get_index() --init get_index fn
391 -- Playlist insert loop
392 while true do
393 str = nil
394 tries = 0
395 repeat
396 idxp = random(1, tag_entries)
397 tries = tries + 1 -- prevent endless loops
398 until check_lru(idxp) == 0 or tries > fsz -- check for recent repeats
399
400 posln = get_index(idxp)
401
402 tag_len = bytesLE_n(readchrs(DATASZ))
403 posln = posln + DATASZ -- idx = bytesLE_n(readchrs(DATASZ))
404 str = readchrs(tag_len) or "\0" -- Read the database string
405 str = str:match("^(%Z+)%z$") -- \0 terminated string
406
407 -- Insert track into playlist
408 if str ~= nil then
409 tracks = tracks + 1
410 show_progress()
411 push_lru(idxp) -- add to repeat list
412 if playlist_insert(str) < 0 then
413 rb.sleep(rb.HZ) --rb playlist fn display own message wait for that
414 rb.splash(rb.HZ, sPLAYLISTERROR)
415 break; -- ERROR, PLAYLIST FULL?
416 end
417 end
418
419 if tracks >= trackcount then
420 playlist_flush()
421 do_progress_header()
422 break
423 end
424
425 -- check for cancel non-blocking
426 if rb.get_plugin_action(0) == CANCEL_BUTTON then
427 break
428 end
429 end
430 end -- get_files
431
432 function build_anchor_index()
433 -- index every n files
434 ANCHOR_INTV = 1 -- for small db we can put all the entries in ram
435 local ent = tag_entries / 100 -- more than 1000 will be incrementally loaded
436 while ent >= 10 do -- need to reduce the size of the anchor index?
437 ent = ent / 10
438 ANCHOR_INTV = ANCHOR_INTV * 10
439 end -- should be power of 10 (10, 100, 1000..)
440 --grab an index for every ANCHOR_INTV entries
441 local aidx={}
442 local acount = 0
443 local next_idx = 1
444 local index = 1
445 local tlen
446 if ANCHOR_INTV == 1 then acount = 1 end
447 while index <= tag_entries and posln < fsz do
448 if next_idx == index then
449 acount = acount + 1
450 next_idx = acount * ANCHOR_INTV
451 aidx[index] = posln
452 else -- fill the weak table, we already did the work afterall
453 track_index[index] = {posln} -- put vals inside table to make them collectable
454 end
455 index = index + 1
456 tlen = bytesLE_n(readchrs(DATASZ))
457 posln = posln + tlen + DATASZ
458 end
459 return aidx
460 end
461
462 function parse_database_offsets(plidx)
463 local tlen
464 -- round to nearest anchor entry that is less than plidx
465 local aidx = (plidx / ANCHOR_INTV) * ANCHOR_INTV
466 local cidx = aidx
467 track_index[cidx] = {anchor_index[aidx] or fbegin};
468 -- maybe we can use previous work to get closer to the desired offset
469 while track_index[cidx] ~= nil and cidx <= plidx do
470 cidx = cidx + 1 --keep seeking till we find an empty entry
471 end
472 posln = track_index[cidx - 1][1]
473 while cidx <= plidx do --[[ walk the remaining entries from the last known
474 & save the entries on the way to our desired entry ]]
475 tlen = bytesLE_n(readchrs(DATASZ))
476 posln = posln + tlen + DATASZ
477 track_index[cidx] = {posln} -- put vals inside table to make them collectable
478 if posln >= fsz then posln = fbegin end
479 cidx = cidx + 1
480 end
481 end
482
483 if trackcount ~= nil then
484 rb.splash(10, sSEARCHINGFILES)
485 fbegin = posln --Mark the beginning for later loops
486 tag_len = 0
487 anchor_index = build_anchor_index() -- index track offsets
488 if ANCHOR_INTV == 1 then
489 -- all track indexes are in ram
490 track_index = anchor_index
491 anchor_index = nil
492 end
493--[[ --profiling
494 local starttime = rb.current_tick();
495 get_tracks_random()
496 local endtime = rb.current_tick();
497 rb.splash(1000, (endtime - starttime) .. " ticks");
498 end
499 if (false) then
500--]]
501 get_tracks_random()
502 playlistisfinalized = playlist_finalize(playlist_handle)
503 end
504
505 file:close()
506 collectgarbage("collect")
507 if trackcount and play == true and playlistisfinalized == true then
508 rb.audio("stop")
509 rb.yield()
510 if savepl == true then
511 rb.playlist("create", playlistpath .. "/", playlist)
512 rb.playlist("insert_playlist", playlistpath .. "/" .. playlist)
513 rb.sleep(rb.HZ)
514 end
515 rb.playlist("start", 0, 0, 0)
516 end
517
518end -- playlist_create
519
520local function main()
521 if not rb.file_exists(rb.ROCKBOX_DIR .. "/database_4.tcd") then
522 rb.splash(rb.HZ, sINITDATABASE)
523 os.exit(1);
524 end
525 if rb.cpu_boost then rb.cpu_boost(true) end
526 rb.backlight_force_on()
527 if not rb.dir_exists(playlistpath) then
528 luadir.mkdir(playlistpath)
529 end
530 rb.lcd_clear_display()
531 rb.lcd_update()
532 collectgarbage("collect")
533 create_random_playlist(rb.ROCKBOX_DIR .. "/database_4.tcd",
534 playlist_name, max_tracks, play_on_success);
535 -- Restore user backlight settings
536 rb.backlight_use_settings()
537 if rb.cpu_boost then rb.cpu_boost(false) end
538 rb.sleep(rb.HZ)
539 rb.splash(rb.HZ * 2, sGOODBYE)
540--[[
541local used, allocd, free = rb.mem_stats()
542local lu = collectgarbage("count")
543local fmt = function(t, v) return string.format("%s: %d Kb\n", t, v /1024) end
544
545-- this is how lua recommends to concat strings rather than ..
546local s_t = {}
547s_t[1] = "rockbox:\n"
548s_t[2] = fmt("Used ", used)
549s_t[3] = fmt("Allocd ", allocd)
550s_t[4] = fmt("Free ", free)
551s_t[5] = "\nlua:\n"
552s_t[6] = fmt("Used", lu * 1024)
553s_t[7] = "\n\nNote that the rockbox used count is a high watermark"
554rb.splash_scroller(10 * rb.HZ, table.concat(s_t)) --]]
555
556end --MAIN
557
558main() -- BILGUS