1 ---------------------------------------------------------------------------
2 -- @author Alexander Yakushev <yakushev.alex@gmail.com>
3 -- @copyright 2011-2013 Alexander Yakushev
5 ---------------------------------------------------------------------------
8 local awful = require('awful')
13 -- Checks whether file specified by filename exists.
14 local function file_exists(filename, mode)
16 f = io.open(filename, mode)
25 local function str_interpose(coll, sep)
29 local result = coll[1]
31 result = result .. sep .. coll[i]
38 jamendo.FORMAT_MP3 = { display = "MP3 (128k)",
39 short_display = "MP3",
41 jamendo.FORMAT_OGG = { display = "Ogg Vorbis (q4)",
42 short_display = "Ogg",
44 jamendo.ORDER_RATINGDAILY = { display = "Daily rating",
45 short_display = "daily rating",
46 value = "ratingday_desc" }
47 jamendo.ORDER_RATINGWEEKLY = { display = "Weekly rating",
48 short_display = "weekly rating",
49 value = "ratingweek_desc" }
50 jamendo.ORDER_RATINGTOTAL = { display = "All time rating",
51 short_display = "all time rating",
52 value = "ratingtotal_desc" }
53 jamendo.ORDER_RANDOM = { display = "Random",
54 short_display = "random",
55 value = "random_desc" }
56 jamendo.ORDER_RELEVANCE = { display = "None (consecutive)",
57 short_display = "none",
58 value = "searchweight_desc" }
59 jamendo.SEARCH_ARTIST = { display = "Artist",
62 jamendo.SEARCH_ALBUM = { display = "Album",
65 jamendo.SEARCH_TAG = { display = "Tag",
68 jamendo.ALL_FORMATS = { jamendo.FORMAT_MP3, jamendo.FORMAT_OGG }
69 jamendo.ALL_ORDERS = { ORDER_RELEVANCE, ORDER_RANDOM, ORDER_RATINGDAILY,
70 ORDER_RATINGWEEKLY, ORDER_RATINGTOTAL }
72 jamendo.current_request_table = { unit = "track",
73 fields = {"id", "artist_url", "artist_name", "name",
74 "stream", "album_image", "album_name" },
75 joins = { "track_album", "album_artist" },
76 params = { streamencoding = jamendo.FORMAT_MP3,
77 order = jamendo.ORDER_RATINGWEEKLY,
81 local jamendo_list = {}
82 local cache_file = awful.util.getdir ("cache").."/jamendo_cache"
83 local cache_header = "[version=1.1.0]"
84 local album_covers_folder = awful.util.getdir("cache") .. "/jamendo_covers/"
85 local default_mp3_stream = nil
86 local search_template = { fields = { "id", "name" },
88 params = { order = ORDER_RELEVANCE,
91 -- DEPRECATED. Will be removed in the next major release.
92 -- Returns default stream number for MP3 format. Requests API for it
93 -- not more often than every hour.
94 local function get_default_mp3_stream()
95 if not default_mp3_stream or
96 (os.time() - default_mp3_stream.last_checked) > 3600 then
98 jamendo.perform_request("echo $(curl -w %{redirect_url} " ..
99 "'http://api.jamendo.com/get2/stream/track/redirect/" ..
100 "?streamencoding="..jamendo.FORMAT_MP3.value.."&id=729304')")
101 local _, _, prefix = string.find(trygetlink, "stream(%d+)%.jamendo%.com")
102 default_mp3_stream = { id = prefix, last_checked = os.time() }
104 return default_mp3_stream.id
107 -- Returns the track ID from the given link to Jamendo stream. If the
108 -- given text is not the Jamendo stream returns nil.
109 function jamendo.get_id_from_link(link)
110 local _, _, id = string.find(link,"storage%-new.newjamendo.com/%?trackid=(%d+)")
114 -- Returns link to music stream for the given track ID. Uses MP3
115 -- format and the default stream for it.
116 local function get_link_by_id(id)
117 -- This function is subject to change in the future.
118 return string.format("http://storage-new.newjamendo.com?trackid=%s&format=mp31&u=0", id)
121 -- -- Returns the album id for given music stream.
122 -- function get_album_id_by_link(link)
123 -- local id = get_id_from_link(link, true)
124 -- if id and jamendo_list[id] then
125 -- return jamendo_list[id].album_id
129 -- Returns the track table for the given music stream.
130 function jamendo.get_track_by_link(link)
131 local id = jamendo.get_id_from_link(link, true)
132 if id and jamendo_list[id] then
133 return jamendo_list[id]
137 -- If a track is actually a Jamendo stream, replace it with normal
139 function jamendo.replace_link(track_name)
140 local track = jamendo.get_track_by_link(track_name)
142 return track.display_name
148 -- Returns table of track IDs, names and other things based on the
150 function jamendo.return_track_table(request_table)
151 local req_string = jamendo.form_request(request_table)
152 local response = jamendo.perform_request(req_string)
154 return nil -- Bad internet connection
156 local parse_table = jamendo.parse_json(response)
157 for i = 1, #parse_table do
158 if parse_table[i].stream == "" then
159 -- Some songs don't have Ogg stream, use MP3 instead
160 parse_table[i].stream = get_link_by_id(parse_table[i].id)
162 _, _, parse_table[i].artist_link_name =
163 string.find(parse_table[i].artist_url, "\\/artist\\/(.+)")
164 -- Remove Jamendo escape slashes
165 parse_table[i].artist_name =
166 string.gsub(parse_table[i].artist_name, "\\/", "/")
167 parse_table[i].name = string.gsub(parse_table[i].name, "\\/", "/")
169 parse_table[i].display_name =
170 parse_table[i].artist_name .. " - " .. parse_table[i].name
171 -- Do Jamendo a favor, extract album_id for the track yourself
172 -- from album_image link :)
173 local _, _, album_id =
174 string.find(parse_table[i].album_image, "\\/(%d+)\\/covers")
175 parse_table[i].album_id = album_id or 0
176 -- Save fetched tracks for further caching
177 jamendo_list[parse_table[i].id] = parse_table[i]
183 -- Generates the request to Jamendo API based on provided request
184 -- table. If request_table is nil, uses current_request_table instead.
185 -- For all values that do not exist in request_table use ones from
186 -- current_request_table.
187 -- return - HTTP-request
188 function jamendo.form_request(request_table)
189 local curl_str = "curl -A 'Mozilla/4.0' -fsm 5 \"%s\""
190 local url = "http://api.jamendo.com/get2/%s/%s/json/%s/?%s"
191 request_table = request_table or jamendo.current_request_table
193 local fields = request_table.fields or jamendo.current_request_table.fields
194 local joins = request_table.joins or jamendo.current_request_table.joins
195 local unit = request_table.unit or jamendo.current_request_table.unit
197 -- Form fields string (like field1+field2+fieldN)
198 local f_string = str_interpose(fields, "+")
200 local j_string = str_interpose(joins, "+")
203 -- If parameters where supplied in request_table, add them to the
204 -- parameters in current_request_table.
205 if request_table.params and
206 request_table.params ~= jamendo.current_request_table.params then
207 -- First fill params with current_request_table parameters
208 for k, v in pairs(jamendo.current_request_table.params) do
211 -- Then add and overwrite them with request_table parameters
212 for k, v in pairs(request_table.params) do
215 else -- Or just use current_request_table.params
216 params = jamendo.current_request_table.params
218 -- Form parameter string (like param1=value1¶m2=value2)
219 local param_string = ""
220 for k, v in pairs(params) do
221 if type(v) == "table" then
224 v = string.gsub(v, " ", "+")
225 param_string = param_string .. "&" .. k .. "=" .. v
228 return string.format(curl_str, string.format(url, f_string, unit, j_string, param_string))
231 -- Primitive function for parsing Jamendo API JSON response. Does not
232 -- support arrays. Supports only strings and numbers as values.
233 -- Provides basic safety (correctly handles special symbols like comma
234 -- and curly brackets inside strings)
236 function jamendo.parse_json(text)
237 local parse_table = {}
240 local inblock = false
241 local instring = false
244 while i and i < string.len(text) do
245 if not inblock then -- We are not inside the block, find next {
246 i = string.find(text, "{", i+1)
250 if not curr_key then -- We haven't found key yet
251 if not instring then -- We are not in string, check for more tags
252 local j = string.find(text, '"', i+1)
253 local k = string.find(text, '}', i+1)
254 if j and j < k then -- There are more tags in this block
257 else -- Block is over, we found its ending
260 table.insert(parse_table, block)
262 else -- We are in string, find its ending
263 _, i, curr_key = string.find(text,'(.-[^%\\])"', i+1)
266 else -- We have the key, let's find the value
267 if not curr_val then -- Value is not found yet
268 if not instring then -- Not in string, check if value is string
269 local j = string.find(text, '"', i+1)
270 local k = string.find(text, '[,}]', i+1)
271 if j and j < k then -- Value is string
275 _, i, curr_val = string.find(text,'(%d+)', i+1)
277 else -- We are in string, find its ending
278 local j = string.find(text, '"', i+1)
279 if j == i+1 then -- String is empty
283 _, i, curr_val = string.find(text,'(.-[^%\\])"', i+1)
284 curr_val = jamendo.utf8_codes_to_symbols(curr_val)
288 else -- We have both key and value, add it to table
289 block[curr_key] = curr_val
299 -- Jamendo returns Unicode symbols as \uXXXX. Lua does not transform
300 -- them into symbols so we need to do it ourselves.
301 function jamendo.utf8_codes_to_symbols (s)
302 local hexnums = "[%dabcdefABCDEF]"
303 local pattern = string.format("\\u(%s%s%s%s?)",
304 hexnums, hexnums, hexnums, hexnums)
305 local decode = function(code)
306 code = tonumber(code, 16)
307 if code < 128 then -- one-byte symbol
308 return string.char(code)
309 elseif code < 2048 then -- two-byte symbol
310 -- Grab high and low bytes
311 local hi = math.floor(code / 64)
312 local lo = math.fmod(code, 64)
313 -- Return symbol as \hi\lo
314 return string.char(hi + 192, lo + 128)
315 elseif code < 65536 then
316 -- Grab high, middle and low bytes
317 local hi = math.floor(code / 4096)
318 local leftover = code - hi * 4096
319 local mi = math.floor(leftover / 64)
320 leftover = leftover - mi * 64
321 local lo = math.fmod(leftover, 64)
322 -- Return symbol as \hi\mi\lo
323 return string.char(hi + 224, mi + 160, lo + 128)
324 elseif code < 1114112 then
325 -- Grab high, highmiddle, lowmiddle and low bytes
326 local hi = math.floor(code / 262144)
327 local leftover = code - hi * 262144
328 local hm = math.floor(leftover / 4096)
329 leftover = leftover - hm * 4096
330 local lm = math.floor(leftover / 64)
331 local lo = math.fmod(leftover, 64)
332 -- Return symbol as \hi\hm\lm\lo
333 return string.char(hi + 240, hm + 128, lm + 128, lo + 128)
334 else -- It is not Unicode symbol at all
335 return tostring(code)
338 return string.gsub(s, pattern, decode)
341 -- Retrieves mapping of track IDs to track names and album IDs to
342 -- avoid redundant queries when Awesome gets restarted.
343 local function retrieve_cache()
344 local bus = io.open(cache_file)
347 local header = bus:read("*line")
348 if header == cache_header then
349 for l in bus:lines() do
350 local _, _, id, artist_link_name, album_name, album_id, track_name =
351 string.find(l,"(%d+)-([^-]+)-([^-]+)-(%d+)-(.+)")
354 track.artist_link_name = string.gsub(artist_link_name, '\\_', '-')
355 track.album_name = string.gsub(album_name, '\\_', '-')
356 track.album_id = album_id
357 track.display_name = track_name
358 jamendo_list[id] = track
361 -- We encountered an outdated version of the cache
362 -- file. Let's just remove it.
363 awful.util.spawn("rm -f " .. cache_file)
368 -- Saves track IDs to track names and album IDs mapping into the cache
370 function jamendo.save_cache()
371 local bus = io.open(cache_file, "w")
372 bus:write(cache_header .. "\n")
373 for id,track in pairs(jamendo_list) do
374 bus:write(string.format("%s-%s-%s-%s-%s\n", id,
375 string.gsub(track.artist_link_name, '-', '\\_'),
376 string.gsub(track.album_name, '-', '\\_'),
377 track.album_id, track.display_name))
383 -- Retrieve cache on initialization
386 -- Returns a filename of the album cover and formed wget request that
387 -- downloads the album cover for the given track name. If the album
388 -- cover already exists returns nil as the second argument.
389 function jamendo.fetch_album_cover_request(track_id)
390 local track = jamendo_list[track_id]
391 local album_id = track.album_id
393 if album_id == 0 then -- No cover for tracks without album!
396 local file_path = album_covers_folder .. album_id .. ".jpg"
398 if not file_exists(file_path) then -- We need to download it
399 -- First check if cache directory exists
400 f = io.popen('test -d ' .. album_covers_folder .. ' && echo t')
401 if f:read("*line") ~= 't' then
402 awful.util.spawn("mkdir " .. album_covers_folder)
406 if not track.album_image then -- Wow! We have album_id, but
407 local a_id = tostring(album_id) --don't have album_image. Well,
408 local prefix = --it happens.
409 string.sub(a_id, 1, #a_id - 3)
411 string.format("http://imgjam.com/albums/s%s/%s/covers/1.100.jpg",
412 prefix == "" and 0 or prefix, a_id)
415 return file_path, string.format("wget %s -O %s 2> /dev/null",
416 track.album_image, file_path)
417 else -- Cover already downloaded, return its filename and nil
418 return file_path, nil
422 -- Returns a file containing an album cover for given track id. First
423 -- searches in the cache folder. If file is not there, fetches it from
424 -- the Internet and saves into the cache folder.
425 function jamendo.get_album_cover(track_id)
426 local file_path, fetch_req = jamendo.fetch_album_cover_request(track_id)
428 local f = io.popen(fetch_req)
431 -- Let's check if file is finally there, just in case
432 if not file_exists(file_path) then
439 -- Same as get_album_cover, but downloads (if necessary) the cover
441 function jamendo.get_album_cover_async(track_id)
442 local file_path, fetch_req = jamendo.fetch_album_cover_request(track_id)
444 asyncshell.request(fetch_req)
448 -- Checks if track_name is actually a link to Jamendo stream. If true
449 -- returns the file with album cover for the track.
450 function jamendo.try_get_cover(track_name)
451 local id = jamendo.get_id_from_link(track_name)
453 return jamendo.get_album_cover(id)
457 -- Same as try_get_cover, but calls get_album_cover_async inside.
458 function jamendo.try_get_cover_async(track_name)
459 local id = jamendo.get_id_from_link(track_name)
461 return jamendo.get_album_cover_async(id)
465 -- Returns the track table for given query and search method.
466 -- what - search method - SEARCH_ARTIST, ALBUM or TAG
467 -- s - string to search
468 function jamendo.search_by(what, s)
469 -- Get a default request and set unit and query
470 local req = search_template
472 req.params.searchquery = s
473 local resp = jamendo.perform_request(jamendo.form_request(req))
475 local search_res = jamendo.parse_json(resp)[1]
478 -- Now when we got the search result, find tracks filtered by
481 params[what.value] = search_res.id
482 req = { params = params }
483 local track_table = jamendo.return_track_table(req)
484 return { search_res = search_res, tracks = track_table }
489 -- Executes request_string with io.popen and returns the response.
490 function jamendo.perform_request(request_string)
491 local bus = assert(io.popen(request_string,'r'))
492 local response = bus:read("*all")
494 -- Curl with popen can sometimes fail to fetch data when the
495 -- connection is slow. Let's try again if it fails.
496 if #response == 0 then
497 bus = assert(io.popen(request_string,'r'))
498 response = bus:read("*all")
500 -- If it still can't read anything, return nil
501 if #response ~= 0 then
508 -- Sets default streamencoding in current_request_table.
509 function jamendo.set_current_format(format)
510 jamendo.current_request_table.params.streamencoding = format
513 -- Sets default order in current_request_table.
514 function jamendo.set_current_order(order)
515 jamendo.current_request_table.params.order = order