First integration with Last.fm

Koel can now integrate and use the rich information from Last.fm. Now
whenever a song is played, its album and artist information will be
queried from Last.fm and cached for later use. What's better, if an
album has no cover, Koel will try to update its cover if one is found on
Last.fm.

In order to use this feature, users only need to provide valid Last.fm
API credentials (namely LASTFM_API_KEY and LASTFM_API_SECRET) in .env. A
npm and gulp rebuild is also required - just like with every update.
This commit is contained in:
An Phan 2015-12-20 00:36:44 +08:00
parent e536ff6d35
commit cf27ed713d
37 changed files with 1654 additions and 299 deletions

View file

@ -17,6 +17,10 @@ APP_MAX_SCAN_TIME=600
# See https://github.com/phanan/koel/wiki#streaming-music for more information.
STREAMING_METHOD=php
# If you want Koel to integrate with Last.fm, set the API details here.
LASTFM_API_KEY=
LASTFM_API_SECRET=
DB_HOST=localhost
DB_DATABASE=homestead
DB_USERNAME=homestead

View file

@ -15,6 +15,8 @@ env:
ADMIN_EMAIL: koel@example.com
ADMIN_NAME: Koel
ADMIN_PASSWORD: SoSecureK0el
LASTFM_API_KEY: foo
LASTFM_API_SECRET: bar
branches:
only:

13
app/Facades/Lastfm.php Normal file
View file

@ -0,0 +1,13 @@
<?php
namespace App\Facades;
use Illuminate\Support\Facades\Facade;
class Lastfm extends Facade
{
protected static function getFacadeAccessor()
{
return 'Lastfm';
}
}

View file

@ -6,7 +6,6 @@ use App\Models\Artist;
use App\Models\Interaction;
use App\Models\Playlist;
use App\Models\Setting;
use App\Models\Song;
use App\Models\User;
class DataController extends Controller
@ -32,6 +31,7 @@ class DataController extends Controller
'interactions' => Interaction::byCurrentUser()->get(),
'users' => auth()->user()->is_admin ? User::all() : [],
'user' => auth()->user(),
'useLastfm' => env('LASTFM_API_KEY') && env('LASTFM_SECRET'),
]);
}
}

View file

@ -39,4 +39,22 @@ class SongController extends Controller
{
return response()->json(Song::findOrFail($id)->lyrics);
}
/**
* Get extra information about a song via Last.fm
*
* @param string $id
*
* @return \Illuminate\Http\JsonResponse
*/
public function getInfo($id)
{
$song = Song::with('album.artist')->findOrFail($id);
return response()->json([
'lyrics' => $song->lyrics,
'album_info' => $song->album->getInfo(),
'artist_info' => $song->album->artist->getInfo(),
]);
}
}

View file

