--- /dev/null
+---------------------------------------------------------------------------
+-- @author Alexander Yakushev <yakushev.alex@gmail.com>
+-- @copyright 2011-2013 Alexander Yakushev
+-- @release v1.2.4
+---------------------------------------------------------------------------
+
+-- Grab environment
+local awful = require('awful')
+
+local 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
+
+local function str_interpose(coll, sep)
+ if #coll == 0 then
+ return ""
+ end
+ local result = coll[1]
+ for i = 2, #coll do
+ result = result .. sep .. coll[i]
+ end
+ print(result)
+ return result
+end
+
+-- Global variables
+jamendo.FORMAT_MP3 = { display = "MP3 (128k)",
+ short_display = "MP3",
+ value = "mp31" }
+jamendo.FORMAT_OGG = { display = "Ogg Vorbis (q4)",
+ short_display = "Ogg",
+ value = "ogg2" }
+jamendo.ORDER_RATINGDAILY = { display = "Daily rating",
+ short_display = "daily rating",
+ value = "ratingday_desc" }
+jamendo.ORDER_RATINGWEEKLY = { display = "Weekly rating",
+ short_display = "weekly rating",
+ value = "ratingweek_desc" }
+jamendo.ORDER_RATINGTOTAL = { display = "All time rating",
+ short_display = "all time rating",
+ value = "ratingtotal_desc" }
+jamendo.ORDER_RANDOM = { display = "Random",
+ short_display = "random",
+ value = "random_desc" }
+jamendo.ORDER_RELEVANCE = { display = "None (consecutive)",
+ short_display = "none",
+ value = "searchweight_desc" }
+jamendo.SEARCH_ARTIST = { display = "Artist",
+ unit = "artist",
+ value = "artist_id" }
+jamendo.SEARCH_ALBUM = { display = "Album",
+ unit = "album",
+ value = "album_id" }
+jamendo.SEARCH_TAG = { display = "Tag",
+ unit = "tag",
+ value = "tag_id" }
+jamendo.ALL_FORMATS = { jamendo.FORMAT_MP3, jamendo.FORMAT_OGG }
+jamendo.ALL_ORDERS = { ORDER_RELEVANCE, ORDER_RANDOM, ORDER_RATINGDAILY,
+ ORDER_RATINGWEEKLY, ORDER_RATINGTOTAL }
+
+jamendo.current_request_table = { unit = "track",
+ fields = {"id", "artist_url", "artist_name", "name",
+ "stream", "album_image", "album_name" },
+ joins = { "track_album", "album_artist" },
+ params = { streamencoding = jamendo.FORMAT_MP3,
+ order = jamendo.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 =
+ jamendo.perform_request("echo $(curl -w %{redirect_url} " ..
+ "'http://api.jamendo.com/get2/stream/track/redirect/" ..
+ "?streamencoding="..jamendo.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 jamendo.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 jamendo.get_track_by_link(link)
+ local id = jamendo.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 jamendo.replace_link(track_name)
+ local track = jamendo.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 jamendo.return_track_table(request_table)
+ local req_string = jamendo.form_request(request_table)
+ local response = jamendo.perform_request(req_string)
+ if not response then
+ return nil -- Bad internet connection
+ end
+ local parse_table = jamendo.parse_json(response)
+ for i = 1, #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
+ jamendo.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 jamendo.form_request(request_table)
+ local curl_str = "curl -A 'Mozilla/4.0' -fsm 5 \"%s\""
+ local url = "http://api.jamendo.com/get2/%s/%s/json/%s/?%s"
+ request_table = request_table or jamendo.current_request_table
+
+ local fields = request_table.fields or jamendo.current_request_table.fields
+ local joins = request_table.joins or jamendo.current_request_table.joins
+ local unit = request_table.unit or jamendo.current_request_table.unit
+
+ -- Form fields string (like field1+field2+fieldN)
+ local f_string = str_interpose(fields, "+")
+ -- Form joins string
+ local j_string = str_interpose(joins, "+")
+
+ 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 ~= jamendo.current_request_table.params then
+ -- First fill params with current_request_table parameters
+ for k, v in pairs(jamendo.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 = jamendo.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, f_string, unit, j_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 jamendo.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 = jamendo.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 jamendo.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.fmod(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.fmod(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.fmod(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 jamendo.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 jamendo.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, #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 jamendo.get_album_cover(track_id)
+ local file_path, fetch_req = jamendo.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 jamendo.get_album_cover_async(track_id)
+ local file_path, fetch_req = jamendo.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 jamendo.try_get_cover(track_name)
+ local id = jamendo.get_id_from_link(track_name)
+ if id then
+ return jamendo.get_album_cover(id)
+ end
+end
+
+-- Same as try_get_cover, but calls get_album_cover_async inside.
+function jamendo.try_get_cover_async(track_name)
+ local id = jamendo.get_id_from_link(track_name)
+ if id then
+ return jamendo.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 jamendo.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 = jamendo.perform_request(jamendo.form_request(req))
+ if resp then
+ local search_res = jamendo.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 = jamendo.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 jamendo.perform_request(request_string)
+ local bus = assert(io.popen(request_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 #response == 0 then
+ bus = assert(io.popen(request_string,'r'))
+ response = bus:read("*all")
+ bus:close()
+ -- If it still can't read anything, return nil
+ if #response ~= 0 then
+ return nil
+ end
+ end
+ return response
+end
+
+-- Sets default streamencoding in current_request_table.
+function jamendo.set_current_format(format)
+ jamendo.current_request_table.params.streamencoding = format
+end
+
+-- Sets default order in current_request_table.
+function jamendo.set_current_order(order)
+ jamendo.current_request_table.params.order = order
+end
+
+return jamendo