Finish structure and song download

This commit is contained in:
An Phan 2016-06-03 01:53:26 +08:00
parent 8333f38d6d
commit 5185f3dc6b
16 changed files with 325 additions and 32 deletions

View file

@ -62,6 +62,12 @@ FFMPEG_PATH=/usr/local/bin/ffmpeg
# but slower streaming and more bandwidth.
OUTPUT_BIT_RATE=128
# Whether to allow song downloading.
# Note that if you're downloading more than one song, Koel will zip them up
# using PHP's ZipArchive. So if the module isn't available in the current
# environment, such a download will (silently) fail.
ALLOW_DOWNLOAD=true
# The variables below are Laravel-specific.
# You can change them if you know what you're doing. Otherwise, just leave them as-is.

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

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

View file

@ -3,9 +3,8 @@
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller as BaseController;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller extends BaseController
{
use AuthorizesRequests;
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers\API\Download;
use App\Http\Controllers\API\Controller as BaseController;
class Controller extends BaseController
{
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\API\Download;
use App\Http\Requests\API\Download\SongRequest;
use App\Models\Song;
use Download;
class SongController extends Controller
{
/**
* Download a song or multiple songs.
*
* @param SongRequest $request
*
* @return
*/
public function download(SongRequest $request)
{
$songs = Song::whereIn('id', $request->songs)->get();
return response()->download(Download::from($songs));
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Http\Requests\API\Download;
use App\Http\Requests\API\Request as BaseRequest;
class Request extends BaseRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return env('ALLOW_DOWNLOAD', true);
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Http\Requests\API\Download;
class SongRequest extends Request
{
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'songs' => 'required|array',
];
}
}

View file

@ -29,24 +29,32 @@ Route::group(['prefix' => 'api', 'namespace' => 'API'], function () {
Route::get('{song}/info', 'SongController@show');
Route::put('songs', 'SongController@update');
// Interaction routes
Route::post('interaction/play', 'InteractionController@play');
Route::post('interaction/like', 'InteractionController@like');
Route::post('interaction/batch/like', 'InteractionController@batchLike');
Route::post('interaction/batch/unlike', 'InteractionController@batchUnlike');
// Playlist routes
Route::resource('playlist', 'PlaylistController');
Route::put('playlist/{playlist}/sync', 'PlaylistController@sync')->where(['playlist' => '\d+']);
// User and user profile routes
Route::resource('user', 'UserController', ['only' => ['store', 'update', 'destroy']]);
Route::put('me', 'ProfileController@update');
// Last.fm-related routes
Route::get('lastfm/connect', 'LastfmController@connect');
Route::post('lastfm/session-key', 'LastfmController@setSessionKey');
Route::get('lastfm/callback', [
'as' => 'lastfm.callback',
'uses' => 'LastfmController@callback',
]);
Route::delete('lastfm/disconnect', 'LastfmController@disconnect');
// Download routes
Route::group(['prefix' => 'download', 'namespace' => 'Download'], function () {
Route::get('songs', 'SongController@download');
});
});
});

View file

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

113
app/Services/Download.php Normal file
View file

