diff --git a/.env.example b/.env.example index b0c5e1d7..5d37d9a5 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/app/Facades/Download.php b/app/Facades/Download.php new file mode 100644 index 00000000..3362feae --- /dev/null +++ b/app/Facades/Download.php @@ -0,0 +1,13 @@ +songs)->get(); + + return response()->download(Download::from($songs)); + } +} diff --git a/app/Http/Requests/API/Download/Request.php b/app/Http/Requests/API/Download/Request.php new file mode 100644 index 00000000..3014f3a6 --- /dev/null +++ b/app/Http/Requests/API/Download/Request.php @@ -0,0 +1,18 @@ + 'required|array', + ]; + } +} diff --git a/app/Http/routes.php b/app/Http/routes.php index dcab65ce..fc13b703 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -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'); + }); }); }); diff --git a/app/Providers/DownloadServiceProvider.php b/app/Providers/DownloadServiceProvider.php new file mode 100644 index 00000000..36dbafde --- /dev/null +++ b/app/Providers/DownloadServiceProvider.php @@ -0,0 +1,31 @@ +singleton('Download', function () { + return new Download(); + }); + } +} diff --git a/app/Services/Download.php b/app/Services/Download.php new file mode 100644 index 00000000..0aad4c88 --- /dev/null +++ b/app/Services/Download.php @@ -0,0 +1,113 @@ +|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); + } +} diff --git a/config/app.php b/config/app.php index eaf4b5c5..828fac2d 100644 --- a/config/app.php +++ b/config/app.php @@ -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, diff --git a/resources/assets/js/components/shared/song-menu.vue b/resources/assets/js/components/shared/song-menu.vue index 144eefbb..fe183fc0 100644 --- a/resources/assets/js/components/shared/song-menu.vue +++ b/resources/assets/js/components/shared/song-menu.vue @@ -27,6 +27,7 @@