@ -22,8 +22,7 @@ Route::group(['prefix' => 'api', 'middleware' => 'auth', 'namespace' => 'API'],
post('settings', 'SettingController@save');
get('{id}/play', 'SongController@play')->where('id', '[a-f0-9]{32}');
get('{id}/lyrics', 'SongController@getLyrics')->where('id', '[a-f0-9]{32}');
get('{id}/info', 'SongController@getInfo')->where('id', '[a-f0-9]{32}');
post('interaction/play', 'InteractionController@play');
post('interaction/like', 'InteractionController@like');

View file

@ -2,12 +2,15 @@
namespace App\Models;
use App\Facades\Lastfm;
use Illuminate\Database\Eloquent\Model;
/**
* @property string cover The path to the album's cover
* @property bool has_cover If the album has a cover image
* @property int id
* @property string name Name of the album
* @property Artist artist The album's artist
*/
class Album extends Model
{
@ -49,6 +52,32 @@ class Album extends Model
return $album;
}
/**
* Get extra information about the album from Last.fm.
*
* @return array|false
*/
public function getInfo()
{
if ($this->id === self::UNKNOWN_ID) {
return false;
}
$info = Lastfm::getAlbumInfo($this->name, $this->artist->name);
// If our current album has no cover, and Last.fm has one, why don't we steal it?
// Great artists steal for their great albums!
if (!$this->has_cover &&
is_string($image = array_get($info, 'image')) &&
ini_get('allow_url_fopen')
) {
$extension = explode('.', $image);
$this->writeCoverFile(file_get_contents($image), last($extension));
}
return $info;
}
/**
* Generate a cover from provided data.
*
@ -68,10 +97,24 @@ class Album extends Model
public function generateCover(array $cover)
{
$extension = explode('/', $cover['image_mime']);
$fileName = uniqid().'.'.strtolower($extension[1]);
$extension = empty($extension[1]) ? 'png' : $extension[1];
$this->writeCoverFile($cover['data'], $extension);
}
/**
* Write a cover image file with binary data and update the Album with the new cover file.
*
* @param string $binaryData
* @param string $extension The file extension
*/
private function writeCoverFile($binaryData, $extension)
{
$extension = trim(strtolower($extension), '. ');
$fileName = uniqid().".$extension";
$coverPath = app()->publicPath().'/public/img/covers/'.$fileName;
file_put_contents($coverPath, $cover['data']);
file_put_contents($coverPath, $binaryData);
$this->update(['cover' => $fileName]);
}
@ -101,6 +144,8 @@ class Album extends Model
* This makes sure they are always sane.
*
* @param $value
*
* @return string
*/
public function getNameAttribute($value)
{

View file

@ -2,11 +2,13 @@
namespace App\Models;
use App\Facades\Lastfm;
use App\Facades\Util;
use Illuminate\Database\Eloquent\Model;
/**
* @property int id The model ID
* @property int id The model ID
* @property string name The artist name
*/
class Artist extends Model
{
@ -54,4 +56,22 @@ class Artist extends Model
return self::firstOrCreate(compact('name'), compact('name'));
}
/**
* Get extra information about the artist from Last.fm.
*
* @return array|false
*/
public function getInfo()
{
if ($this->id === self::UNKNOWN_ID) {
return false;
}
$info = Lastfm::getArtistInfo($this->name);
// TODO: Copy the artist's image for our local use.
return $info;
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Providers;
use App\Services\Lastfm;
use Illuminate\Support\ServiceProvider;
class LastfmServiceProvider extends ServiceProvider
{
/**
* Bootstrap the application services.
*
* @return void
*/
public function boot()
{
//
}
/**
* Register the application services.
*
* @return void
*/
public function register()
{
app()->singleton('Lastfm', function () {
return new Lastfm();
});
}
}

170
app/Services/Lastfm.php Normal file
View file

@ -0,0 +1,170 @@
<?php
namespace App\Services;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Cache;
use Log;
class Lastfm extends RESTfulService
{
/**
* Specify the response format, since Last.fm only returns XML.
*
* @var string
*/
protected $responseFormat = 'xml';
/**
* Override the key param, since, again, Lastfm wants to be different.
*
* @var string
*/
protected $keyParam = 'api_key';
/**
* Construct an instance of Lastfm service.
*
* @param string $key Last.fm API key.
* @param string $secret Last.fm API shared secret.
* @param Client $client The Guzzle HTTP client.
*/
public function __construct($key = null, $secret = null, Client $client = null)
{
parent::__construct(
$key ?: env('LASTFM_API_KEY'),
$secret ?: env('LASTFM_API_SECRET'),
'https://ws.audioscrobbler.com/2.0',
$client ?: new Client()
);
}
/**
* Get information about an artist.
*
* @param $name string Name of the artist
*
* @return object|false
*/
public function getArtistInfo($name)
{
if (!$this->enabled()) {
return false;
}
$name = urlencode($name);
try {
$cacheKey = md5("lastfm_artist_$name");
if ($response = Cache::get($cacheKey)) {
$response = simplexml_load_string($response);
} else {
if ($response = $this->get("?method=artist.getInfo&autocorrect=1&artist=$name")) {
Cache::put($cacheKey, $response->asXML(), 24 * 60 * 7);
}
}
$response = json_decode(json_encode($response), true);
if (!$response || !$artist = array_get($response, 'artist')) {
return false;
}
return [
'url' => array_get($artist, 'url'),
'image' => count($artist['image']) > 3 ? $artist['image'][3] : $artist['image'][0],
'bio' => [
'summary' => $this->formatText(array_get($artist, 'bio.summary')),
'full' => $this->formatText(array_get($artist, 'bio.content')),
],
];
} catch (\Exception $e) {
Log::error($e);
return false;
}
}
/**
* Correctly format a string returned by Last.fm.
*
* @param string $str
*
* @return string
*/
protected function formatText($str)
{
if (!$str) {
return '';
}
return trim(str_replace('Read more on Last.fm', '', nl2br(strip_tags(html_entity_decode($str)))));
}
/**
* Get information about an album.
*
* @param string $name Name of the album
* @param string $artistName Name of the artist
*
* @return array|false
*/
public function getAlbumInfo($name, $artistName)
{
if (!$this->enabled()) {
return false;
}
$name = urlencode($name);
$artistName = urlencode($artistName);
try {
$cacheKey = md5("lastfm_album_{$name}_{$artistName}");
if ($response = Cache::get($cacheKey)) {
$response = simplexml_load_string($response);
} else {
if ($response = $this->get("?method=album.getInfo&autocorrect=1&album=$name&artist=$artistName")) {
Cache::put($cacheKey, $response->asXML(), 24 * 60 * 7);
}
}
$response = json_decode(json_encode($response), true);
if (!$response || !$album = array_get($response, 'album')) {
return false;
}
return [
'url' => array_get($album, 'url'),
'image' => count($album['image']) > 3 ? $album['image'][3] : $album['image'][0],
'wiki' => [
'summary' => $this->formatText(array_get($album, 'wiki.summary')),
'full' => $this->formatText(array_get($album, 'wiki.content')),
],
'tracks' => array_map(function ($track) {
return [
'title' => $track['name'],
'length' => (int) $track['duration'],
'url' => $track['url'],
];
}, array_get($album, 'tracks.track', [])),
];
} catch (\Exception $e) {
Log::error($e);
return false;
}
}
/**
* Determine if Last.fm integration is enabled.
*
* @return bool
*/
public function enabled()
{
return $this->getKey() && $this->getSecret();
}
}

View file

@ -0,0 +1,208 @@
<?php
namespace App\Services;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use InvalidArgumentException;
/**
* Class RESTfulService.
*
* @method object get($uri)
* @method object post($uri, array $data = [])
* @method object put($uri, array $data = [])
* @method object patch($uri, array $data = [])
* @method object head($uri, array $data = [])
* @method object delete($uri)
*/
class RESTfulService
{
protected $responseFormat = 'json';
/**
* The API endpoint.
*
* @var string
*/
protected $endpoint = null;
/**
* The GuzzleHttp client to talk to the API.
*
* @var Client;
*/
protected $client;
/**
* The API key.
*
* @var string
*/
protected $key;
/**
* The query parameter name for the key.
* For example, Last.fm use api_key, like this:
* https://ws.audioscrobbler.com/2.0?method=artist.getInfo&artist=Kamelot&api_key=API_KEY.
*
* @var string
*/
protected $keyParam = 'key';
/**
* The API secret.
*
* @var string
*/
protected $secret;
public function __construct($key, $secret, $endpoint, Client $client)
{
$this->setKey($key);
$this->setSecret($secret);
$this->setEndpoint($endpoint);
$this->setClient($client);
}
/**
* Make a request to the API.
*
* @param string $verb The HTTP verb
* @param string $uri The API URI (segment)
* @param array $params An array of parameters
*
* @return object The JSON response.
*/
public function request($verb, $uri, $params = [])
{
try {
$body = (string) $this->getClient()
->$verb($this->buildUrl($uri), ['form_params' => $params])
->getBody();
if ($this->responseFormat === 'json') {
return json_decode($body);
}
if ($this->responseFormat === 'xml') {
return simplexml_load_string($body);
}
return $body;
} catch (ClientException $e) {
return false;
}
}
/**
* Make an HTTP call to the external resource.
*
* @param string $method The HTTP method
* @param array $args An array of parameters
*
* @return object
*/
public function __call($method, $args)
{
if (count($args) < 1) {
throw new InvalidArgumentException('Magic request methods require a URI and optional options array');
}
$uri = $args[0];
$opts = isset($args[1]) ? $args[1] : [];
return $this->request($method, $uri, $opts);
}
/**
* Turn a URI segment into a full API URL.
*
* @param string $uri
*
* @return string
*/
public function buildUrl($uri)
{
if (!starts_with($uri, ['http://', 'https://'])) {
if ($uri[0] != '/') {
$uri = "/$uri";
}
$uri = $this->endpoint.$uri;
}
// Append the API key.
if (parse_url($uri, PHP_URL_QUERY)) {
$uri .= "&{$this->keyParam}=".$this->getKey();
} else {
$uri .= "?{$this->keyParam}=".$this->getKey();
}
return $uri;
}
/**
* @return Client
*/
public function getClient()
{
return $this->client;
}
/**
* @param Client $client
*/
public function setClient($client)
{
$this->client = $client;
}
/**
* @return string
*/
public function getKey()
{
return $this->key;
}
/**
* @param string $key
*/
public function setKey($key)
{
$this->key = $key;
}
/**
* @return string
*/
public function getSecret()
{
return $this->secret;
}
/**
* @param string $secret
*/
public function setSecret($secret)
{
$this->secret = $secret;
}
/**
* @return string
*/
public function getEndpoint()
{
return $this->endpoint;
}
/**
* @param string $endpoint
*/
public function setEndpoint($endpoint)
{
$this->endpoint = $endpoint;
}
}

View file

@ -9,7 +9,8 @@
"laravel/framework": "5.1.*",
"james-heinrich/getid3": "^1.9",
"phanan/cascading-config": "~2.0",
"barryvdh/laravel-ide-helper": "^2.1"
"barryvdh/laravel-ide-helper": "^2.1",
"guzzlehttp/guzzle": "^6.1"
},
"require-dev": {
"fzaninotto/faker": "~1.4",

648
composer.lock generated
View file

@ -4,9 +4,72 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically"
],
"hash": "d6bc7d442de58681377bb1c28e216590",
"content-hash": "6ae1cb335a5047084101d16a3b864ac8",
"hash": "17203bc34b6f1fe1e3f418afa6e101f5",
"content-hash": "19b8169471239898bb337b97f362bbbf",
"packages": [
{
"name": "barryvdh/laravel-ide-helper",
"version": "v2.1.0",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-ide-helper.git",
"reference": "83999f8467374adcb8893f566c9171c9d9691f50"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-ide-helper/zipball/83999f8467374adcb8893f566c9171c9d9691f50",
"reference": "83999f8467374adcb8893f566c9171c9d9691f50",
"shasum": ""
},
"require": {
"illuminate/console": "5.0.x|5.1.x",
"illuminate/filesystem": "5.0.x|5.1.x",
"illuminate/support": "5.0.x|5.1.x",
"php": ">=5.4.0",
"phpdocumentor/reflection-docblock": "2.0.4",
"symfony/class-loader": "~2.3"
},
"require-dev": {
"doctrine/dbal": "~2.3"
},
"suggest": {
"doctrine/dbal": "Load information from the database about models for phpdocs (~2.3)"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.1-dev"
}
},
"autoload": {
"psr-4": {
"Barryvdh\\LaravelIdeHelper\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "Laravel IDE Helper, generates correct PHPDocs for all Facade classes, to improve auto-completion.",
"keywords": [
"autocomplete",
"codeintel",
"helper",
"ide",
"laravel",
"netbeans",
"phpdoc",
"phpstorm",
"sublime"
],
"time": "2015-08-13 11:40:00"
},
{
"name": "classpreloader/classpreloader",
"version": "3.0.0",
@ -217,6 +280,177 @@
],
"time": "2015-11-06 14:35:42"
},
{
"name": "guzzlehttp/guzzle",
"version": "6.1.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
"reference": "c6851d6e48f63b69357cbfa55bca116448140e0c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/c6851d6e48f63b69357cbfa55bca116448140e0c",
"reference": "c6851d6e48f63b69357cbfa55bca116448140e0c",
"shasum": ""
},
"require": {
"guzzlehttp/promises": "~1.0",
"guzzlehttp/psr7": "~1.1",
"php": ">=5.5.0"
},
"require-dev": {
"ext-curl": "*",
"phpunit/phpunit": "~4.0",
"psr/log": "~1.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "6.1-dev"
}
},
"autoload": {
"files": [
"src/functions_include.php"
],
"psr-4": {
"GuzzleHttp\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"description": "Guzzle is a PHP HTTP client library",
"homepage": "http://guzzlephp.org/",
"keywords": [
"client",
"curl",
"framework",
"http",
"http client",
"rest",
"web service"
],
"time": "2015-11-23 00:47:50"
},
{
"name": "guzzlehttp/promises",
"version": "1.0.3",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
"reference": "b1e1c0d55f8083c71eda2c28c12a228d708294ea"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/promises/zipball/b1e1c0d55f8083c71eda2c28c12a228d708294ea",
"reference": "b1e1c0d55f8083c71eda2c28c12a228d708294ea",
"shasum": ""
},
"require": {
"php": ">=5.5.0"
},
"require-dev": {
"phpunit/phpunit": "~4.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0-dev"
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Promise\\": "src/"
},
"files": [
"src/functions_include.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"description": "Guzzle promises library",
"keywords": [
"promise"
],
"time": "2015-10-15 22:28:00"
},
{
"name": "guzzlehttp/psr7",
"version": "1.2.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "4d0bdbe1206df7440219ce14c972aa57cc5e4982"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/4d0bdbe1206df7440219ce14c972aa57cc5e4982",
"reference": "4d0bdbe1206df7440219ce14c972aa57cc5e4982",
"shasum": ""
},
"require": {
"php": ">=5.4.0",
"psr/http-message": "~1.0"
},
"provide": {
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"phpunit/phpunit": "~4.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0-dev"
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Psr7\\": "src/"
},
"files": [
"src/functions_include.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"description": "PSR-7 message implementation",
"keywords": [
"http",
"message",
"stream",
"uri"
],
"time": "2015-11-03 01:34:55"
},
{
"name": "jakub-onderka/php-console-color",
"version": "0.1",
@ -878,6 +1112,152 @@
],
"time": "2015-12-10 14:48:13"
},
{
"name": "phanan/cascading-config",
"version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/phanan/cascading-config.git",
"reference": "02efc75ae964f63f0c2a40a22654111fecea895c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phanan/cascading-config/zipball/02efc75ae964f63f0c2a40a22654111fecea895c",
"reference": "02efc75ae964f63f0c2a40a22654111fecea895c",
"shasum": ""
},
"require-dev": {
"laravel/framework": "~5.1",
"laravel/lumen-framework": "~5.1.6",
"phpunit/phpunit": "~5.0"
},
"type": "library",
"autoload": {
"psr-4": {
"PhanAn\\CascadingConfig\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Phan An",
"email": "me@phanan.net",
"homepage": "http://phanan.net"
}
],
"description": "Bringing the cascading configuration system back to Laravel 5.",
"homepage": "https://github.com/phanan/cascading-config",
"keywords": [
"cascade",
"cascading",
"config",
"configuration",
"laravel",
"laravel 5"
],
"time": "2015-11-16 17:01:33"
},
{
"name": "phpdocumentor/reflection-docblock",
"version": "2.0.4",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
"reference": "d68dbdc53dc358a816f00b300704702b2eaff7b8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/d68dbdc53dc358a816f00b300704702b2eaff7b8",
"reference": "d68dbdc53dc358a816f00b300704702b2eaff7b8",
"shasum": ""
},
"require": {
"php": ">=5.3.3"
},
"require-dev": {
"phpunit/phpunit": "~4.0"
},
"suggest": {
"dflydev/markdown": "~1.0",
"erusev/parsedown": "~1.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-0": {
"phpDocumentor": [
"src/"
]
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mike van Riel",
"email": "mike.vanriel@naenius.com"
}
],
"time": "2015-02-03 12:10:50"
},
{
"name": "psr/http-message",
"version": "1.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-message.git",
"reference": "85d63699f0dbedb190bbd4b0d2b9dc707ea4c298"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-message/zipball/85d63699f0dbedb190bbd4b0d2b9dc707ea4c298",
"reference": "85d63699f0dbedb190bbd4b0d2b9dc707ea4c298",
"shasum": ""
},
"require": {
"php": ">=5.3.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "http://www.php-fig.org/"
}
],
"description": "Common interface for HTTP messages",
"keywords": [
"http",
"http-message",
"psr",
"psr-7",
"request",
"response"
],
"time": "2015-05-04 20:22:00"
},
{
"name": "psr/log",
"version": "1.0.0",
@ -1041,6 +1421,58 @@
],
"time": "2015-06-06 14:19:39"
},
{
"name": "symfony/class-loader",
"version": "v2.8.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/class-loader.git",
"reference": "51f83451bf0ddfc696e47e4642d6cd10fcfce160"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/class-loader/zipball/51f83451bf0ddfc696e47e4642d6cd10fcfce160",
"reference": "51f83451bf0ddfc696e47e4642d6cd10fcfce160",
"shasum": ""
},
"require": {
"php": ">=5.3.9"
},
"require-dev": {
"symfony/finder": "~2.0,>=2.0.5|~3.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.8-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Component\\ClassLoader\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony ClassLoader Component",
"homepage": "https://symfony.com",
"time": "2015-11-26 07:00:59"
},
{
"name": "symfony/console",
"version": "v2.7.7",
@ -1911,69 +2343,6 @@
}
],
"packages-dev": [
{
"name": "barryvdh/laravel-ide-helper",
"version": "v2.1.0",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-ide-helper.git",
"reference": "83999f8467374adcb8893f566c9171c9d9691f50"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-ide-helper/zipball/83999f8467374adcb8893f566c9171c9d9691f50",
"reference": "83999f8467374adcb8893f566c9171c9d9691f50",
"shasum": ""
},
"require": {
"illuminate/console": "5.0.x|5.1.x",
"illuminate/filesystem": "5.0.x|5.1.x",
"illuminate/support": "5.0.x|5.1.x",
"php": ">=5.4.0",
"phpdocumentor/reflection-docblock": "2.0.4",
"symfony/class-loader": "~2.3"
},
"require-dev": {
"doctrine/dbal": "~2.3"
},
"suggest": {
"doctrine/dbal": "Load information from the database about models for phpdocs (~2.3)"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.1-dev"
}
},
"autoload": {
"psr-4": {
"Barryvdh\\LaravelIdeHelper\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "Laravel IDE Helper, generates correct PHPDocs for all Facade classes, to improve auto-completion.",
"keywords": [
"autocomplete",
"codeintel",
"helper",
"ide",
"laravel",
"netbeans",
"phpdoc",
"phpstorm",
"sublime"
],
"time": "2015-08-13 11:40:00"
},
{
"name": "doctrine/instantiator",
"version": "1.0.5",
@ -2190,103 +2559,6 @@
],
"time": "2015-04-02 19:54:00"
},
{
"name": "phanan/cascading-config",
"version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/phanan/cascading-config.git",
"reference": "02efc75ae964f63f0c2a40a22654111fecea895c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phanan/cascading-config/zipball/02efc75ae964f63f0c2a40a22654111fecea895c",
"reference": "02efc75ae964f63f0c2a40a22654111fecea895c",
"shasum": ""
},
"require-dev": {
"laravel/framework": "~5.1",
"laravel/lumen-framework": "~5.1.6",
"phpunit/phpunit": "~5.0"
},
"type": "library",
"autoload": {
"psr-4": {
"PhanAn\\CascadingConfig\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Phan An",
"email": "me@phanan.net",
"homepage": "http://phanan.net"
}
],
"description": "Bringing the cascading configuration system back to Laravel 5.",
"homepage": "https://github.com/phanan/cascading-config",
"keywords": [
"cascade",
"cascading",
"config",
"configuration",
"laravel",
"laravel 5"
],
"time": "2015-11-16 17:01:33"
},
{
"name": "phpdocumentor/reflection-docblock",
"version": "2.0.4",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
"reference": "d68dbdc53dc358a816f00b300704702b2eaff7b8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/d68dbdc53dc358a816f00b300704702b2eaff7b8",
"reference": "d68dbdc53dc358a816f00b300704702b2eaff7b8",
"shasum": ""
},
"require": {
"php": ">=5.3.3"
},
"require-dev": {
"phpunit/phpunit": "~4.0"
},
"suggest": {
"dflydev/markdown": "~1.0",
"erusev/parsedown": "~1.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-0": {
"phpDocumentor": [
"src/"
]
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mike van Riel",
"email": "mike.vanriel@naenius.com"
}
],
"time": "2015-02-03 12:10:50"
},
{
"name": "phpspec/php-diff",
"version": "v1.0.2",
@ -3198,58 +3470,6 @@
"homepage": "https://github.com/sebastianbergmann/version",
"time": "2015-06-21 13:59:46"
},
{
"name": "symfony/class-loader",
"version": "v2.8.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/class-loader.git",
"reference": "51f83451bf0ddfc696e47e4642d6cd10fcfce160"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/class-loader/zipball/51f83451bf0ddfc696e47e4642d6cd10fcfce160",
"reference": "51f83451bf0ddfc696e47e4642d6cd10fcfce160",
"shasum": ""
},
"require": {
"php": ">=5.3.9"
},
"require-dev": {
"symfony/finder": "~2.0,>=2.0.5|~3.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.8-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Component\\ClassLoader\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony ClassLoader Component",
"homepage": "https://symfony.com",
"time": "2015-11-26 07:00:59"
},
{
"name": "symfony/yaml",
"version": "v3.0.0",

View file

@ -150,6 +150,7 @@ return [
App\Providers\RouteServiceProvider::class,
App\Providers\MediaServiceProvider::class,
App\Providers\UtilServiceProvider::class,
App\Providers\LastfmServiceProvider::class,
],
@ -202,6 +203,7 @@ return [
'Media' => App\Facades\Media::class,
'Util' => App\Facades\Util::class,
'Lastfm' => App\Facades\Lastfm::class,
],

View file

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddPreferencesToUsersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->text('preferences')->after('is_admin')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('preferences');
});
}
}

