├── README.md
├── Search Spotify.lbaction
└── Contents
│ ├── Info.plist
│ └── Scripts
│ ├── default.js
│ └── suggestion.php
└── example.png
/README.md:
--------------------------------------------------------------------------------
1 | LBSpotify
2 | =========
3 |
4 | Search Spotify from Launchbar with predictions
5 |
6 | 
7 |
--------------------------------------------------------------------------------
/Search Spotify.lbaction/Contents/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleIconFile
6 | com.spotify.client
7 | LBTextInputTitle
8 | Spotify
9 | CFBundleIdentifier
10 | com.kyleacarson.LaunchBar.action.SearchSpotify
11 | CFBundleName
12 | Search Spotify
13 | CFBundleVersion
14 | 2.0
15 | LBDescription
16 |
17 | LBAuthor
18 | Kyle Carson
19 | LBSummary
20 | Search Spotify (with predictions!)
21 | LBTwitter
22 | @kyleacarson
23 |
24 | LBScripts
25 |
26 | LBSuggestionsScript
27 |
28 | LBLiveFeedbackEnabled
29 |
30 | LBScriptName
31 | suggestion.php
32 | LBBackgroundKillEnabled
33 |
34 |
35 | LBDefaultScript
36 |
37 | LBScriptName
38 | default.js
39 |
40 |
41 | LBAssociatedApplication
42 | com.spotify.client
43 | LBArgument
44 | songs, artists, albums
45 | LBRequiresArgument
46 |
47 | LBResult
48 | opens Spotify
49 | LBAcceptedArgumentTypes
50 |
51 | string
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/Search Spotify.lbaction/Contents/Scripts/default.js:
--------------------------------------------------------------------------------
1 | function runWithString(string)
2 | {
3 | var url = string
4 | if ( ! string.match(/^spotify:/))
5 | {
6 | string = string || "";
7 |
8 | url = "spotify:search:" + encodeURIComponent(string);
9 | }
10 |
11 | LaunchBar.openURL( url );
12 | }
13 |
14 | function run()
15 | {
16 | LaunchBar.openURL("spotify:")
17 | }
--------------------------------------------------------------------------------
/Search Spotify.lbaction/Contents/Scripts/suggestion.php:
--------------------------------------------------------------------------------
1 |
2 | // This class is designed to make it easy to run multiple curl requests in parallel, rather than
3 | // waiting for each one to finish before starting the next. Under the hood it uses curl_multi_exec
4 | // but since I find that interface painfully confusing, I wanted one that corresponded to the tasks
5 | // that I wanted to run.
6 | //
7 | // To use it, first create the ParallelCurl object:
8 | //
9 | // $parallelcurl = new ParallelCurl(10);
10 | //
11 | // The first argument to the constructor is the maximum number of outstanding fetches to allow
12 | // before blocking to wait for one to finish. You can change this later using setMaxRequests()
13 | // The second optional argument is an array of curl options in the format used by curl_setopt_array()
14 | //
15 | // Next, start a URL fetch:
16 | //
17 | // $parallelcurl->startRequest('http://example.com', 'on_request_done', array('something'));
18 | //
19 | // The first argument is the address that should be fetched
20 | // The second is the callback function that will be run once the request is done
21 | // The third is a 'cookie', that can contain arbitrary data to be passed to the callback
22 | //
23 | // This startRequest call will return immediately, as long as less than the maximum number of
24 | // requests are outstanding. Once the request is done, the callback function will be called, eg:
25 | //
26 | // on_request_done($content, 'http://example.com', $ch, array('something'));
27 | //
28 | // The callback should take four arguments. The first is a string containing the content found at
29 | // the URL. The second is the original URL requested, the third is the curl handle of the request that
30 | // can be queried to get the results, and the fourth is the arbitrary 'cookie' value that you
31 | // associated with this object. This cookie contains user-defined data.
32 | //
33 | // By Pete Warden , freely reusable, see http://petewarden.typepad.com for more
34 |
35 | class ParallelCurl {
36 |
37 | public $max_requests;
38 | public $options;
39 |
40 | public $outstanding_requests;
41 | public $multi_handle;
42 |
43 | public function __construct($in_max_requests = 10, $in_options = array()) {
44 | $this->max_requests = $in_max_requests;
45 | $this->options = $in_options;
46 |
47 | $this->outstanding_requests = array();
48 | $this->multi_handle = curl_multi_init();
49 | }
50 |
51 | //Ensure all the requests finish nicely
52 | public function __destruct() {
53 | $this->finishAllRequests();
54 | }
55 |
56 | // Sets how many requests can be outstanding at once before we block and wait for one to
57 | // finish before starting the next one
58 | public function setMaxRequests($in_max_requests) {
59 | $this->max_requests = $in_max_requests;
60 | }
61 |
62 | // Sets the options to pass to curl, using the format of curl_setopt_array()
63 | public function setOptions($in_options) {
64 |
65 | $this->options = $in_options;
66 | }
67 |
68 | // Start a fetch from the $url address, calling the $callback function passing the optional
69 | // $user_data value. The callback should accept 3 arguments, the url, curl handle and user
70 | // data, eg on_request_done($url, $ch, $user_data);
71 | public function startRequest($url, $callback, $user_data = array(), $post_fields=null) {
72 |
73 | if( $this->max_requests > 0 )
74 | $this->waitForOutstandingRequestsToDropBelow($this->max_requests);
75 |
76 | $ch = curl_init();
77 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
78 | curl_setopt_array($ch, $this->options);
79 | curl_setopt($ch, CURLOPT_URL, $url);
80 |
81 | if (isset($post_fields)) {
82 | curl_setopt($ch, CURLOPT_POST, TRUE);
83 | curl_setopt($ch, CURLOPT_POSTFIELDS, $post_fields);
84 | }
85 |
86 | curl_multi_add_handle($this->multi_handle, $ch);
87 |
88 | $ch_array_key = (int)$ch;
89 |
90 | $this->outstanding_requests[$ch_array_key] = array(
91 | 'url' => $url,
92 | 'callback' => $callback,
93 | 'user_data' => $user_data,
94 | );
95 |
96 | $this->checkForCompletedRequests();
97 | }
98 |
99 | // You *MUST* call this function at the end of your script. It waits for any running requests
100 | // to complete, and calls their callback functions
101 | public function finishAllRequests() {
102 | $this->waitForOutstandingRequestsToDropBelow(1);
103 | }
104 |
105 | // Checks to see if any of the outstanding requests have finished
106 | private function checkForCompletedRequests() {
107 | /*
108 | // Call select to see if anything is waiting for us
109 | if (curl_multi_select($this->multi_handle, 0.0) === -1)
110 | return;
111 |
112 | // Since something's waiting, give curl a chance to process it
113 | do {
114 | $mrc = curl_multi_exec($this->multi_handle, $active);
115 | } while ($mrc == CURLM_CALL_MULTI_PERFORM);
116 | */
117 | // fix for https://bugs.php.net/bug.php?id=63411
118 | do {
119 | $mrc = curl_multi_exec($this->multi_handle, $active);
120 | } while ($mrc == CURLM_CALL_MULTI_PERFORM);
121 |
122 | while ($active && $mrc == CURLM_OK) {
123 | if (curl_multi_select($this->multi_handle) != -1) {
124 | do {
125 | $mrc = curl_multi_exec($this->multi_handle, $active);
126 | } while ($mrc == CURLM_CALL_MULTI_PERFORM);
127 | }
128 | else
129 | return;
130 | }
131 |
132 | // Now grab the information about the completed requests
133 | while ($info = curl_multi_info_read($this->multi_handle)) {
134 |
135 | $ch = $info['handle'];
136 | $ch_array_key = (int)$ch;
137 |
138 | if (!isset($this->outstanding_requests[$ch_array_key])) {
139 | die("Error - handle wasn't found in requests: '$ch' in ".
140 | print_r($this->outstanding_requests, true));
141 | }
142 |
143 | $request = $this->outstanding_requests[$ch_array_key];
144 |
145 | $url = $request['url'];
146 | $content = curl_multi_getcontent($ch);
147 | $callback = $request['callback'];
148 | $user_data = $request['user_data'];
149 |
150 | call_user_func($callback, $content, $url, $ch, $user_data);
151 |
152 | unset($this->outstanding_requests[$ch_array_key]);
153 |
154 | curl_multi_remove_handle($this->multi_handle, $ch);
155 | }
156 |
157 | }
158 |
159 | // Blocks until there's less than the specified number of requests outstanding
160 | private function waitForOutstandingRequestsToDropBelow($max)
161 | {
162 | while (1) {
163 | $this->checkForCompletedRequests();
164 | if (count($this->outstanding_requests)<$max)
165 | break;
166 |
167 | usleep(10000);
168 | }
169 | }
170 |
171 | }
172 |
173 | class SpotifyQuery
174 | {
175 | var $search;
176 | function __construct($search)
177 | {
178 | $this->search = strtolower(trim($search));
179 | }
180 |
181 | /**
182 | * Begins looking for suggestions
183 | * @param function $callback called with $suggestions. e.g., $callback( $suggestions );
184 | */
185 | function getSuggestions($callback)
186 | {
187 | if (strlen($this->search) == 0) return [];
188 |
189 | $cachekey = "suggestions_" . $this->search;
190 |
191 | $cache = $this->getCache($cachekey);
192 | if ($cache)
193 | {
194 | $callback($cache);
195 | return;
196 | } else {
197 | $this->pullSuggestions( $callback );
198 | }
199 | }
200 |
201 | private function suggestionForTrack($track)
202 | {
203 | return [
204 | 'title' => $track->name,
205 | 'subtitle' => $track->artists[0]->name,
206 | 'icon' => "at.obdev.LaunchBar:AudioTrackTemplate",
207 | 'url' => $track->href,
208 | ];
209 | }
210 |
211 | private function suggestionForArtist($artist)
212 | {
213 | return [
214 | 'title' => $artist->name,
215 | 'icon' => "at.obdev.LaunchBar:ArtistTemplate",
216 | 'url' => $artist->href,
217 | ];
218 | }
219 |
220 | private function suggestionForAlbum($album)
221 | {
222 | return [
223 | 'title' => $album->name,
224 | 'subtitle' => $album->artists[0]->name,
225 | 'icon' => "at.obdev.LaunchBar:AlbumTemplate",
226 | 'url' => $album->href,
227 | ];
228 | }
229 |
230 | private function pullSuggestions($callback)
231 | {
232 | $curl = new ParallelCurl(3);
233 |
234 | foreach ([
235 | "track" => "https://api.spotify.com/v1/search?type=track&q=". urlencode($this->search),
236 | "artist" => "https://api.spotify.com/v1/search?type=artist&q=". urlencode($this->search),
237 | "album" => "https://api.spotify.com/v1/search?type=album&q=". urlencode($this->search)
238 | ] as $type => $url)
239 | {
240 | $curl->startRequest( $url, [ $this, 'processRequest' ], [ 'type' => $type, 'callback' => $callback ] );
241 | }
242 | }
243 |
244 | private $trackResults;
245 | private $artistResults;
246 | private $albumResults;
247 |
248 | /**
249 | * Used by ParallelCurl. Will return suggestions when everything is accounted for
250 | * @param string $content
251 | * @param string $url
252 | * @param class $ch curl object
253 | * @param array $cookies
254 | */
255 | function processRequest( $content, $url, $ch, $cookies)
256 | {
257 | $result = json_decode( $content );
258 | if ($cookies['type'] == 'track')
259 | {
260 | $this->trackResults = $result;
261 | }
262 | if ($cookies['type'] == 'artist')
263 | {
264 | $this->artistResults = $result;
265 | }
266 | if ($cookies['type'] == 'album')
267 | {
268 | $this->albumResults = $result;
269 | }
270 |
271 | if (isSet($this->trackResults) && isSet($this->artistResults) && isSet($this->albumResults))
272 | {
273 | $this->returnSuggestions( $cookies['callback'] );
274 | }
275 | }
276 |
277 | private function returnSuggestions( $callback )
278 | {
279 | $suggestions = [ ];
280 | foreach($this->trackResults->tracks->items as $i => $track)
281 | {
282 | if ($i > 2) break;
283 |
284 | $suggestions[] = $this->suggestionForTrack( $track );
285 | }
286 | foreach($this->artistResults->artists->items as $i => $artist)
287 | {
288 | if ($i > 2) break;
289 |
290 | $suggestions[] = $this->suggestionForArtist( $artist );
291 | }
292 | foreach($this->albumResults->albums->items as $i => $album)
293 | {
294 | if ($i > 2) break;
295 |
296 | $suggestions[] = $this->suggestionForAlbum( $album );
297 | }
298 |
299 | $primaryResult = $this->primaryResult($suggestions);
300 | if ($primaryResult)
301 | {
302 | // If the top suggestion is a song, it's already on top
303 | if ($primaryResult['icon'] != "at.obdev.LaunchBar:AudioTrackTemplate")
304 | {
305 | array_unshift( $suggestions, $primaryResult );
306 | }
307 | }
308 |
309 | $cachekey = "suggestions_" . $this->search;
310 | $this->setCacheValue($cachekey, $suggestions);
311 |
312 | $callback( $suggestions );
313 | }
314 |
315 | /**
316 | * Returns the ideal result
317 | * @param array $results [description]
318 | * @return array $result
319 | */
320 | function primaryResult($results)
321 | {
322 | // We're going to filter our the 2nd/3rd song/artist/album because Spotify knows it's less relevant
323 | $keptResults = [];
324 | foreach($results as $i => $result)
325 | {
326 | for ($j = 0; $j < $i; $j++)
327 | {
328 | $keptResult = $results[$j];
329 |
330 | if ($result['icon'] == $keptResult['icon'])
331 | {
332 | continue 2;
333 | }
334 | }
335 | $keptResults[] = $result;
336 | }
337 |
338 | usort($keptResults, function($a, $b)
339 | {
340 | $aVal = levenshtein($a['title'], $this->search, 10, 10, 2);
341 | $bVal = levenshtein($b['title'], $this->search, 10, 10, 2);
342 |
343 | if ($aVal < $bVal) return -1;
344 | if ($aVal > $bVal) return 1;
345 |
346 | if ($a['value'] && ! $b['value'])
347 | {
348 | return -1;
349 | } else if ($b['value'] && ! $a['value'])
350 | {
351 | return 1;
352 | }
353 |
354 | return 0;
355 | });
356 |
357 | return $keptResults[0];
358 | }
359 |
360 | ///////////
361 | /////////// Cache
362 | ///////////
363 |
364 | private function cacheFile()
365 | {
366 | // Hash THIS file's contents
367 | $hash = sha1( file_get_contents( __FILE__ ) );
368 | return "/tmp/LBSpotifyCache-$hash.json";
369 | }
370 |
371 | private function getCache($key = false)
372 | {
373 | $cache = [];
374 | if (file_exists( $this->cacheFile() ))
375 | {
376 | $cache = json_decode( file_get_contents( $this->cacheFile() ), true );
377 | }
378 | if (! is_array($cache))
379 | {
380 | $cache = [];
381 | }
382 |
383 | if ($key)
384 | {
385 | if (isSet($cache[$key]))
386 | {
387 | return $cache[$key];
388 | }
389 | return null;
390 | }
391 |
392 | return $cache;
393 | }
394 |
395 | private function setCache($cache)
396 | {
397 | if (! is_array($cache)) return;
398 |
399 | file_put_contents( $this->cacheFile(), json_encode( $cache ) );
400 | }
401 |
402 | private function setCacheValue($key, $value)
403 | {
404 | $cache = $this->getCache();
405 | $cache[$key] = $value;
406 |
407 | $this->setCache($cache);
408 | }
409 |
410 | ///////////
411 | /////////// End Cache
412 | ///////////
413 | }
414 |
415 | $query = new SpotifyQuery( $argv[1] );
416 |
417 | $query->getSuggestions(function($suggestions) {
418 | echo json_encode( $suggestions );
419 | });
420 |
421 | ?>
422 |
--------------------------------------------------------------------------------
/example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nosrac/LBSpotify/eb29e6dd9288c15fb96adede76735a463a44b585/example.png
--------------------------------------------------------------------------------