Add Last.fm scrobbling functionality

This commit is contained in:
An Phan 2015-12-20 20:17:35 +08:00
parent d6c440c2ee
commit f449a1a744
18 changed files with 622 additions and 62 deletions

View file

@ -30,8 +30,8 @@ class DataController extends Controller
'playlists' => $playlists,
'interactions' => Interaction::byCurrentUser()->get(),
'users' => auth()->user()->is_admin ? User::all() : [],
'user' => auth()->user(),
'useLastfm' => env('LASTFM_API_KEY') && env('LASTFM_SECRET'),
'currentUser' => auth()->user(),
'useLastfm' => env('LASTFM_API_KEY') && env('LASTFM_API_SECRET'),
]);
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\API;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use App\Services\Lastfm;
class LastfmController extends Controller
{
/*
* The Guard implementation.
*
* @var Guard
*/
protected $auth;
/**
* Construct the controller with a custom Redirector via DI.
*
* @param Redirector $redirector
* @param Guard $auth
*/
public function __construct(Guard $auth)
{
$this->auth = $auth;
}
/**
* Connect the current user to Last.fm.
*
* @param Redirector $redirector
* @param Lastfm $lastfm
*
* @return \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse
*/
public function connect(Redirector $redirector, Lastfm $lastfm)
{
if (!$lastfm->enabled()) {
abort(401, 'Koel is not configured to use with Last.fm yet.');
}
return $redirector->to(
'https://www.last.fm/api/auth/?api_key='
.$lastfm->getKey()
.'&cb='.route('lastfm.callback')
);
}
/**
* Serve the callback request from Last.fm.
*
* @param Request $request
* @param Lastfm $lastfm
*
* @return \Illuminate\Http\Response
*/
public function callback(Request $request, Lastfm $lastfm)
{
if (!$token = $request->input('token')) {
abort(500, 'Something wrong happened.');
}
// Get the session key using the obtained token.
if (!$sessionKey = $lastfm->getSessionKey($token)) {
abort(500, 'Invalid token key.');
}
$this->auth->user()->savePreference('lastfm_session_key', $sessionKey);
return view('api.lastfm.callback');
}
/**
* Disconnect the current user from Last.fm.
*
* @return \Illuminate\Http\JsonResponse
*/
public function disconnect()
{
$this->auth->user()->deletePreference('lastfm_session_key');
return response()->json();
}
}

View file

@ -29,21 +29,9 @@ class SongController extends Controller
}
/**
* Get the lyrics of a song.
*
* @param $id
*
* @return \Illuminate\Http\JsonResponse
*/
public function getLyrics($id)
{
return response()->json(Song::findOrFail($id)->lyrics);
}
/**
* Get extra information about a song via Last.fm
* Get extra information about a song via Last.fm.
*
* @param string $id
* @param string $id
*
* @return \Illuminate\Http\JsonResponse
*/
@ -57,4 +45,17 @@ class SongController extends Controller
'artist_info' => $song->album->artist->getInfo(),
]);
}
/**
* Scrobble a song.
*
* @param string $id The song's ID
* @param string $timestamp The UNIX timestamp when the song started playing.
*
* @return \Illuminate\Http\JsonResponse
*/
public function scrobble($id, $timestamp)
{
return response()->json(Song::with('album.artist')->findOrFail($id)->scrobble($timestamp));
}
}

View file

@ -23,6 +23,10 @@ Route::group(['prefix' => 'api', 'middleware' => 'auth', 'namespace' => 'API'],
get('{id}/play', 'SongController@play')->where('id', '[a-f0-9]{32}');
get('{id}/info', 'SongController@getInfo')->where('id', '[a-f0-9]{32}');
post('{id}/scrobble/{timestamp}', 'SongController@scrobble')->where([
'id' => '[a-f0-9]{32}',
'timestamp' => '\d+',
]);
post('interaction/play', 'InteractionController@play');
post('interaction/like', 'InteractionController@like');
@ -34,4 +38,11 @@ Route::group(['prefix' => 'api', 'middleware' => 'auth', 'namespace' => 'API'],
resource('user', 'UserController', ['only' => ['store', 'update', 'destroy']]);
put('me', 'UserController@updateProfile');
get('lastfm/connect', 'LastfmController@connect');
get('lastfm/callback', [
'as' => 'lastfm.callback',
'uses' => 'LastfmController@callback',
]);
delete('lastfm/disconnect', 'LastfmController@disconnect');
});

View file