View file

@ -24,5 +24,7 @@
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_DRIVER" value="sync"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="LASTFM_API_KEY" value="foo"/>
<env name="LASTFM_API_SECRET" value="bar"/>
</php>
</phpunit>

View file

@ -239,6 +239,7 @@
<style lang="sass">
@import "resources/assets/sass/partials/_vars.scss";
@import "resources/assets/sass/partials/_mixins.scss";
@import "resources/assets/sass/partials/_shared.scss";
#app {
display: flex;

View file

@ -0,0 +1,124 @@
<template>
<article v-if="song" id="albumInfo">
<h1>
<span>{{ song.album.name }}</span>
<a class="shuffle" @click.prevent="shuffleAll"><i class="fa fa-random"></i></a>
</h1>
<div v-if="song.album.info">
<img v-if="song.album.info.image" :src="song.album.info.image"
title=""
class="cover">
<div class="wiki" v-if="song.album.info.wiki && song.album.info.wiki.summary">
<div class="summary" v-show="!showingFullWiki">{{{ song.album.info.wiki.summary }}}</div>
<div class="full" v-show="showingFullWiki">{{{ song.album.info.wiki.full }}}</div>
<button class="more" v-show="!showingFullWiki" @click.prevent="showingFullWiki = !showingFullWiki">
Full Wiki
</button>
</div>
<section class="track-listing" v-if="song.album.info.tracks.length">
<h1>Track Listing</h1>
<ul class="tracks">
<li v-for="track in song.album.info.tracks">
<span class="no">{{ $index + 1 }}</span>
<span class="title">{{ track.title }}</span>
<span class="length">{{ track.fmtLength }}</span>
</li>
</ul>
</section>
<footer>Data &copy; <a target="_blank" href="{{{ song.album.info.url }}}">Last.fm</a></footer>
</div>
<p class="none" v-else>
No album information found. At all.
</p>
</article>
</template>
<script>
import playback from '../../../services/playback';
export default {
replace: false,
data() {
return {
song: null,
showingFullWiki: false,
};
},
methods: {
resetState() {
this.song = null;
this.showingFullWiki = false;
},
shuffleAll() {
playback.playAllInAlbum(this.song.album);
}
},
events: {
'song:info-loaded': function (song) {
this.song = song;
},
},
}
</script>
<style lang="sass">
@import "resources/assets/sass/partials/_vars.scss";
@import "resources/assets/sass/partials/_mixins.scss";
#albumInfo {
img.cover {
width: 100%;
height: auto;
}
.wiki {
margin-top: 16px;
}
.track-listing {
margin-top: 16px;
h1 {
font-size: 20px;
margin-bottom: 0;
display: block;
}
li {
display: flex;
justify-content: space-between;
padding: 8px;
&:nth-child(even) {
background: rgba(255, 255, 255, 0.05);
}
.no {
flex: 0 0 24px;
opacity: .5;
}
.title {
flex: 1;
}
.length {
flex: 0 0 48px;
text-align: right;
opacity: .5;
}
}
}
}
</style>

