--------------------------------------------------------------------------- -- @author Alexander Yakushev -- @copyright 2011 Alexander Yakushev -- @release v1.1.5 --------------------------------------------------------------------------- -- Grab environment local os = os local awful = awful local string = string local table = table local io = io local pairs = pairs local type = type local assert = assert local print = print local tonumber = tonumber local math = math local tostring = tostring local asyncshell = asyncshell module('jamendo') -- UTILITY STUFF -- Checks whether file specified by filename exists. local function file_exists(filename, mode) mode = mode or 'r' f = io.open(filename, mode) if f then f:close() return true else return false end end -- Global variables FORMAT_MP3 = { display = "MP3 (128k)", short_display = "MP3", value = "mp31" } FORMAT_OGG = { display = "Ogg Vorbis (q4)", short_display = "Ogg", value = "ogg2" } ORDER_RATINGDAILY = { display = "Daily rating", short_display = "daily rating", value = "ratingday_desc" } ORDER_RATINGWEEKLY = { display = "Weekly rating", short_display = "weekly rating", value = "ratingweek_desc" } ORDER_RATINGTOTAL = { display = "All time rating", short_display = "all time rating", value = "ratingtotal_desc" } ORDER_RANDOM = { display = "Random", short_display = "random", value = "random_desc" } ORDER_RELEVANCE = { display = "None (consecutive)", short_display = "none", value = "searchweight_desc" } SEARCH_ARTIST = { display = "Artist", unit = "artist", value = "artist_id" } SEARCH_ALBUM = { display = "Album", unit = "album", value = "album_id" } SEARCH_TAG = { display = "Tag", unit = "tag", value = "tag_id" } ALL_FORMATS = { FORMAT_MP3, FORMAT_OGG } ALL_ORDERS = { ORDER_RELEVANCE, ORDER_RANDOM, ORDER_RATINGDAILY, ORDER_RATINGWEEKLY, ORDER_RATINGTOTAL } current_request_table = { unit = "track", fields = {"id", "artist_url", "artist_name", "name", "stream", "album_image", "album_name" }, joins = { "track_album", "album_artist" }, params = { streamencoding = FORMAT_MP3, order = ORDER_RATINGWEEKLY, n = 100 }} -- Local variables local jamendo_list = {} local cache_file = awful.util.getdir ("cache").."/jamendo_cache" local cache_header = "[version=1.1.0]" local album_covers_folder = awful.util.getdir("cache") .. "/jamendo_covers/" local default_mp3_stream = nil local search_template = { fields = { "id", "name" }, joins = {}, params = { order = ORDER_RELEVANCE, n = 1}} -- DEPRECATED. Will be removed in the next major release. -- Returns default stream number for MP3 format. Requests API for it -- not more often than every hour. local function get_default_mp3_stream() if not default_mp3_stream or (os.time() - default_mp3_stream.last_checked) > 3600 then local trygetlink = perform_request("echo $(curl -w %{redirect_url} " .. "'http://api.jamendo.com/get2/stream/track/redirect/" .. "?streamencoding="..FORMAT_MP3.value.."&id=729304')") local _, _, prefix = string.find(trygetlink,"stream(%d+)\.jamendo\.com") default_mp3_stream = { id = prefix, last_checked = os.time() } end return default_mp3_stream.id end -- Returns the track ID from the given link to Jamendo stream. If the -- given text is not the Jamendo stream returns nil. function get_id_from_link(link) local _, _, id = string.find(link,"storage%-new.newjamendo.com%?trackid=(%d+)") return id end -- Returns link to music stream for the given track ID. Uses MP3 -- format and the default stream for it. local function get_link_by_id(id) -- This function is subject to change in the future. return string.format("http://storage-new.newjamendo.com?trackid=%s&format=mp31&u=0", id) end -- -- Returns the album id for given music stream. -- function get_album_id_by_link(link) -- local id = get_id_from_link(link, true) -- if id and jamendo_list[id] then -- return jamendo_list[id].album_id -- end -- end -- Returns the track table for the given music stream. function get_track_by_link(link) local id = get_id_from_link(link, true) if id and jamendo_list[id] then return jamendo_list[id] end end -- If a track is actually a Jamendo stream, replace it with normal -- track name. function replace_link(track_name) local track = get_track_by_link(track_name) if track then return track.display_name else return track_name end end -- Returns table of track IDs, names and other things based on the -- request table. function return_track_table(request_table) local req_string = form_request(request_table) local response = perform_request(req_string) if not response then return nil -- Bad internet connection end parse_table = parse_json(response) for i = 1, table.getn(parse_table) do if parse_table[i].stream == "" then -- Some songs don't have Ogg stream, use MP3 instead parse_table[i].stream = get_link_by_id(parse_table[i].id) end _, _, parse_table[i].artist_link_name = string.find(parse_table[i].artist_url, "\\/artist\\/(.+)") -- Remove Jamendo escape slashes parse_table[i].artist_name = string.gsub(parse_table[i].artist_name, "\\/", "/") parse_table[i].name = string.gsub(parse_table[i].name, "\\/", "/") parse_table[i].display_name = parse_table[i].artist_name .. " - " .. parse_table[i].name -- Do Jamendo a favor, extract album_id for the track yourself -- from album_image link :) local _, _, album_id = string.find(parse_table[i].album_image, "\\/(%d+)\\/covers") parse_table[i].album_id = album_id or 0 -- Save fetched tracks for further caching jamendo_list[parse_table[i].id] = parse_table[i] end save_cache() return parse_table end -- Generates the request to Jamendo API based on provided request -- table. If request_table is nil, uses current_request_table instead. -- For all values that do not exist in request_table use ones from -- current_request_table. -- return - HTTP-request function form_request(request_table) local curl_str = "curl -A 'Mozilla/4.0' -fsm 5 \"%s\"" local url = "http://api.jamendo.com/en/?m=get2%s%s" request_table = request_table or current_request_table local fields = request_table.fields or current_request_table.fields local joins = request_table.joins or current_request_table.joins local unit = request_table.unit or current_request_table.unit -- Form field&joins string (like field1+field2+fieldN%2Fjoin+) local fnj_string = "&m_params=" for i = 1, table.getn(fields) do fnj_string = fnj_string .. fields[i] .. "+" end fnj_string = string.sub(fnj_string,1,string.len(fnj_string)-1) fnj_string = fnj_string .. "%2F" .. unit .. "%2Fjson%2F" for i = 1, table.getn(joins) do fnj_string = fnj_string .. joins[i] .. "+" end fnj_string = fnj_string .. "%2F" local params = {} -- If parameters where supplied in request_table, add them to the -- parameters in current_request_table. if request_table.params and request_table.params ~= current_request_table.params then -- First fill params with current_request_table parameters for k, v in pairs(current_request_table.params) do params[k] = v end -- Then add and overwrite them with request_table parameters for k, v in pairs(request_table.params) do params[k] = v end else -- Or just use current_request_table.params params = current_request_table.params end -- Form parameter string (like param1=value1¶m2=value2) local param_string = "" for k, v in pairs(params) do if type(v) == "table" then v = v.value end v = string.gsub(v, " ", "+") param_string = param_string .. "&" .. k .. "=" .. v end return string.format(curl_str, string.format(url, fnj_string, param_string)) end -- Primitive function for parsing Jamendo API JSON response. Does not -- support arrays. Supports only strings and numbers as values. -- Provides basic safety (correctly handles special symbols like comma -- and curly brackets inside strings) -- text - JSON text function parse_json(text) local parse_table = {} local block = {} local i = 0 local inblock = false local instring = false local curr_key = nil local curr_val = nil while i and i < string.len(text) do if not inblock then -- We are not inside the block, find next { i = string.find(text, "{", i+1) inblock = true block = {} else if not curr_key then -- We haven't found key yet if not instring then -- We are not in string, check for more tags local j = string.find(text, '"', i+1) local k = string.find(text, '}', i+1) if j and j < k then -- There are more tags in this block i = j instring = true else -- Block is over, we found its ending i = k inblock = false table.insert(parse_table, block) end else -- We are in string, find its ending _, i, curr_key = string.find(text,'(.-[^%\\])"', i+1) instring = false end else -- We have the key, let's find the value if not curr_val then -- Value is not found yet if not instring then -- Not in string, check if value is string local j = string.find(text, '"', i+1) local k = string.find(text, '[,}]', i+1) if j and j < k then -- Value is string i = j instring = true else -- Value is int _, i, curr_val = string.find(text,'(%d+)', i+1) end else -- We are in string, find its ending local j = string.find(text, '"', i+1) if j == i+1 then -- String is empty i = j curr_val = "" else _, i, curr_val = string.find(text,'(.-[^%\\])"', i+1) curr_val = utf8_codes_to_symbols(curr_val) end instring = false end else -- We have both key and value, add it to table block[curr_key] = curr_val curr_key = nil curr_val = nil end end end end return parse_table end -- Jamendo returns Unicode symbols as \uXXXX. Lua does not transform -- them into symbols so we need to do it ourselves. function utf8_codes_to_symbols (s) local hexnums = "[%dabcdefABCDEF]" local pattern = string.format("\\u(%s%s%s%s?)", hexnums, hexnums, hexnums, hexnums) local decode = function(code) code = tonumber(code, 16) if code < 128 then -- one-byte symbol return string.char(code) elseif code < 2048 then -- two-byte symbol -- Grab high and low bytes local hi = math.floor(code / 64) local lo = math.mod(code, 64) -- Return symbol as \hi\lo return string.char(hi + 192, lo + 128) elseif code < 65536 then -- Grab high, middle and low bytes local hi = math.floor(code / 4096) local leftover = code - hi * 4096 local mi = math.floor(leftover / 64) leftover = leftover - mi * 64 local lo = math.mod(leftover, 64) -- Return symbol as \hi\mi\lo return string.char(hi + 224, mi + 160, lo + 128) elseif code < 1114112 then -- Grab high, highmiddle, lowmiddle and low bytes local hi = math.floor(code / 262144) local leftover = code - hi * 262144 local hm = math.floor(leftover / 4096) leftover = leftover - hm * 4096 local lm = math.floor(leftover / 64) local lo = math.mod(leftover, 64) -- Return symbol as \hi\hm\lm\lo return string.char(hi + 240, hm + 128, lm + 128, lo + 128) else -- It is not Unicode symbol at all return tostring(code) end end return string.gsub(s, pattern, decode) end -- Retrieves mapping of track IDs to track names and album IDs to -- avoid redundant queries when Awesome gets restarted. local function retrieve_cache() local bus = io.open(cache_file) local track = {} if bus then local header = bus:read("*line") if header == cache_header then for l in bus:lines() do local _, _, id, artist_link_name, album_name, album_id, track_name = string.find(l,"(%d+)-([^-]+)-([^-]+)-(%d+)-(.+)") track = {} track.id = id track.artist_link_name = string.gsub(artist_link_name, '\\_', '-') track.album_name = string.gsub(album_name, '\\_', '-') track.album_id = album_id track.display_name = track_name jamendo_list[id] = track end else -- We encountered an outdated version of the cache -- file. Let's just remove it. awful.util.spawn("rm -f " .. cache_file) end end end -- Saves track IDs to track names and album IDs mapping into the cache -- file. function save_cache() local bus = io.open(cache_file, "w") bus:write(cache_header .. "\n") for id,track in pairs(jamendo_list) do bus:write(string.format("%s-%s-%s-%s-%s\n", id, string.gsub(track.artist_link_name, '-', '\\_'), string.gsub(track.album_name, '-', '\\_'), track.album_id, track.display_name)) end bus:flush() bus:close() end -- Retrieve cache on initialization retrieve_cache() -- Returns a filename of the album cover and formed wget request that -- downloads the album cover for the given track name. If the album -- cover already exists returns nil as the second argument. function fetch_album_cover_request(track_id) local track = jamendo_list[track_id] local album_id = track.album_id if album_id == 0 then -- No cover for tracks without album! return nil end local file_path = album_covers_folder .. album_id .. ".jpg" if not file_exists(file_path) then -- We need to download it -- First check if cache directory exists f = io.popen('test -d ' .. album_covers_folder .. ' && echo t') if f:read("*line") ~= 't' then awful.util.spawn("mkdir " .. album_covers_folder) end f:close() if not track.album_image then -- Wow! We have album_id, but local a_id = tostring(album_id) --don't have album_image. Well, local prefix = --it happens. string.sub(a_id, 1, string.len(a_id) - 3) track.album_image = string.format("http://imgjam.com/albums/s%s/%s/covers/1.100.jpg", prefix == "" and 0 or prefix, a_id) end return file_path, string.format("wget %s -O %s 2> /dev/null", track.album_image, file_path) else -- Cover already downloaded, return its filename and nil return file_path, nil end end -- Returns a file containing an album cover for given track id. First -- searches in the cache folder. If file is not there, fetches it from -- the Internet and saves into the cache folder. function get_album_cover(track_id) local file_path, fetch_req = fetch_album_cover_request(track_id) if fetch_req then local f = io.popen(fetch_req) f:close() -- Let's check if file is finally there, just in case if not file_exists(file_path) then return nil end end return file_path end -- Same as get_album_cover, but downloads (if necessary) the cover -- asynchronously. function get_album_cover_async(track_id) local file_path, fetch_req = fetch_album_cover_request(track_id) if fetch_req then asyncshell.request(fetch_req) end end -- Checks if track_name is actually a link to Jamendo stream. If true -- returns the file with album cover for the track. function try_get_cover(track_name) local id = get_id_from_link(track_name) if id then return get_album_cover(id) end end -- Same as try_get_cover, but calls get_album_cover_async inside. function try_get_cover_async(track_name) local id = get_id_from_link(track_name) if id then return get_album_cover_async(id) end end -- Returns the track table for given query and search method. -- what - search method - SEARCH_ARTIST, ALBUM or TAG -- s - string to search function search_by(what, s) -- Get a default request and set unit and query local req = search_template req.unit = what.unit req.params.searchquery = s local resp = perform_request(form_request(req)) if resp then local search_res = parse_json(resp)[1] if search_res then -- Now when we got the search result, find tracks filtered by -- this result. local params = {} params[what.value] = search_res.id req = { params = params } local track_table = return_track_table(req) return { search_res = search_res, tracks = track_table } end end end -- Executes request_string with io.popen and returns the response. function perform_request(reqest_string) local bus = assert(io.popen(reqest_string,'r')) local response = bus:read("*all") bus:close() -- Curl with popen can sometimes fail to fetch data when the -- connection is slow. Let's try again if it fails. if string.len(response) == 0 then bus = assert(io.popen(reqest_string,'r')) response = bus:read("*all") bus:close() -- If it still can't read anything, return nil if string.len(response) ~= 0 then return nil end end return response end -- Sets default streamencoding in current_request_table. function set_current_format(format) current_request_table.params.streamencoding = format end -- Sets default order in current_request_table. function set_current_order(order) current_request_table.params.order = order end