@ -3,6 +3,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Lastfm;
/**
* @property string path
@ -30,6 +31,36 @@ class Song extends Model
return $this->belongsToMany(Playlist::class);
}
/**
* Scrobble the song using Last.fm service.
*
* @param string $timestamp The UNIX timestamp in which the song started playing.
*
* @return mixed
*/
public function scrobble($timestamp)
{
// Don't scrobble the unknown guys. No one knows them.
if ($this->album->artist->id === Artist::UNKNOWN_ID) {
return false;
}
auth()->user()->setHidden([]);
// If the current user hasn't connected to Last.fm, don't do shit.
if (!$sessionKey = auth()->user()->getPreference('lastfm_session_key')) {
return false;
}
return Lastfm::scrobble(
$this->album->artist->name,
$this->title,
$timestamp,
$this->album->name === Album::UNKNOWN_NAME ? '' : $this->album->name,
$sessionKey
);
}
/**
* Sometimes the tags extracted from getID3 are HTML entity encoded.
* This makes sure they are always sane.

View file

@ -24,6 +24,13 @@ AuthenticatableContract,
*/
protected $table = 'users';
/**
* The preferences that we don't want to show to the client.
*
* @var array
*/
protected $hiddenPreferences = ['lastfm_session_key'];
/**
* The attributes that are protected from mass assign.
*
@ -52,4 +59,87 @@ AuthenticatableContract,
{
return $this->hasMany(Interaction::class);
}
/**
* Get a preference item of the current user.
*
* @param string $key
*
* @return string|null
*/
public function getPreference($key)
{
// We can't use $this->preferences directly, since the data has been tampered
// by getPreferencesAttribute().
return array_get((array) unserialize($this->attributes['preferences']), $key);
}
/**
* Save a user preference.
*
* @param string $key
* @param string $val
*/
public function savePreference($key, $val)
{
$preferences = $this->preferences;
$preferences[$key] = $val;
$this->preferences = $preferences;
$this->save();
}
/**
* An alias to savePreference().
*
* @see $this::savePreference
*/
public function setPreference($key, $val)
{
return $this->savePreference($key, $val);
}
/**
* Delete a preference.
*
* @param string $key
*/
public function deletePreference($key)
{
$preferences = $this->preferences;
array_forget($preferences, $key);
$this->update(compact('preferences'));
}
/**
* User preferences are stored as a serialized associative array.
*
* @param array $value
*/
public function setPreferencesAttribute($value)
{
$this->attributes['preferences'] = serialize($value);
}
/**
* Unserialize the user preferences back to an array before returning.
*
* @param string $value
*
* @return array
*/
public function getPreferencesAttribute($value)
{
$preferences = unserialize($value) ?: [];
// Hide the user's secrets away!
foreach ($this->hiddenPreferences as $key) {
if (isset($preferences[$key])) {
$preferences[$key] = 'hidden';
}
}
return $preferences;
}
}

View file