View file

@ -0,0 +1,77 @@
<template>
<article v-if="song" id="artistInfo">
<h1>
<span>{{ song ? song.album.artist.name : '' }}</span>
<a class="shuffle" @click.prevent="shuffleAll"><i class="fa fa-random"></i></a>
</h1>
<div v-if="song.album.artist.info">
<img v-if="song.album.artist.info.image" :src="song.album.artist.info.image"
title="They see me posin, they hatin"
class="cool-guys-posing cover">
<div class="bio" v-if="song.album.artist.info.bio.summary">
<div class="summary" v-show="!showingFullBio">{{{ song.album.artist.info.bio.summary }}}</div>
<div class="full" v-show="showingFullBio">{{{ song.album.artist.info.bio.full }}}</div>
<button class="more" v-show="!showingFullBio" @click.prevent="showingFullBio = !showingFullBio">
Full Bio
</button>
</div>
<p class="none" v-else>This artist has no Last.fm biography yet.</p>
<footer>Data &copy; <a target="_blank" href="{{{ song.album.artist.info.url }}}">Last.fm</a></footer>
</div>
<p class="none" v-else>Nothing can be found. This artist is a mystery.</p>
</article>
</template>
<script>
import playback from '../../../services/playback';
export default {
replace: false,
data() {
return {
song: null,
showingFullBio: false,
};
},
methods: {
resetState() {
this.song = null;
this.showingFullBio = false;
},
shuffleAll() {
playback.playAllByArtist(this.song.album.artist);
},
},
events: {
'song:info-loaded': function (song) {
this.song = song;
},
},
}
</script>
<style lang="sass">
@import "resources/assets/sass/partials/_vars.scss";
@import "resources/assets/sass/partials/_mixins.scss";
#artistInfo {
img.cool-guys-posing {
width: 100%;
height: auto;
}
.bio {
margin-top: 16px;
}
}
</style>