@ -0,0 +1,113 @@
<?php
namespace App\Services;
use App\Models\Song;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Playlist;
use Exception;
use Illuminate\Database\Eloquent\Collection;
use Log;
class Download
{
/**
* Generic method to generate a download archive from various source types.
*
* @param Song|Collection<Song>|Album|Artist|Playlist $mixed
*
* @return string Full path to the generated archive
*/
public function from($mixed)
{
if (is_a($mixed, Song::class)) {
return $this->fromSong($mixed);
} elseif (is_a($mixed, Collection::class)) {
return $this->fromMultipleSongs($mixed);
} elseif (is_a($mixed, Album::class)) {
return $this->fromAlbum($mixed);
} elseif (is_a($mixed, Artist::class)) {
return $this->fromArtist($mixed);
} elseif (is_a($mixed, Playlist::class)) {
return $this->fromPlaylist($mixed);
} else {
throw new Exception('Unsupport download type.');
}
}
protected function fromSong(Song $song)
{
// Maybe more interesting things can be added in the future.
// For now, we simply return the song's path.
return $song->path;
}
protected function fromMultipleSongs(Collection $songs)
{
if ($songs->count() === 1) {
return $this->fromSong($songs->first());
}
if (!class_exists('\ZipArchive')) {
throw new Exception('Downloading multiple files requires ZipArchive module.');
}
// Start gathering the songs into a zip file.
$zip = new \ZipArchive();
// We use system's temp dir instead storage_path() here, so that the generated files
// can be cleaned up automatically after server reboot.
$filename = rtrim(sys_get_temp_dir(), '/').'/koel-download-'.uniqid().'.zip';
if ($zip->open($filename, \ZipArchive::CREATE) !== true) {
throw new Exception('Cannot create zip file.');
}
$localNames = [
// 'duplicated-name.mp3' => currentFileIndex
];
$songs->each(function ($s) use ($zip, &$localNames) {
try {
// We add all files into the zip archive as a flat structure.
// As a result, there can be duplicate file names.
// The following several lines are to make sure each file name is unique.
$name = basename($s->path);
if (array_key_exists($name, $localNames)) {
$localNames[$name]++;
$parts = explode('.', $name);
$ext = $parts[count($parts) - 1];
$parts[count($parts) - 1] = $localNames[$name].".$ext";
$name = implode('.', $parts);
} else {
$localNames[$name] = 1;
}
$zip->addFile($s->path, $name);
} catch (Exception $e) {
Log::error($e);
}
});
$zip->close();
return $filename;
}
protected function fromPlaylist(Playlist $playlist)
{
return $this->fromMultipleSongs($playlist->songs);
}
protected function fromAlbum(Album $album)
{
return $this->fromMultipleSongs($abum->songs);
}
protected function fromArtist(Artist $artist)
{
$songs = $artist->with('albums', 'albums.songs')->get();
return $this->fromMultipleSongs($songs);
}
}

View file

