A modern Music Player Daemon based on Rockbox open source high quality audio player
libadwaita audio rust zig deno mpris rockbox mpd
at master 558 lines 20 kB view raw
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