View file

@ -1,25 +1,41 @@
<template>
<section id="extra" :class="{ showing: prefs.showExtraPanel }">
<h1>Lyrics</h1>
<div class="tabs">
<div class="header clear">
<a @click.prevent="currentView = 'lyrics'"
:class="{ active: currentView == 'lyrics' }">Lyrics</a>
<a @click.prevent="currentView = 'artistInfo'"
:class="{ active: currentView == 'artistInfo' }">Artist</a>
<a @click.prevent="currentView = 'albumInfo'"
:class="{ active: currentView == 'albumInfo' }">Album</a>
</div>
<div class="content">
<lyrics></lyrics>
<div class="panes">
<lyrics v-ref:lyrics v-show="currentView == 'lyrics'"></lyrics>
<artist-info v-ref:artist-info v-show="currentView == 'artistInfo'"></artist-info>
<album-info v-ref:album-info v-show="currentView == 'albumInfo'"></album-info>
</div>
</div>
</section>
</template>
<script>
import isMobile from 'ismobilejs';
import _ from 'lodash';
import lyrics from './lyrics.vue';
import artistInfo from './artist-info.vue'
import albumInfo from './album-info.vue'
import preferenceStore from '../../../stores/preference';
import songStore from '../../../stores/song';
export default {
components: { lyrics },
components: { lyrics, artistInfo, albumInfo },
data() {
return {
prefs: preferenceStore.state,
currentView: 'lyrics',
};
},
@ -39,6 +55,19 @@
this.prefs.showExtraPanel = false;
}
},
'song:play': function (song) {
// Reset all applicable child components' states
_.each(this.$refs, child => {
if (typeof child.resetState === 'function') {
child.resetState();
}
});
songStore.getInfo(song, () => {
this.$broadcast('song:info-loaded', song);
});
},
},
};
</script>
@ -49,11 +78,12 @@
#extra {
flex: 0 0 334px;
padding: 16px 16px $footerHeight;
padding: 24px 16px $footerHeight;
background: $colorExtraBgr;
max-height: calc(100vh - #{$headerHeight + $footerHeight});
overflow: auto;
display: none;
color: $color2ndText;
&.showing {
display: block;
@ -61,9 +91,77 @@
h1 {
font-weight: $fontWeight_UltraThin;
font-size: 32px;
font-size: 28px;
margin-bottom: 16px;
line-height: 64px;
line-height: 36px;
@include vertical-center();
span {
flex: 1;
margin-right: 12px;
}
a {
font-size: 14px;
&:hover {
color: $colorHighlight;
}
}
}
.tabs {
.header {
border-bottom: 1px solid $colorHighlight;
a {
padding: 4px 12px;
margin-left: 4px;
border-radius: 4px 4px 0 0;
text-transform: uppercase;
color: #fff;
opacity: .4;
border: 1px solid $colorHighlight;
margin-bottom: -1px;
float: left;
&.active {
border-bottom: 1px solid $colorExtraBgr;
color: $colorHighlight;
opacity: 1;
}
}
}
.panes {
padding: 16px 0;
}
}
.more {
margin-top: 8px;
border-radius: 3px;
background: $colorBlue;
color: #fff;
padding: 4px 8px;
display: inline-block;
text-transform: uppercase;
font-size: 80%;
}
footer {
margin-top: 24px;
font-size: 90%;
a {
color: #fff;
font-weight: $fontWeight_Normal;
&:hover {
color: #b90000;
}
}
}

View file

@ -1,41 +1,39 @@
<template>
<div id="lyrics">{{{ lyrics }}}</div>
<article id="lyrics">
<div class="content">
<div v-if="lyrics">{{{ lyrics }}}</div>
<p class="none" v-else>No lyrics found. Are you not listening to Bach?</p>
</div>
</article>
</template>
<script>
import songStore from '../../../stores/song';
export default {
replace: false,
data() {
return {
lyrics: '',
};
},
events: {
/**
* Whenever a song is played, get its lyrics from store to display.
*
* @param object song The currently played song
*
* @return true
*/
'song:play': function (song) {
this.lyrics = 'Loading…';
songStore.getLyrics(song, () => this.lyrics = song.lyrics);
return true;
methods: {
resetState() {
this.lyrics = '';
},
},
}
events: {
'song:info-loaded': function (song) {
this.lyrics = song.lyrics;
},
},
};
</script>
<style lang="sass">
@import "resources/assets/sass/partials/_vars.scss";
@import "resources/assets/sass/partials/_mixins.scss";
#lyrics {
color: $color2ndText;
}
</style>

View file

@ -83,43 +83,9 @@
.buttons {
text-align: right;
position: relative;
z-index: 2;
display: flex;
button {
$buttonHeight: 28px;
background-color: $colorHighlight;
font-size: 12px;
height: $buttonHeight;
padding: 0 16px;
line-height: $buttonHeight;
text-transform: uppercase;
display: inline-block;
border-radius: $buttonHeight/2 0px 0px $buttonHeight/2;
&:last-of-type {
border-top-right-radius: $buttonHeight/2;
border-bottom-right-radius: $buttonHeight/2;
}
&:not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
margin-left: 1px;
}
i {
margin-right: 4px;
}
&:hover {
background-color: darken($colorHighlight, 10%);
}
@include inset-when-pressed();
}
@include button-group();
}
input[type="search"] {

View file

@ -28,7 +28,7 @@
* Play all songs in the current album.
*/
play() {
playback.queueAndPlay(this.album.songs);
playback.playAllInAlbum(this.album);
},
},
};