@ -152,6 +152,7 @@ return [
App\Providers\MediaServiceProvider::class,
App\Providers\UtilServiceProvider::class,
App\Providers\LastfmServiceProvider::class,
App\Providers\DownloadServiceProvider::class,
],
@ -168,40 +169,41 @@ return [
'aliases' => [
'App' => Illuminate\Support\Facades\App::class,
'Artisan' => Illuminate\Support\Facades\Artisan::class,
'Auth' => Illuminate\Support\Facades\Auth::class,
'Blade' => Illuminate\Support\Facades\Blade::class,
'Cache' => Illuminate\Support\Facades\Cache::class,
'Config' => Illuminate\Support\Facades\Config::class,
'Cookie' => Illuminate\Support\Facades\Cookie::class,
'Crypt' => Illuminate\Support\Facades\Crypt::class,
'DB' => Illuminate\Support\Facades\DB::class,
'Eloquent' => Illuminate\Database\Eloquent\Model::class,
'Event' => Illuminate\Support\Facades\Event::class,
'File' => Illuminate\Support\Facades\File::class,
'Gate' => Illuminate\Support\Facades\Gate::class,
'Hash' => Illuminate\Support\Facades\Hash::class,
'Lang' => Illuminate\Support\Facades\Lang::class,
'Log' => Illuminate\Support\Facades\Log::class,
'Mail' => Illuminate\Support\Facades\Mail::class,
'Password' => Illuminate\Support\Facades\Password::class,
'Queue' => Illuminate\Support\Facades\Queue::class,
'Redirect' => Illuminate\Support\Facades\Redirect::class,
'Redis' => Illuminate\Support\Facades\Redis::class,
'Request' => Illuminate\Support\Facades\Request::class,
'Response' => Illuminate\Support\Facades\Response::class,
'Route' => Illuminate\Support\Facades\Route::class,
'Schema' => Illuminate\Support\Facades\Schema::class,
'Session' => Illuminate\Support\Facades\Session::class,
'Storage' => Illuminate\Support\Facades\Storage::class,
'URL' => Illuminate\Support\Facades\URL::class,
'App' => Illuminate\Support\Facades\App::class,
'Artisan' => Illuminate\Support\Facades\Artisan::class,
'Auth' => Illuminate\Support\Facades\Auth::class,
'Blade' => Illuminate\Support\Facades\Blade::class,
'Cache' => Illuminate\Support\Facades\Cache::class,
'Config' => Illuminate\Support\Facades\Config::class,
'Cookie' => Illuminate\Support\Facades\Cookie::class,
'Crypt' => Illuminate\Support\Facades\Crypt::class,
'DB' => Illuminate\Support\Facades\DB::class,
'Eloquent' => Illuminate\Database\Eloquent\Model::class,
'Event' => Illuminate\Support\Facades\Event::class,
'File' => Illuminate\Support\Facades\File::class,
'Gate' => Illuminate\Support\Facades\Gate::class,
'Hash' => Illuminate\Support\Facades\Hash::class,
'Lang' => Illuminate\Support\Facades\Lang::class,
'Log' => Illuminate\Support\Facades\Log::class,
'Mail' => Illuminate\Support\Facades\Mail::class,
'Password' => Illuminate\Support\Facades\Password::class,
'Queue' => Illuminate\Support\Facades\Queue::class,
'Redirect' => Illuminate\Support\Facades\Redirect::class,
'Redis' => Illuminate\Support\Facades\Redis::class,
'Request' => Illuminate\Support\Facades\Request::class,
'Response' => Illuminate\Support\Facades\Response::class,
'Route' => Illuminate\Support\Facades\Route::class,
'Schema' => Illuminate\Support\Facades\Schema::class,
'Session' => Illuminate\Support\Facades\Session::class,
'Storage' => Illuminate\Support\Facades\Storage::class,
'URL' => Illuminate\Support\Facades\URL::class,
'Validator' => Illuminate\Support\Facades\Validator::class,
'View' => Illuminate\Support\Facades\View::class,
'View' => Illuminate\Support\Facades\View::class,
'Media' => App\Facades\Media::class,
'Util' => App\Facades\Util::class,
'Lastfm' => App\Facades\Lastfm::class,
'Download' => App\Facades\Download::class,
'JWTAuth' => Tymon\JWTAuth\Facades\JWTAuth::class,
'JWTFactory' => Tymon\JWTAuth\Facades\JWTFactory::class,

View file

@ -27,6 +27,7 @@
</ul>
</li>
<li v-if="isAdmin" @click="openEditForm">Edit</li>
<li @click="download">Download</li>
</ul>
</template>
@ -40,6 +41,7 @@
import userStore from '../../stores/user';
import playlistStore from '../../stores/playlist';
import playback from '../../services/playback';
import download from '../../services/download';
export default {
props: ['songs'],
@ -122,6 +124,11 @@
this.close();
},
download() {
download.fromSongs(this.songs);
this.close();
},
},
/**

View file

@ -0,0 +1,44 @@
import $ from 'jquery';
import { map } from 'lodash';
import playlistStore from '../stores/playlist';
import ls from './ls';
export default {
fromSongs(songs) {
const ids = map(songs, 'id');
const params = $.param({ songs: ids });
return this.trigger(`songs?${params}`);
},
fromAlbum(album) {
},
fromArtist(artist) {
},
fromPlaylist(playlist) {
if (!playlistStore.getSongs(playlist).length) {
console.warn('Empty playlist.');
return;
}
return this.trigger(`playlist/${playlist.id}`);
},
/**
* Build a download link using a segment and trigger it.
*
* @param {string} uri The uri segment, corresponding to the song(s),
* artist, playlist, or album.
*/
trigger(uri) {
const sep = uri.indexOf('?') === -1 ? '?' : '&';
const frameId = `downloader${Date.now()}`;
$(`<iframe id="${frameId}" style="display:none"></iframe`).appendTo('body');
document.getElementById(frameId).src = `/api/download/${uri}${sep}jwt-token=${ls.get('jwt-token')}`;
},
}