├── 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 | ![Example Image](https://raw.githubusercontent.com/Nosrac/LBSpotify/master/example.png) 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 | 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 --------------------------------------------------------------------------------