View file

@ -27,7 +27,7 @@
* Play all songs by the current artist.
*/
play() {
playback.queueAndPlay(artistStore.getSongsByArtist(this.artist));
playback.playAllByArtist(this.artist);
},
},
};

View file

@ -40,8 +40,8 @@
@click.prevent="like"></i>
<span class="control"
@click.prevent="toggleLyrics"
:class="{ active: prefs.showExtraPanel }">Lyrics</span>
@click.prevent="toggleExtraPanel"
:class="{ active: prefs.showExtraPanel }">Info</span>
<i class="queue control fa fa-list-ol control"
:class="{ 'active': viewingQueue }"
@ -216,9 +216,9 @@
/**
* <That's it. That's it!>
*
* Toggle hide or show the lyrics panel.
* Toggle hide or show the extra panel.
*/
toggleLyrics() {
toggleExtraPanel() {
preferenceStore.set('showExtraPanel', !this.prefs.showExtraPanel);
},

View file

@ -3,6 +3,8 @@ import $ from 'jquery';
import queueStore from '../stores/queue';
import songStore from '../stores/song';
import artistStore from '../stores/artist';
import albumStore from '../stores/album';
import preferenceStore from '../stores/preference';
import config from '../config';
@ -294,4 +296,24 @@ export default {
this.play(queueStore.first());
},
/**
* Play all songs by an artist.
*
* @param {Object} artist The artist object
* @param {Boolean} shuffle Whether to shuffle the songs
*/
playAllByArtist(artist, shuffle = true) {
this.queueAndPlay(artistStore.getSongsByArtist(artist), true);
},
/**
* Play all songs in an album.
*
* @param {Object} album The album object
* @param {Boolean} shuffle Whether to shuffle the songs
*/
playAllInAlbum(album, shuffle = true) {
this.queueAndPlay(album.songs, true);
},
};