@ -39,6 +39,16 @@ class Lastfm extends RESTfulService
);
}
/**
* Determine if Last.fm integration is enabled.
*
* @return bool
*/
public function enabled()
{
return $this->getKey() && $this->getSecret();
}
/**
* Get information about an artist.
*
@ -86,22 +96,6 @@ class Lastfm extends RESTfulService
}
}
/**
* 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.
*
@ -159,12 +153,119 @@ class Lastfm extends RESTfulService
}
/**
* Determine if Last.fm integration is enabled.
* Get Last.fm's session key for the authenticated user using a token.
*
* @param string $token The token after successfully connecting to Last.fm
*
* @link http://www.last.fm/api/webauth#4
*
* @return string The token key
*/
public function getSessionKey($token)
{
$query = $this->buildAuthCallParams([
'method' => 'auth.getSession',
'token' => $token,
], true);
try {
$response = $this->get("/?$query", [], false);
return (string) $response->session->key;
} catch (\Exception $e) {
Log::error($e);
return false;
}
}
/**
* Scrobble a song.
*
* @param string $artist The artist name
* @param string $track The track name
* @param string|int $timestamp The UNIX timestamp
* @param string $album The album name
* @param string $sk The session key
*
* @return bool
*/
public function enabled()
public function scrobble($artist, $track, $timestamp, $album = null, $sk = null)
{
return $this->getKey() && $this->getSecret();
$params = compact('artist', 'track', 'timestamp');
if ($album) {
$params['album'] = $album;
}
$params['sk'] = $sk ?: auth()->user()->getPreference('lastfm_session_key');
$params['method'] = 'track.scrobble';
try {
return !!$this->post('/', $this->buildAuthCallParams($params), false);
} catch (\Exception $e) {
Log::error($e);
return false;
}
}
/**
* Build the parameters to use for _authenticated_ Last.fm API calls.
* Such calls require:
* - The API key (api_key)
* - The API signature (api_sig).
*
* @link http://www.last.fm/api/webauth#5
*
* @param array $params The array of parameters.
* @param bool $toString Whether to turn the array into a query string
*
* @return array|string
*/
public function buildAuthCallParams(array $params, $toString = false)
{
$params['api_key'] = $this->getKey();
ksort($params);
// Generate the API signature.
// @link http://www.last.fm/api/webauth#6
$str = '';
foreach ($params as $name => $value) {
$str .= $name.$value;
}
$str .= $this->getSecret();
$params['api_sig'] = md5($str);
if (!$toString) {
return $params;
}
$query = '';
foreach ($params as $key => $value) {
$query .= "$key=$value&";
}
return rtrim($query, '&');
}
/**
* 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)))));
}
}

View file

@ -68,17 +68,20 @@ class RESTfulService
/**
* 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
* @param string $verb The HTTP verb
* @param string $uri The API URI (segment)
* @param bool $appendKey Whether to automatically append the API key into the URI.
* While it's usually the case, some services (like Last.fm) requires
* an "API signature" of the request. Appending an API key will break the request.
* @param array $params An array of parameters
*
* @return object The JSON response.
* @return object
*/
public function request($verb, $uri, $params = [])
public function request($verb, $uri, $appendKey = true, $params = [])
{
try {
$body = (string) $this->getClient()
->$verb($this->buildUrl($uri), ['form_params' => $params])
->$verb($this->buildUrl($uri, $appendKey), ['form_params' => $params])
->getBody();
if ($this->responseFormat === 'json') {
@ -111,18 +114,20 @@ class RESTfulService
$uri = $args[0];
$opts = isset($args[1]) ? $args[1] : [];
$appendKey = isset($args[2]) ? $args[2] : true;
return $this->request($method, $uri, $opts);
return $this->request($method, $uri, $appendKey, $opts);
}
/**
* Turn a URI segment into a full API URL.
*
* @param string $uri
* @param bool $appendKey Whether to automatically append the API key into the URL.
*
* @return string
*/
public function buildUrl($uri)
public function buildUrl($uri, $appendKey = true)
{
if (!starts_with($uri, ['http://', 'https://'])) {
if ($uri[0] != '/') {
@ -132,11 +137,12 @@ class RESTfulService
$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();
if ($appendKey) {
if (parse_url($uri, PHP_URL_QUERY)) {
$uri .= "&{$this->keyParam}=".$this->getKey();
} else {
$uri .= "?{$this->keyParam}=".$this->getKey();
}
}
return $uri;

View file

@ -6,6 +6,7 @@ $factory->define(App\Models\User::class, function ($faker) {
'email' => $faker->email,
'password' => bcrypt(str_random(10)),
'is_admin' => false,
'preferences' => [],
'remember_token' => str_random(10),
];
});

View file

@ -45,6 +45,54 @@
<span @click="prefs.notify = !prefs.notify">Show Now Playing song notification</span>
</div>
</div>
<section class="lastfm" >
<h1>Last.fm Integration</h1>
<div v-if="sharedState.useLastfm">
<p>This installation of Koel integrates with Last.fm.
<span v-if="state.current.preferences.lastfm_session_key">
It appears that you have connected your Last.fm account as well Perfect!
</span>
<span v-else>
It appears that you havent connected to your Last.fm account thought.
</span>
</p>
<p>
Connecting Koel and your Last.fm account enables exciting features scrobbling is one of them.
</p>
<p v-if="state.current.preferences.lastfm_session_key">
For the sake of democracy, you have the option to disconnect from Last.fm too.
Doing so will reload Koel, though.
</p>
<div class="buttons">
<button @click.prevent="connectToLastfm" class="connect">
<i class="fa fa-lastfm"></i>
{{ state.current.preferences.lastfm_session_key ? 'Reconnect' : 'Connect' }}
</button>
<button
v-if="state.current.preferences.lastfm_session_key"
@click.prevent="disconnectFromLastfm"
class="disconnect"
>
Disconnect
</button>
</div>
</div>
<div v-else>
<p>This installation of Koel has no Last.fm integration.
<span v-if="state.current.is_admin">Visit
<a href="https://github.com/phanan/koel/wiki" target="_blank">Koels Wiki</a>
for a quick how-to. Really, you should do it.
</span>
<span v-else>Try politely asking your adminstrator to enable it.</span>
</p>
</div>
</section>
</div>
</div>
</template>
@ -54,6 +102,8 @@
import userStore from '../../../stores/user';
import preferenceStore from '../../../stores/preference';
import sharedStore from '../../../stores/shared';
import http from '../../../services/http';
export default {
data() {
@ -64,6 +114,7 @@
confirmPwd: '',
showStatus: false,
prefs: preferenceStore.state,
sharedState: sharedStore.state,
};
},
@ -98,6 +149,29 @@
savePreference() {
preferenceStore.save();
},
/**
* Connect the current user to Last.fm.
* This method opens a new window.
* Koel will reload once the connection is successful.
*/
connectToLastfm() {
window.open('/api/lastfm/connect', '_blank', 'toolbar=no,titlebar=no,location=no,width=1024,height=640');
},
/**
* Disconnect the current user from Last.fm.
* Oh God why.
*/
disconnectFromLastfm() {
// Should we use userStore?
// - We shouldn't. This doesn't have anything to do with stores.
// Should we confirm the user?
// - Nope. Users should be grown-ass adults who take responsibilty of their actions.
// But one of my users is my new born kid!
// - Then? Kids will fuck things up anyway.
http.delete('lastfm/disconnect', {}, () => window.location.reload());
}
},
};
</script>
@ -132,7 +206,33 @@
border-top: 1px solid $color2ndBgr;
}
.lastfm {
border-top: 1px solid $color2ndBgr;
color: $color2ndText;
margin-top: 16px;
padding-top: 16px;
a {
color: $colorHighlight;
}
h1 {
font-size: 24px;
margin-bottom: 16px;
}
.buttons {
margin-top: 16px;
.connect {
background: #d31f27; // Last.fm color yo!
}
.disconnect {
background: $colorGrey; // Our color yo!
}
}
}
@media only screen
and (max-device-width : 667px)

View file

@ -50,6 +50,8 @@ export default {
// Listen to 'ended' event on the audio player and play the next song in the queue.
this.player.media.addEventListener('ended', e => {
songStore.scrobble(queueStore.current());
if (preferenceStore.get('repeatMode') === 'REPEAT_ONE') {
this.player.restart();
this.player.play();
@ -82,6 +84,9 @@ export default {
// Set the song as the current song
queueStore.current(song);
// Record the UNIX timestamp the song start playing, for scrobbling purpose
song.playStartTime = Math.floor(Date.now() / 1000);
this.app.$broadcast('song:play', song);
$('title').text(`${song.title} ♫ Koel`);

View file

@ -1,4 +1,5 @@
import http from '../services/http';
import { assign } from 'lodash';
export default {
state: {
@ -12,19 +13,17 @@ export default {
settings: [],
currentUser: null,
playlists: [],
useLastfm: false,
},
init(cb = null) {
http.get('data', {}, data => {
this.state.songs = data.songs;
this.state.artists = data.artists;
this.state.albums = data.albums;
this.state.settings = data.settings;
this.state.playlists = data.playlists;
this.state.interactions = data.interactions;
this.state.users = data.users;
this.state.currentUser = data.user;
this.state.settings = data.settings;
assign(this.state, data);
// If this is a new user, initialize his preferences to be an empty object.
if (!this.state.currentUser.preferences) {
this.state.currentUser.preferences = {};
}
if (cb) {
cb();

View file

@ -6,6 +6,7 @@ import stub from '../stubs/song';
import albumStore from './album';
import favoriteStore from './favorite';
import sharedStore from './shared';
import useStore from './user';
export default {
stub,
@ -150,4 +151,24 @@ export default {
}
});
},
/**
* Scrobble a song (using Last.fm)
*
* @param {Object} song
* @param {Function} cb
*/
scrobble(song, cb = null) {
if (!sharedStore.state.useLastfm || !useStore.current().preferences.lastfm_session_key) {
return;
}
http.post(`${song.id}/scrobble/${song.playStartTime}`, () => {
if (cb) {
cb();
}
return;
});
},
};

View file

@ -11,6 +11,7 @@ $colorGreen: #56a052;
$colorBlue: #4c769a;
$colorRed: #c34848;
$colorOrange: #ff7d2e;
$colorGrey: #3c3c3c;
$colorSidebarBgr: #212121;
$colorExtraBgr: #212121;

View file

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<title>Authentication successful!</title>
<meta charset="utf-8">
</head>
<body>
<h3>Perfecto!</h3>
<p>Koel has successfully connected to your Last.fm account and is now restarting for the exciting features.</p>
<p>This window will automatically close in 3 seconds.</p>
<script>
window.opener.location.reload(false);
window.setTimeout(function () {
window.close();
}, 3000);
</script>
</body>
</html>

View file

@ -4,7 +4,13 @@ use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Response;
use App\Models\User;
use App\Services\Lastfm;
use App\Http\Controllers\API\LastfmController;
use Illuminate\Routing\Redirector;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Http\Request;;
use Mockery as m;
class LastfmTest extends TestCase
{
@ -12,7 +18,7 @@ class LastfmTest extends TestCase
public function testGetArtistInfo()
{
$client = \Mockery::mock(Client::class, [
$client = m::mock(Client::class, [
'get' => new Response(200, [], file_get_contents(dirname(__FILE__).'/blobs/lastfm/artist.xml')),
]);
@ -33,7 +39,7 @@ class LastfmTest extends TestCase
public function testGetArtistInfoFailed()
{
$client = \Mockery::mock(Client::class, [
$client = m::mock(Client::class, [
'get' => new Response(400, [], file_get_contents(dirname(__FILE__).'/blobs/lastfm/artist-notfound.xml')),
]);
@ -44,7 +50,7 @@ class LastfmTest extends TestCase
public function testGetAlbumInfo()
{
$client = \Mockery::mock(Client::class, [
$client = m::mock(Client::class, [
'get' => new Response(200, [], file_get_contents(dirname(__FILE__).'/blobs/lastfm/album.xml')),
]);
@ -77,7 +83,7 @@ class LastfmTest extends TestCase
public function testGetAlbumInfoFailed()
{
$client = \Mockery::mock(Client::class, [
$client = m::mock(Client::class, [
'get' => new Response(400, [], file_get_contents(dirname(__FILE__).'/blobs/lastfm/album-notfound.xml')),
]);
@ -85,4 +91,64 @@ class LastfmTest extends TestCase
$this->assertFalse($api->getAlbumInfo('foo', 'bar'));
}
public function testBuildAuthCallParams()
{
$api = new Lastfm('key', 'secret');
$params = [
'qux' => '安',
'bar' => 'baz',
];
$this->assertEquals([
'api_key' => 'key',
'bar' => 'baz',
'qux' => '安',
'api_sig' => '7f21233b54edea994aa0f23cf55f18a2',
], $api->buildAuthCallParams($params));
$this->assertEquals('api_key=key&bar=baz&qux=安&api_sig=7f21233b54edea994aa0f23cf55f18a2',
$api->buildAuthCallParams($params, true));
}
public function testGetSessionKey()
{
$client = m::mock(Client::class, [
'get' => new Response(200, [], file_get_contents(dirname(__FILE__).'/blobs/lastfm/session-key.xml')),
]);
$api = new Lastfm(null, null, $client);
$this->assertEquals('foo', $api->getSessionKey('bar'));
}
public function testControllerConnect()
{
$redirector = m::mock(Redirector::class);
$redirector->shouldReceive('to')->once();
$guard = m::mock(Guard::class, ['user' => factory(User::class)->create()]);
(new LastfmController($guard))->connect($redirector, new Lastfm());
}
public function testControllerCallback()
{
$request = m::mock(Request::class, ['input' => 'token']);
$lastfm = m::mock(Lastfm::class, ['getSessionKey' => 'bar']);
$user = factory(User::class)->create();
$guard = m::mock(Guard::class, ['user' => $user]);
(new LastfmController($guard))->callback($request, $lastfm);
$this->assertEquals('bar', $user->getPreference('lastfm_session_key'));
}
public function testControllerDisconnect()
{
$user = factory(User::class)->create(['preferences' => ['lastfm_session_key' => 'bar']]);
$this->actingAs($user)->delete('api/lastfm/disconnect');
$this->assertNull($user->getPreference('lastfm_session_key'));
}
}

View file

@ -69,4 +69,16 @@ class UserTest extends TestCase
->seeStatusCode(403)
->seeInDatabase('users', ['id' => $admin->id]);
}
public function testUserPreferences()
{
$user = factory(User::class)->create();
$this->assertNull($user->getPreference('foo'));
$user->setPreference('foo', 'bar');
$this->assertEquals('bar', $user->getPreference('foo'));
$user->deletePreference('foo');
$this->assertNull($user->getPreference('foo'));
}
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<lfm status="ok">
<session>
<name>koel</name>
<key>foo</key>
<subscriber>0</subscriber>
</session>
</lfm>