mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
Add Last.fm scrobbling functionality
This commit is contained in:
parent
d6c440c2ee
commit
f449a1a744
18 changed files with 622 additions and 62 deletions
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
86
app/Http/Controllers/API/LastfmController.php
Normal file
86
app/Http/Controllers/API/LastfmController.php
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)))));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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),
|
||||
];
|
||||
});
|
||||
|
|
|
@ -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 haven’t 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">Koel’s 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)
|
||||
|
|
|
@ -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`);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
@ -11,6 +11,7 @@ $colorGreen: #56a052;
|
|||
$colorBlue: #4c769a;
|
||||
$colorRed: #c34848;
|
||||
$colorOrange: #ff7d2e;
|
||||
$colorGrey: #3c3c3c;
|
||||
|
||||
$colorSidebarBgr: #212121;
|
||||
$colorExtraBgr: #212121;
|
||||
|
|
21
resources/views/api/lastfm/callback.blade.php
Normal file
21
resources/views/api/lastfm/callback.blade.php
Normal 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>
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
8
tests/blobs/lastfm/session-key.xml
Normal file
8
tests/blobs/lastfm/session-key.xml
Normal 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>
|
Loading…
Reference in a new issue