View file

@ -107,13 +107,14 @@ export default {
},
/**
* Get a song's lyrics.
* A HTTP request will be made if the song has no lyrics attribute yet.
* Get extra song information (lyrics, artist info, album info).
*
* @param object song
* @param function cb
* @param {Object} song
* @param {Function} cb
*
* @return {Object}
*/
getLyrics(song, cb = null) {
getInfo(song, cb = null) {
if (!_.isUndefined(song.lyrics)) {
if (cb) {
cb();
@ -122,12 +123,31 @@ export default {
return;
}
http.get(`${song.id}/lyrics`, lyrics => {
song.lyrics = lyrics;
http.get(`${song.id}/info`, data => {
song.lyrics = data.lyrics;
// If the artist image is not in a nice form, don't use it.
if (data.artist_info && typeof data.artist_info.image !== 'string') {
data.artist_info.image = null;
}
song.album.artist.info = data.artist_info;
// Convert the duration into i:s
if (data.album_info && data.album_info.tracks) {
_.each(data.album_info.tracks, track => track.fmtLength = utils.secondsToHis(track.length));
}
// If the album cover is not in a nice form, don't use it.
if (data.album_info && typeof data.album_info.image !== 'string') {
data.album_info.image = null;
}
song.album.info = data.album_info;
if (cb) {
cb();
}
});
}
},
};

View file

@ -104,3 +104,42 @@
box-shadow: inset 0px 10px 10px -10px rgba(0,0,0,1);
}
}
@mixin button-group() {
display: flex;
position: relative;
button {
$buttonHeight: 28px;
background-color: $colorHighlight;
font-size: 12px;
height: $buttonHeight;
padding: 0 16px;
line-height: $buttonHeight;
text-transform: uppercase;
display: inline-block;
border-radius: $buttonHeight/2 0px 0px $buttonHeight/2;
&:last-of-type {
border-top-right-radius: $buttonHeight/2;
border-bottom-right-radius: $buttonHeight/2;
}
&:not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
margin-left: 1px;
}
i {
margin-right: 4px;
}
&:hover {
background-color: darken($colorHighlight, 10%);
}
@include inset-when-pressed();
}
}

View file

