JasonWoof Got questions, comments, patches, etc.? Contact Jason Woofenden
fix login_cached, playlist fetch coming along
[libre-fm-client-daemon.git] / api.coffee
1 fs = require 'fs'
2 http = require 'http'
3 crypto = require 'crypto'
4 xml = require 'node-xml'
5
6 # return the current time in miliseconds (since the epoch)
7 now_ms = ->
8         return new Date().getTime()
9
10 # soooo annoying that setTimeout takes the ms arg last
11 timeout = (ms, func) -> setTimeout func, ms
12
13 auth = {}
14
15 md5 = (str) ->
16         sum = crypto.createHash 'md5'
17         sum.update str
18         return sum.digest 'hex'
19
20 new_auth_token = (user, pass) ->
21         token = md5(user + md5(pass))
22         auth.user = user
23         auth.token = token
24         return token
25
26 auth_file = "#{process.env.HOME}/.libre.fm-cmus.auth"
27 save_auth = (user, pass, callback) ->
28         token = new_auth_token(user, pass)
29         text = JSON.stringify user: user, token: new_auth_token(user, pass)
30         fs.writeFile auth_file, text, 'utf8', callback
31         return token
32
33 # load login credentials from settings file
34 load_auth = (callback) ->
35         if auth.user and auth.token
36                 callback auth
37                 return
38
39         fs.readFile auth_file, 'utf8', (err, data) ->
40                 if err
41                         callback err
42                 else
43                         auth = JSON.parse data
44                         callback null, auth
45
46 # elements look like: ['name', {attr: value, attr2: value2,...}, [contents]]
47 # text nodes look like elements with blank names: ['', {}, 'foo']
48 # so <foo bar="baz">qux<quux></quux>corge</foo> -> ['foo', {bar:'baz'}, [['',{},'qux'], ['cuux', {}, []], ['',{},'corge']]]
49 # callback(err, array)
50 parse_xml = (str, callback) ->
51         done = false
52         nests = [[]]
53         huh = ->
54                 # FIXME switch to an xml parser that will tell me when it's done
55                 unless done
56                         done = true
57                         callback "xml parser failed to do anything with login server response"
58                         console.log "xml parser didn't exit: #{str}"
59         parser = new xml.SaxParser (cb) ->
60                 cb.onStartElementNS (name, attr_tuples, prefix, uri, namespaces) ->
61                         attrs = {}
62                         for tuple in attr_tuples
63                                 attrs[tuple[0]] = tuple[1]
64                         element = [name, attrs, []]
65                         nests[0].push element
66                         nests.unshift element[2]
67                 cb.onCharacters (str) ->
68                         if nests[0][nests[0].length - 1]?[0] is ''
69                                 nests[0][nests[0].length - 1][2] += str
70                         else
71                                 nests[0].push ['', {}, str]
72                 cb.onEndElementNS (name, attrs, prefix, uri, namespaces) ->
73                         nests.shift()
74                         if nests.length is 1
75                                 done = true
76                                 callback null, nests[0]
77                 cb.onEndDocument huh
78         parser.parseString str
79         timeout 1, huh
80
81 exports.parse_xml = parse_xml
82
83 # returns just the "lfm" element of xml (as described in parse_xml) or fires an error
84 # callback(err, array)
85 parse_lfm_xml = (text, callback) ->
86         parse_xml text, (err, parsed) ->
87                 if err
88                         # parse error
89                         callback err
90                         return
91                 for element in parsed
92                         if element[0] is 'lfm'
93                                 callback null, element
94                                 return
95                 callback "Couldn't find lfm element in server response"
96                 return
97         return
98
99 # login and get a session key
100 # callback(err, sk)
101 login = (callback) ->
102         load_auth (err, auth) ->
103                 return callback(err) if err?
104
105                 http.get { host: 'alpha.libre.fm', port: 80, path: "/2.0/?method=auth.getmobilesession&username=#{auth.user}&authToken=#{auth.token}"}, (res) ->
106                         if res.statusCode != 200
107                                 console.log "login response code: #{res.statusCode}"
108                                 callback "login response code: #{res.statusCode}"
109                                 return
110
111                         res.setEncoding 'utf8'
112                         body = ''
113                         res.on 'data', (chunk) ->
114                                 body += chunk
115                         res.on 'end', ->
116                                 element = ''
117                                 content = ''
118                                 done = false
119                                 huh = ->
120                                         # FIXME switch to an xml parser that will tell me when it's done
121                                         unless done
122                                                 done = true
123                                                 callback "xml parser failed to do anything with login server response"
124                                                 console.log "xml parser didn't exit: #{body}"
125                                 parser = new xml.SaxParser (cb) ->
126                                         cb.onStartElementNS (name, attrs, prefix, uri, namespaces) ->
127                                                 element = name
128                                                 content = ''
129                                         cb.onCharacters (str) ->
130                                                 content += str
131                                         cb.onEndElementNS (name, attrs, prefix, uri, namespaces) ->
132                                                 if element is 'key'
133                                                         done = true
134                                                         auth.sk = content
135                                                         auth.sk_date = now_ms
136                                                         console.log("got key \"#{content}\"")
137                                                         callback null, content
138                                                 else if element is 'error'
139                                                         done = true
140                                                         callback "login failed: \"#{content}\""
141                                                 # ignore other tags stuff in there
142                                         cb.onEndDocument huh
143                                 parser.parseString body
144                                 timeout 1, huh
145
146 # callback(err, sk)
147 login_cached = (callback) ->
148         if auth.sk
149                 callback null, auth.sk
150         login callback
151
152
153 # callback(err)
154 tune = (tag, callback) ->
155         login_cached (err, sk) ->
156                 return callback(err) if err?
157
158                 args = "method=radio.tune&station=librefm://#{tag}&sk=#{sk}"
159                 headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': args.length }
160                 req = http.request { host: 'alpha.libre.fm', port: 80, method: 'POST', path: "/2.0/", headers: headers }, (res) ->
161                         if res.statusCode != 200
162                                 # libre.fm always returns 200, even for authentication failures
163                                 console.log "radio.tune response code: #{res.statusCode}"
164                                 callback "radio.tune response code: #{res.statusCode}"
165                                 return
166                         else
167                                 res.setEncoding 'utf8'
168                                 response_text = ''
169                                 res.on 'data', (chunk) ->
170                                         response_text += chunk
171                                 res.on 'end', ->
172                                         parse_lfm_xml response_text, (err, lfm) ->
173                                                 if err
174                                                         # parse error
175                                                         callback "Error while parsing server reply while tuning into \"#{tag}\" station: #{err}"
176                                                         return
177                                                 if lfm[1].status is 'ok'
178                                                         console.log "Tuned to #{tag}"
179                                                         callback()
180                                                         return
181                                                 for element in lfm[2]
182                                                         if element[0] is 'error'
183                                                                 code = element[1].code
184                                                                 if code is 4 # invalid authentication token
185                                                                         delay = 0
186                                                                         sk_age = now_ms() - auth.sk_date
187                                                                         if sk_age < 30000
188                                                                                 console.log "Server said our auth token was invalid only #{sk_age}ms after we got it."
189                                                                                 delay = 30000 - sk_age
190                                                                                 console.log "Waiting another #{delay}ms before requesting another one."
191                                                                         # get new auth token
192                                                                         timeout delay, ->
193                                                                                 login (err, sk) ->
194                                                                                         if err
195                                                                                                 callback err
196                                                                                                 return
197                                                                                         tune tag, callback
198                                                                                         return
199                                                                 else
200                                                                         if typeof element[2][0]?[2] is 'string'
201                                                                                 message = JSON.stringify element[2][0][2]
202                                                                         console.log "server response from tune: code #{code} message: #{message}"
203                                                                         callback "Error during tune: code #{code} message: #{message}"
204                                                                 return
205                                                 # looked through all elements, and didn't find one with name "error"
206                                                 callback "Error during tune: server responded without success or error message"
207
208                 req.on 'error', (err) ->
209                         console.log "tune post http error: #{err}"
210                         callback "tune post http error: #{err}"
211
212                 req.write args
213                 req.end()
214
215 # FIXME call tune automatically or remove "tag" argument
216 get_playlist = (tag, callback) ->
217         login_cached (err, sk) ->
218                 return callback(err) if err?
219
220                 console.log "getting playlist with sk=#{sk}"
221                 http.get { host: 'alpha.libre.fm', port: 80, path: "/2.0/?method=radio.getPlaylist&sk=#{sk}"}, (res) ->
222                         if res.statusCode != 200
223                                 console.log "login response code: #{res.statusCode}"
224                                 callback "login response code: #{res.statusCode}"
225                                 return
226
227                         res.setEncoding 'utf8'
228                         response_text = ''
229                         res.on 'data', (chunk) ->
230                                 response_text += chunk
231                         res.on 'end', ->
232                                 # while testing, got response_text === "BADSESSION"
233                                 console.log "server said: #{response_text}"
234                                 parse_xml response_text, (err, response) ->
235                                         parse_lfm_xml response_text, (err, lfm) ->
236                                                 if err
237                                                         # parse error
238                                                         callback "Error while parsing server reply while requesting playlist: #{err}"
239                                                         return
240                                                 for element in response
241                                                         if element[0] is 'playlist'
242                                                                 # FIXME write this bit
243                                                                 console.log 'Yay we got a playlist!'
244                                                                 console.log JSON.stringify element[0]
245                                                         else if element[0] is 'lfm'
246                                                                 if element[1].status isnt 'failed'
247                                                                         callback "Server responded to our playlist request with #{JSON.stringify response}"
248                                                                         return
249
250                                                                 # FIXME search for "error" element instead of assuming it's first
251                                                                 code = element[2][0]?.code
252                                                                 if code is 4 # invalid authentication token
253                                                                         delay = 0
254                                                                         sk_age = now_ms() - auth.sk_date
255                                                                         if sk_age < 30000
256                                                                                 console.log "Server said our auth token was invalid only #{sk_age}ms after we got it."
257                                                                                 delay = 30000 - sk_age
258                                                                                 console.log "Waiting another #{delay}ms before requesting another one."
259                                                                         # get new auth token
260                                                                         timeout delay, ->
261                                                                                 login (err, sk) ->
262                                                                                         if err
263                                                                                                 callback err
264                                                                                                 return
265                                                                                         get_playlist tag, callback
266                                                                                         return
267                                                                 else
268                                                                         if typeof element[2][0]?[2] is 'string'
269                                                                                 message = JSON.stringify element[2][0][2]
270                                                                         console.log "server response from tune: code #{code} message: #{message}"
271                                                                         callback "Error during tune: code #{code} message: #{message}"
272                                                                 return
273                                                 # looked through all elements, and didn't find one with name "error"
274                                                 callback "Error during tune: server responded without success or error message"
275
276 test = (tag, callback) ->
277         tune tag, (err) ->
278                 if err?
279                         console.log err
280                         callback err
281                         return
282                 get_playlist tag, ->
283                         if err?
284                                 console.log err
285                                 callback err
286                                 return
287                         console.log 'yay'
288                         callback()
289
290 exports.test = test
291 exports.get_playlist = get_playlist
292 exports.tune = tune
293 exports.login = login # fixme remove this from the API and call it automatically
294 exports.new_auth_token = new_auth_token
295 exports.save_auth = save_auth