@ -43,9 +43,10 @@ a, a:link, a:visited {
}
.clear, .clearfix {
&:after {
&::after {
content: " ";
clear: both;
display: block;
}
}

View file

@ -35,7 +35,7 @@ class ArtistTest extends TestCase
public function testUtf16Names()
{
$name = file_get_contents(dirname(__FILE__).'/stubs/utf16');
$name = file_get_contents(dirname(__FILE__).'/blobs/utf16');
$artist = Artist::get($name);
$artist = Artist::get($name); // to make sure there's no constraint exception

88
tests/LastfmTest.php Normal file
View file

@ -0,0 +1,88 @@
<?php
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Response;
use App\Services\Lastfm;
class LastfmTest extends TestCase
{
use DatabaseTransactions, WithoutMiddleware;
public function testGetArtistInfo()
{
$client = \Mockery::mock(Client::class, [
'get' => new Response(200, [], file_get_contents(dirname(__FILE__).'/blobs/lastfm/artist.xml')),
]);
$api = new Lastfm(null, null, $client);
$this->assertEquals([
'url' => 'http://www.last.fm/music/Kamelot',
'image' => 'http://foo.bar/extralarge.jpg',
'bio' => [
'summary' => 'Quisque ut nisi.',
'full' => 'Quisque ut nisi. Vestibulum ullamcorper mauris at ligula.',
],
], $api->getArtistInfo('foo'));
// Is it cached?
$this->assertNotNull(Cache::get(md5('lastfm_artist_foo')));
}
public function testGetArtistInfoFailed()
{
$client = \Mockery::mock(Client::class, [
'get' => new Response(400, [], file_get_contents(dirname(__FILE__).'/blobs/lastfm/artist-notfound.xml')),
]);
$api = new Lastfm(null, null, $client);
$this->assertFalse($api->getArtistInfo('foo'));
}
public function testGetAlbumInfo()
{
$client = \Mockery::mock(Client::class, [
'get' => new Response(200, [], file_get_contents(dirname(__FILE__).'/blobs/lastfm/album.xml')),
]);
$api = new Lastfm(null, null, $client);
$this->assertEquals([
'url' => 'http://www.last.fm/music/Kamelot/Epica',
'image' => 'http://foo.bar/extralarge.jpg',
'tracks' => [
[
'title' => 'Track 1',
'url' => 'http://foo/track1',
'length' => 100,
],
[
'title' => 'Track 2',
'url' => 'http://foo/track2',
'length' => 150,
],
],
'wiki' => [
'summary' => 'Quisque ut nisi.',
'full' => 'Quisque ut nisi. Vestibulum ullamcorper mauris at ligula.',
],
], $api->getAlbumInfo('foo', 'bar'));
// Is it cached?
$this->assertNotNull(Cache::get(md5('lastfm_album_foo_bar')));
}
public function testGetAlbumInfoFailed()
{
$client = \Mockery::mock(Client::class, [
'get' => new Response(400, [], file_get_contents(dirname(__FILE__).'/blobs/lastfm/album-notfound.xml')),
]);
$api = new Lastfm(null, null, $client);
$this->assertFalse($api->getAlbumInfo('foo', 'bar'));
}
}

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Response;
use App\Services\RESTfulService;
class RESTfulAPIServiceTest extends TestCase
{
use DatabaseTransactions, WithoutMiddleware;
public function testUrlConstruction()
{
$api = new RESTfulService('bar', null, 'http://foo.com', \Mockery::mock(Client::class));
$this->assertEquals('http://foo.com/get/param?key=bar', $api->buildUrl('get/param'));
$this->assertEquals('http://foo.com/get/param?baz=moo&key=bar', $api->buildUrl('/get/param?baz=moo'));
$this->assertEquals('http://baz.com/?key=bar', $api->buildUrl('http://baz.com/'));
}
public function testRequest()
{
$client = \Mockery::mock(Client::class, [
'get' => new Response(200, [], '{"foo":"bar"}'),
'post' => new Response(200, [], '{"foo":"bar"}'),
'delete' => new Response(200, [], '{"foo":"bar"}'),
'put' => new Response(200, [], '{"foo":"bar"}'),
]);
$api = new RESTfulService('foo', null, 'http://foo.com', $client);
$this->assertObjectHasAttribute('foo', $api->get('/'));
$this->assertObjectHasAttribute('foo', $api->post('/'));
$this->assertObjectHasAttribute('foo', $api->put('/'));
$this->assertObjectHasAttribute('foo', $api->delete('/'));
}
}

View file

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8" ?>
<lfm status="failed"><error code="6">Album not found</error>
</lfm>

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8" ?>
<lfm status="ok"><album><name>Epica</name>
<artist>Kamelot</artist>
<mbid>4c5b22d5-e901-3e0c-89b1-ded24953449a</mbid>
<url>http://www.last.fm/music/Kamelot/Epica</url>
<image size="small">http://foo.bar/small.jpg</image>
<image size="medium">http://foo.bar/medium.jpg</image>
<image size="large">http://foo.bar/large.jpg</image>
<image size="extralarge">http://foo.bar/extralarge.jpg</image>
<image size="mega">http://foo.bar/mega.jpg</image>
<image size="">http://foo.bar/fuckisthis.jpg</image>
<listeners>184505</listeners>
<playcount>4433646</playcount>
<tracks><track rank="1"><name>Track 1</name>
<url>http://foo/track1</url>
<duration>100</duration>
<streamable fulltrack="0">0</streamable>
<artist><name>Kamelot</name>
<mbid>46f4cd77-8870-4ad8-a205-1bee18901d0f</mbid>
<url>http://www.last.fm/music/Kamelot</url>
</artist>
</track>
<track rank="2"><name>Track 2</name>
<url>http://foo/track2</url>
<duration>150</duration>
<streamable fulltrack="0">0</streamable>
<artist><name>Kamelot</name>
<mbid>46f4cd77-8870-4ad8-a205-1bee18901d0f</mbid>
<url>http://www.last.fm/music/Kamelot</url>
</artist>
</track>
</tracks>
<tags><tag><name>Power metal</name>
<url>http://www.last.fm/tag/Power+metal</url>
</tag>
<tag><name>symphonic metal</name>
<url>http://www.last.fm/tag/symphonic+metal</url>
</tag>
<tag><name>Progressive metal</name>
<url>http://www.last.fm/tag/Progressive+metal</url>
</tag>
<tag><name>Melodic Power Metal</name>
<url>http://www.last.fm/tag/Melodic+Power+Metal</url>
</tag>
<tag><name>albums I own</name>
<url>http://www.last.fm/tag/albums+I+own</url>
</tag>
</tags>
<wiki><published>03 Sep 2008, 01:16</published>
<summary>Quisque ut nisi.</summary>
<content>Quisque ut nisi. Vestibulum ullamcorper mauris at ligula.</content>
</wiki>
</album>
</lfm>

View file

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8" ?>
<lfm status="failed"><error code="6">The artist you supplied could not be found</error>
</lfm>

View file

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8" ?>
<lfm status="ok"><artist><name>Kamelot</name>
<mbid>46f4cd77-8870-4ad8-a205-1bee18901d0f</mbid>
<url>http://www.last.fm/music/Kamelot</url>
<image size="small">http://foo.bar/small.jpg</image>
<image size="medium">http://foo.bar/medium.jpg</image>
<image size="large">http://foo.bar/large.jpg</image>
<image size="extralarge">http://foo.bar/extralarge.jpg</image>
<image size="mega">http://foo.bar/mega.jpg</image>
<image size="">http://foo.bar/fuckisthis.jpg</image>
<streamable>0</streamable>
<ontour>0</ontour>
<stats><listeners>446805</listeners>
<playcount>30274291</playcount>
</stats>
<similar><artist><name>Serenity</name>
<url>http://www.last.fm/music/Serenity</url>
<image size="small">http://img2-ak.lst.fm/i/u/34s/98efb50f63fd430d815fed3bb151b8e4.png</image>
<image size="medium">http://img2-ak.lst.fm/i/u/64s/98efb50f63fd430d815fed3bb151b8e4.png</image>
<image size="large">http://img2-ak.lst.fm/i/u/174s/98efb50f63fd430d815fed3bb151b8e4.png</image>
<image size="extralarge">http://img2-ak.lst.fm/i/u/300x300/98efb50f63fd430d815fed3bb151b8e4.png</image>
<image size="mega">http://img2-ak.lst.fm/i/u/98efb50f63fd430d815fed3bb151b8e4.png</image>
<image size="">http://img2-ak.lst.fm/i/u/arQ/98efb50f63fd430d815fed3bb151b8e4.png</image>
</artist>
<artist><name>Seventh Wonder</name>
<url>http://www.last.fm/music/Seventh+Wonder</url>
<image size="small">http://img2-ak.lst.fm/i/u/34s/9bb1ed430424492dc9a39bc5d0ec00dc.png</image>
<image size="medium">http://img2-ak.lst.fm/i/u/64s/9bb1ed430424492dc9a39bc5d0ec00dc.png</image>
<image size="large">http://img2-ak.lst.fm/i/u/174s/9bb1ed430424492dc9a39bc5d0ec00dc.png</image>
<image size="extralarge">http://img2-ak.lst.fm/i/u/300x300/9bb1ed430424492dc9a39bc5d0ec00dc.png</image>
<image size="mega">http://img2-ak.lst.fm/i/u/9bb1ed430424492dc9a39bc5d0ec00dc.png</image>
<image size="">http://img2-ak.lst.fm/i/u/arQ/9bb1ed430424492dc9a39bc5d0ec00dc.png</image>
</artist>
</similar>
<tags><tag><name>Power metal</name>
<url>http://www.last.fm/tag/Power+metal</url>
</tag>
<tag><name>symphonic metal</name>
<url>http://www.last.fm/tag/symphonic+metal</url>
</tag>
<tag><name>Progressive metal</name>
<url>http://www.last.fm/tag/Progressive+metal</url>
</tag>
<tag><name>metal</name>
<url>http://www.last.fm/tag/metal</url>
</tag>
<tag><name>melodic metal</name>
<url>http://www.last.fm/tag/melodic+metal</url>
</tag>
</tags>
<bio><links><link rel="original" href="http://last.fm/music/Kamelot/+wiki"></link>
</links>
<published>10 Feb 2006, 13:43</published>
<summary>Quisque ut nisi.</summary>
<content>Quisque ut nisi. Vestibulum ullamcorper mauris at ligula.</content>
</bio>
</artist>
</lfm>