mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat(plus): create command to setup Dropbox storage
This commit is contained in:
parent
d313a72619
commit
bfd00de9e2
29 changed files with 366 additions and 112 deletions
35
.env.example
35
.env.example
|
@ -31,10 +31,37 @@ DATABASE_URL=
|
|||
MYSQL_ATTR_SSL_CA=
|
||||
|
||||
|
||||
# The storage driver. Valid values are:
|
||||
# local: Store files on the server's local filesystem.
|
||||
# s3: Store files on Amazon S3 or a S3-compatible service (e.g. Cloudflare R2 or DigitalOcean Spaces). Koel Plus only.
|
||||
# dropbox: Store files on Dropbox. Koel Plus only.
|
||||
STORAGE_DRIVER=local
|
||||
|
||||
|
||||
# The ABSOLUTE path to your media. This value can always be changed later via the web interface.
|
||||
# Required if you're using the local file system to store your media (STORAGE_DRIVER=local).
|
||||
MEDIA_PATH=
|
||||
|
||||
|
||||
# S3 or S3-compatible service settings. Required if you're using S3 to store your media (STORAGE_DRIVER=s3).
|
||||
# Remember to set CORS policy to allow access from your Koel's domain (or "*").
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
# For Cloudflare R2, set this to "auto". For S3 and other services, set this to the region of your bucket.
|
||||
AWS_REGION=
|
||||
AWS_ENDPOINT=
|
||||
AWS_BUCKET=
|
||||
|
||||
|
||||
# Dropbox settings. Required if you're using Dropbox to store your media (STORAGE_DRIVER=dropbox)
|
||||
# Follow these steps to have these values filled:
|
||||
# 1. Create a Dropbox app at https://www.dropbox.com/developers/apps
|
||||
# 2. Run `php artisan koel:setup-dropbox` from the CLI and follow the instructions.
|
||||
DROPBOX_APP_KEY=
|
||||
DROPBOX_APP_SECRET=
|
||||
DROPBOX_REFRESH_TOKEN=
|
||||
|
||||
|
||||
# By default, Koel ignores dot files and folders. This greatly improves performance if your media
|
||||
# root have folders like .git or .cache. If by any chance your media files are under a dot folder,
|
||||
# set the following setting to false.
|
||||
|
@ -91,14 +118,6 @@ SPOTIFY_CLIENT_ID=
|
|||
SPOTIFY_CLIENT_SECRET=
|
||||
|
||||
|
||||
# To use Amazon S3 with Koel, fill the info here and follow the
|
||||
# installation guide at https://docs.koel.dev/aws-s3.html
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_REGION=
|
||||
AWS_ENDPOINT=
|
||||
|
||||
|
||||
# To integrate Koel with YouTube, set the API key here.
|
||||
# See https://docs.koel.dev/3rd-party.html#youtube for more information.
|
||||
YOUTUBE_API_KEY=
|
||||
|
|
|
@ -106,8 +106,7 @@ class SongBuilder extends Builder
|
|||
|
||||
public function storedOnCloud(): static
|
||||
{
|
||||
return $this->where('path', 'LIKE', 's3://%')
|
||||
->orWhere('path', 'LIKE', 's3+://%')
|
||||
->orWhere('path', 'LIKE', 'dropbox://%');
|
||||
return $this->whereNotNull('storage')
|
||||
->where('storage', '!=', '');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -297,7 +297,8 @@ class InitCommand extends Command
|
|||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('The absolute path to your media directory. If this is skipped (left blank) now, you can set it later via the web interface.'); // @phpcs-ignore-line
|
||||
$this->info('The absolute path to your media directory. You can leave it blank and set it later via the web interface.'); // @phpcs-ignore-line
|
||||
$this->info('If you plan to use Koel with a cloud provider (S3 or Dropbox), you can also skip this.');
|
||||
|
||||
while (true) {
|
||||
$path = $this->ask('Media path', config('koel.media_path'));
|
||||
|
|
94
app/Console/Commands/SetupDropboxCommand.php
Normal file
94
app/Console/Commands/SetupDropboxCommand.php
Normal file
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Facades\License;
|
||||
use App\Services\SongStorage\DropboxStorage;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Console\Kernel as Artisan;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Jackiedo\DotenvEditor\DotenvEditor;
|
||||
use Throwable;
|
||||
|
||||
class SetupDropboxCommand extends Command
|
||||
{
|
||||
protected $signature = 'koel:setup-dropbox';
|
||||
protected $description = 'Set up Dropbox as the storage driver for Koel';
|
||||
|
||||
public function __construct(private Artisan $artisan, private DotenvEditor $dotenvEditor)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (!License::isPlus()) {
|
||||
$this->error('Dropbox as a storage driver is only available in Koel Plus.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Setting up Dropbox as the storage driver for Koel.');
|
||||
|
||||
$appKey = $this->ask('Enter your Dropbox app key');
|
||||
$appSecret = $this->ask('Enter your Dropbox app secret');
|
||||
|
||||
$cacheKey = Str::uuid()->toString();
|
||||
|
||||
Cache::put($cacheKey, ['app_key' => $appKey, 'app_secret' => $appSecret], now()->addMinutes(15));
|
||||
|
||||
$tmpUrl = route('dropbox.authorize', ['state' => $cacheKey]);
|
||||
|
||||
$this->comment('Please visit the following link to authorize Koel to access your Dropbox account:');
|
||||
$this->info($tmpUrl);
|
||||
$this->comment('The link will expire in 15 minutes.');
|
||||
$this->comment('After you have authorized Koel, please enter the access code below.');
|
||||
$accessCode = $this->ask('Enter the access code');
|
||||
|
||||
$response = Http::asForm()
|
||||
->withBasicAuth($appKey, $appSecret)
|
||||
->post('https://api.dropboxapi.com/oauth2/token', [
|
||||
'code' => $accessCode,
|
||||
'grant_type' => 'authorization_code',
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
$this->error(
|
||||
'Failed to authorize with Dropbox. The server said: ' . $response->json('error_description') . '.'
|
||||
);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$refreshToken = $response->json('refresh_token');
|
||||
|
||||
$this->dotenvEditor->setKey('STORAGE_DRIVER', 'dropbox');
|
||||
$this->dotenvEditor->setKey('DROPBOX_APP_KEY', $appKey);
|
||||
$this->dotenvEditor->setKey('DROPBOX_APP_SECRET', $appSecret);
|
||||
$this->dotenvEditor->setKey('DROPBOX_REFRESH_TOKEN', $refreshToken);
|
||||
$this->dotenvEditor->setKey('DROPBOX_APP_FOLDER', $appFolder ?: '/');
|
||||
$this->dotenvEditor->save();
|
||||
|
||||
$this->comment('Uploading a test file to Dropbox to ensure everything is working...');
|
||||
|
||||
try {
|
||||
/** @var DropboxStorage $storage */
|
||||
$storage = app(DropboxStorage::class);
|
||||
$storage->testSetup();
|
||||
} catch (Throwable $e) {
|
||||
$this->error('Failed to upload a test file: ' . $e->getMessage() . '.');
|
||||
$this->comment('Please make sure the app has the correct permissions and try again.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->output->success('All done!');
|
||||
|
||||
Cache::forget($cacheKey);
|
||||
$this->artisan->call('config:clear');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
29
app/Http/Controllers/AuthorizeDropboxController.php
Normal file
29
app/Http/Controllers/AuthorizeDropboxController.php
Normal file
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class AuthorizeDropboxController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
$appKey = Arr::get(Cache::get($request->get('state')), 'app_key');
|
||||
|
||||
abort_unless($appKey, Response::HTTP_NOT_FOUND);
|
||||
|
||||
try {
|
||||
return redirect()->away(
|
||||
"https://www.dropbox.com/oauth2/authorize?client_id=$appKey&response_type=code&token_access_type=offline" // @phpcs:ignore
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
Log::error($e);
|
||||
abort(Response::HTTP_INTERNAL_SERVER_ERROR, 'Failed to authorize with Dropbox. Please try again.');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ use App\Http\Requests\Download\DownloadSongsRequest;
|
|||
use App\Models\Song;
|
||||
use App\Repositories\SongRepository;
|
||||
use App\Services\DownloadService;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class DownloadSongsController extends Controller
|
||||
{
|
||||
|
@ -16,6 +17,10 @@ class DownloadSongsController extends Controller
|
|||
$songs = Song::query()->findMany($request->songs);
|
||||
$songs->each(fn ($song) => $this->authorize('download', $song));
|
||||
|
||||
return response()->download($service->getDownloadablePath($repository->getMany($request->songs)));
|
||||
$downloadablePath = $service->getDownloadablePath($repository->getMany($request->songs));
|
||||
|
||||
abort_unless((bool) $downloadablePath, Response::HTTP_BAD_REQUEST, 'Song cannot be downloaded.');
|
||||
|
||||
return response()->download($downloadablePath);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ use App\Models\Song;
|
|||
use App\Repositories\SongRepository;
|
||||
use App\Values\ScanResult;
|
||||
|
||||
class DeleteNonExistingRecordsPostSync
|
||||
class DeleteNonExistingRecordsPostScan
|
||||
{
|
||||
public function __construct(private SongRepository $songRepository)
|
||||
{
|
|
@ -8,6 +8,7 @@ use App\Values\SongStorageMetadata\LegacyS3Metadata;
|
|||
use App\Values\SongStorageMetadata\LocalMetadata;
|
||||
use App\Values\SongStorageMetadata\S3CompatibleMetadata;
|
||||
use App\Values\SongStorageMetadata\SongStorageMetadata;
|
||||
use App\Values\SongStorageTypes;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
@ -17,6 +18,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Scout\Searchable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @property string $path
|
||||
|
@ -43,6 +45,7 @@ use Laravel\Scout\Searchable;
|
|||
* @property bool $is_public
|
||||
* @property User $owner
|
||||
* @property-read SongStorageMetadata $storage_metadata
|
||||
* @property ?string $storage
|
||||
*
|
||||
* // The following are only available for collaborative playlists
|
||||
* @property-read ?string $collaborator_email The email of the user who added the song to the playlist
|
||||
|
@ -76,6 +79,14 @@ class Song extends Model
|
|||
protected static function booted(): void
|
||||
{
|
||||
static::creating(static fn (self $song) => $song->id = Str::uuid()->toString());
|
||||
|
||||
static::saving(static function (self $song): void {
|
||||
if ($song->storage === '') {
|
||||
$song->storage = SongStorageTypes::LOCAL;
|
||||
}
|
||||
|
||||
SongStorageTypes::assertValidType($song->storage);
|
||||
});
|
||||
}
|
||||
|
||||
public static function query(): SongBuilder
|
||||
|
@ -153,19 +164,26 @@ class Song extends Model
|
|||
{
|
||||
return new Attribute(
|
||||
get: function (): SongStorageMetadata {
|
||||
if (preg_match('/^s3\\+:\\/\\/(.*)\\/(.*)/', $this->path, $matches)) {
|
||||
return S3CompatibleMetadata::make($matches[1], $matches[2]);
|
||||
}
|
||||
try {
|
||||
switch ($this->storage) {
|
||||
case 's3':
|
||||
preg_match('/^s3\\+:\\/\\/(.*)\\/(.*)/', $this->path, $matches);
|
||||
return S3CompatibleMetadata::make($matches[1], $matches[2]);
|
||||
|
||||
if (preg_match('/^s3:\\/\\/(.*)\\/(.*)/', $this->path, $matches)) {
|
||||
return LegacyS3Metadata::make($matches[1], $matches[2]);
|
||||
}
|
||||
case 's3-legacy':
|
||||
preg_match('/^s3\\+:\\/\\/(.*)\\/(.*)/', $this->path, $matches);
|
||||
return LegacyS3Metadata::make($matches[1], $matches[2]);
|
||||
|
||||
if (preg_match('/^dropbox:\\/\\/(.*)\\/(.*)/', $this->path, $matches)) {
|
||||
return DropboxMetadata::make($matches[1], $matches[2]);
|
||||
}
|
||||
case 'dropbox':
|
||||
preg_match('/^dropbox:\\/\\/(.*)/', $this->path, $matches);
|
||||
return DropboxMetadata::make($matches[1]);
|
||||
|
||||
return LocalMetadata::make($this->path);
|
||||
default:
|
||||
return LocalMetadata::make($this->path);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return LocalMetadata::make($this->path);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ use App\Events\MultipleSongsUnliked;
|
|||
use App\Events\NewPlaylistCollaboratorJoined;
|
||||
use App\Events\PlaybackStarted;
|
||||
use App\Events\SongLikeToggled;
|
||||
use App\Listeners\DeleteNonExistingRecordsPostSync;
|
||||
use App\Listeners\DeleteNonExistingRecordsPostScan;
|
||||
use App\Listeners\LoveMultipleTracksOnLastfm;
|
||||
use App\Listeners\LoveTrackOnLastfm;
|
||||
use App\Listeners\MakePlaylistSongsPublic;
|
||||
|
@ -45,7 +45,7 @@ class EventServiceProvider extends BaseServiceProvider
|
|||
],
|
||||
|
||||
MediaScanCompleted::class => [
|
||||
DeleteNonExistingRecordsPostSync::class,
|
||||
DeleteNonExistingRecordsPostScan::class,
|
||||
WriteSyncLog::class,
|
||||
],
|
||||
|
||||
|
|
|
@ -27,11 +27,7 @@ class SongStorageServiceProvider extends ServiceProvider
|
|||
->giveConfig('filesystems.disks.s3.bucket');
|
||||
|
||||
$this->app->when(DropboxStorage::class)
|
||||
->needs('$token')
|
||||
->giveConfig('filesystems.disks.dropbox.token');
|
||||
|
||||
$this->app->when(DropboxStorage::class)
|
||||
->needs('$folder')
|
||||
->giveConfig('filesystems.disks.dropbox.folder');
|
||||
->needs('$config')
|
||||
->giveConfig('filesystems.disks.dropbox');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,17 +4,16 @@ namespace App\Services;
|
|||
|
||||
use App\Models\Song;
|
||||
use App\Models\SongZipArchive;
|
||||
use Illuminate\Http\Response;
|
||||
use App\Services\SongStorage\CloudStorage;
|
||||
use App\Services\SongStorage\DropboxStorage;
|
||||
use App\Services\SongStorage\S3CompatibleStorage;
|
||||
use App\Values\SongStorageTypes;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class DownloadService
|
||||
{
|
||||
public function __construct(private S3Service $s3Service)
|
||||
{
|
||||
}
|
||||
|
||||
public function getDownloadablePath(Collection $songs): string
|
||||
public function getDownloadablePath(Collection $songs): ?string
|
||||
{
|
||||
if ($songs->count() === 1) {
|
||||
return $this->getLocalPath($songs->first());
|
||||
|
@ -26,27 +25,31 @@ class DownloadService
|
|||
->getPath();
|
||||
}
|
||||
|
||||
public function getLocalPath(Song $song): string
|
||||
public function getLocalPath(Song $song): ?string
|
||||
{
|
||||
if ($song->s3_params) {
|
||||
// The song is hosted on Amazon S3.
|
||||
// We download it back to our local server first.
|
||||
$url = $this->s3Service->getSongPublicUrl($song);
|
||||
|
||||
// @todo decouple http from services
|
||||
abort_unless((bool) $url, Response::HTTP_NOT_FOUND);
|
||||
|
||||
$localPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . basename($song->s3_params['key']);
|
||||
|
||||
// The following function requires allow_url_fopen to be ON.
|
||||
// We're just assuming that to be the case here.
|
||||
File::copy($url, $localPath);
|
||||
} else {
|
||||
// The song is hosted locally. Make sure the file exists.
|
||||
$localPath = $song->path;
|
||||
abort_unless(File::exists($localPath), Response::HTTP_NOT_FOUND);
|
||||
if (!SongStorageTypes::supported($song->storage)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $localPath;
|
||||
if (!$song->storage || $song->storage === SongStorageTypes::LOCAL) {
|
||||
return File::exists($song->path) ? $song->path : null;
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case $song->storage === SongStorageTypes::DROPBOX:
|
||||
/** @var CloudStorage $cloudStorage */
|
||||
$cloudStorage = app(DropboxStorage::class);
|
||||
break;
|
||||
|
||||
case $song->storage === SongStorageTypes::S3 || $song->storage === SongStorageTypes::S3_LAMBDA:
|
||||
/** @var CloudStorage $cloudStorage */
|
||||
$cloudStorage = app(S3CompatibleStorage::class);
|
||||
break;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return $cloudStorage->copyToLocal($song);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ use App\Models\Artist;
|
|||
use App\Models\Song;
|
||||
use App\Repositories\SongRepository;
|
||||
use App\Repositories\UserRepository;
|
||||
use App\Values\SongStorageTypes;
|
||||
use Aws\S3\S3ClientInterface;
|
||||
use Illuminate\Cache\Repository as Cache;
|
||||
|
||||
|
@ -79,6 +80,7 @@ class S3Service implements ObjectStorageInterface
|
|||
'mtime' => time(),
|
||||
'owner_id' => $user->id,
|
||||
'is_public' => true,
|
||||
'storage' => SongStorageTypes::S3_LAMBDA,
|
||||
]);
|
||||
|
||||
event(new LibraryChanged());
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
namespace App\Services\SongStorage;
|
||||
|
||||
use App\Exceptions\SongUploadFailedException;
|
||||
use App\Facades\License;
|
||||
use App\Models\Song;
|
||||
use App\Models\User;
|
||||
use App\Services\FileScanner;
|
||||
use App\Values\ScanConfiguration;
|
||||
|
@ -40,13 +40,23 @@ abstract class CloudStorage extends SongStorage
|
|||
return $result;
|
||||
}
|
||||
|
||||
public function copyToLocal(Song $song): string
|
||||
{
|
||||
$tmpDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'koel_tmp';
|
||||
File::ensureDirectoryExists($tmpDir);
|
||||
|
||||
$publicUrl = $this->getSongPresignedUrl($song);
|
||||
$localPath = $tmpDir . DIRECTORY_SEPARATOR . basename($song->storage_metadata->getPath());
|
||||
|
||||
File::copy($publicUrl, $localPath);
|
||||
|
||||
return $localPath;
|
||||
}
|
||||
|
||||
protected function generateStorageKey(string $filename, User $uploader): string
|
||||
{
|
||||
return sprintf('%s__%s__%s', $uploader->id, Str::lower(Ulid::generate()), $filename);
|
||||
}
|
||||
|
||||
public function supported(): bool
|
||||
{
|
||||
return License::isPlus();
|
||||
}
|
||||
abstract public function getSongPresignedUrl(Song $song): string;
|
||||
}
|
||||
|
|
|
@ -5,23 +5,26 @@ namespace App\Services\SongStorage;
|
|||
use App\Models\Song;
|
||||
use App\Models\User;
|
||||
use App\Services\FileScanner;
|
||||
use App\Values\SongStorageTypes;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use League\Flysystem\Filesystem;
|
||||
use Spatie\Dropbox\Client;
|
||||
use Spatie\FlysystemDropbox\DropboxAdapter;
|
||||
|
||||
final class DropboxStorage extends CloudStorage
|
||||
{
|
||||
private Filesystem $filesystem;
|
||||
public Filesystem $filesystem;
|
||||
private DropboxAdapter $adapter;
|
||||
|
||||
public function __construct(protected FileScanner $scanner, private string $token, private string $folder)
|
||||
public function __construct(protected FileScanner $scanner, private array $config)
|
||||
{
|
||||
parent::__construct($scanner);
|
||||
|
||||
$client = new Client($this->token);
|
||||
$client = new Client($this->maybeRefreshAccessToken());
|
||||
$this->adapter = new DropboxAdapter($client);
|
||||
$this->filesystem = new Filesystem($this->adapter, ['case_sensitive' => false]);
|
||||
}
|
||||
|
@ -34,7 +37,10 @@ final class DropboxStorage extends CloudStorage
|
|||
$key = $this->generateStorageKey($file->getClientOriginalName(), $uploader);
|
||||
|
||||
$this->filesystem->write($key, File::get($result->path));
|
||||
$song->update(['path' => "dropbox://$this->folder/$key"]);
|
||||
$song->update([
|
||||
'path' => "dropbox://$key",
|
||||
'storage' => SongStorageTypes::DROPBOX,
|
||||
]);
|
||||
|
||||
File::delete($result->path);
|
||||
|
||||
|
@ -42,8 +48,43 @@ final class DropboxStorage extends CloudStorage
|
|||
});
|
||||
}
|
||||
|
||||
private function maybeRefreshAccessToken(): string
|
||||
{
|
||||
$accessToken = Cache::get('dropbox_access_token');
|
||||
|
||||
if ($accessToken) {
|
||||
return $accessToken;
|
||||
}
|
||||
|
||||
$response = Http::asForm()
|
||||
->withBasicAuth($this->config['app_key'], $this->config['app_secret'])
|
||||
->post('https://api.dropboxapi.com/oauth2/token', [
|
||||
'refresh_token' => $this->config['refresh_token'],
|
||||
'grant_type' => 'refresh_token',
|
||||
])->json();
|
||||
|
||||
Cache::put(
|
||||
'dropbox_access_token',
|
||||
$response['access_token'],
|
||||
now()->addSeconds($response['expires_in'] - 60) // 60 seconds buffer
|
||||
);
|
||||
|
||||
return $response['access_token'];
|
||||
}
|
||||
|
||||
public function getSongPresignedUrl(Song $song): string
|
||||
{
|
||||
return $this->adapter->getUrl($song->storage_metadata->getPath());
|
||||
}
|
||||
|
||||
public function supported(): bool
|
||||
{
|
||||
return SongStorageTypes::supported(SongStorageTypes::DROPBOX);
|
||||
}
|
||||
|
||||
public function testSetup(): void
|
||||
{
|
||||
$this->filesystem->write('test.txt', 'Koel test file.');
|
||||
$this->filesystem->delete('test.txt');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ use App\Models\Song;
|
|||
use App\Models\User;
|
||||
use App\Services\FileScanner;
|
||||
use App\Values\ScanConfiguration;
|
||||
use App\Values\SongStorageTypes;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Throwable;
|
||||
|
@ -89,6 +90,6 @@ final class LocalStorage extends SongStorage
|
|||
|
||||
public function supported(): bool
|
||||
{
|
||||
return true; // Local storage is always supported.
|
||||
return SongStorageTypes::supported(SongStorageTypes::LOCAL);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
namespace App\Services\SongStorage;
|
||||
|
||||
use App\Facades\License;
|
||||
use App\Models\Song;
|
||||
use App\Models\User;
|
||||
use App\Services\FileScanner;
|
||||
use App\Values\SongStorageTypes;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
@ -26,7 +26,10 @@ class S3CompatibleStorage extends CloudStorage
|
|||
$key = $this->generateStorageKey($file->getClientOriginalName(), $uploader);
|
||||
|
||||
Storage::disk('s3')->put($key, File::get($result->path));
|
||||
$song->update(['path' => "s3+://$this->bucket/$key"]);
|
||||
$song->update([
|
||||
'path' => "s3://$this->bucket/$key",
|
||||
'storage' => SongStorageTypes::S3,
|
||||
]);
|
||||
|
||||
File::delete($result->path);
|
||||
|
||||
|
@ -41,6 +44,6 @@ class S3CompatibleStorage extends CloudStorage
|
|||
|
||||
public function supported(): bool
|
||||
{
|
||||
return License::isPlus();
|
||||
return SongStorageTypes::supported(SongStorageTypes::S3);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,13 +5,14 @@ namespace App\Services\SongStorage;
|
|||
use App\Exceptions\MethodNotImplementedException;
|
||||
use App\Models\Song;
|
||||
use App\Models\User;
|
||||
use App\Values\SongStorageTypes;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
/**
|
||||
* The legacy storage implementation for Lambda and S3, to provide backward compatibility.
|
||||
* In this implementation, the songs are supposed to be uploaded to S3 directly.
|
||||
*/
|
||||
final class LegacyLambdaS3Storage extends S3CompatibleStorage
|
||||
final class S3LambdaStorage extends S3CompatibleStorage
|
||||
{
|
||||
public function storeUploadedFile(UploadedFile $file, User $uploader): Song
|
||||
{
|
||||
|
@ -20,6 +21,6 @@ final class LegacyLambdaS3Storage extends S3CompatibleStorage
|
|||
|
||||
public function supported(): bool
|
||||
{
|
||||
return true;
|
||||
return SongStorageTypes::supported(SongStorageTypes::S3_LAMBDA);
|
||||
}
|
||||
}
|
|
@ -20,5 +20,5 @@ abstract class Streamer
|
|||
$this->song = $song;
|
||||
}
|
||||
|
||||
abstract public function stream(): mixed;
|
||||
abstract public function stream(); // @phpcs:ignore
|
||||
}
|
||||
|
|
|
@ -5,8 +5,7 @@ namespace App\Services\Streamers;
|
|||
use App\Exceptions\KoelPlusRequiredException;
|
||||
use App\Models\Song;
|
||||
use App\Services\TranscodingService;
|
||||
use App\Values\SongStorageMetadata\DropboxMetadata;
|
||||
use App\Values\SongStorageMetadata\S3CompatibleMetadata;
|
||||
use App\Values\SongStorageTypes;
|
||||
|
||||
class StreamerFactory
|
||||
{
|
||||
|
@ -20,13 +19,13 @@ class StreamerFactory
|
|||
?int $bitRate = null,
|
||||
float $startTime = 0.0
|
||||
): Streamer {
|
||||
throw_unless($song->storage_metadata->supported(), KoelPlusRequiredException::class);
|
||||
throw_unless(SongStorageTypes::supported($song->storage), KoelPlusRequiredException::class);
|
||||
|
||||
if ($song->storage_metadata instanceof S3CompatibleMetadata) {
|
||||
if ($song->storage === SongStorageTypes::S3 || $song->storage === SongStorageTypes::S3_LAMBDA) {
|
||||
return self::makeStreamerFromClass(S3CompatibleStreamer::class, $song);
|
||||
}
|
||||
|
||||
if ($song->storage_metadata instanceof DropboxMetadata) {
|
||||
if ($song->storage === SongStorageTypes::DROPBOX) {
|
||||
return self::makeStreamerFromClass(DropboxStreamer::class, $song);
|
||||
}
|
||||
|
||||
|
|
|
@ -2,26 +2,19 @@
|
|||
|
||||
namespace App\Values\SongStorageMetadata;
|
||||
|
||||
use App\Facades\License;
|
||||
|
||||
class DropboxMetadata implements SongStorageMetadata
|
||||
{
|
||||
private function __construct(public string $appFolder, public string $key)
|
||||
private function __construct(public string $path)
|
||||
{
|
||||
}
|
||||
|
||||
public static function make(string $appFolder, string $key): self
|
||||
public static function make(string $key): self
|
||||
{
|
||||
return new static($appFolder, $key);
|
||||
return new static($key);
|
||||
}
|
||||
|
||||
public function getPath(): string
|
||||
{
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
public function supported(): bool
|
||||
{
|
||||
return License::isPlus();
|
||||
return $this->path;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,8 +4,4 @@ namespace App\Values\SongStorageMetadata;
|
|||
|
||||
final class LegacyS3Metadata extends S3CompatibleMetadata
|
||||
{
|
||||
public function supported(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,9 +17,4 @@ final class LocalMetadata implements SongStorageMetadata
|
|||
{
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
public function supported(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,8 +2,6 @@
|
|||
|
||||
namespace App\Values\SongStorageMetadata;
|
||||
|
||||
use App\Facades\License;
|
||||
|
||||
class S3CompatibleMetadata implements SongStorageMetadata
|
||||
{
|
||||
private function __construct(public string $bucket, public string $key)
|
||||
|
@ -19,9 +17,4 @@ class S3CompatibleMetadata implements SongStorageMetadata
|
|||
{
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
public function supported(): bool
|
||||
{
|
||||
return License::isPlus();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,5 @@ namespace App\Values\SongStorageMetadata;
|
|||
|
||||
interface SongStorageMetadata
|
||||
{
|
||||
public function supported(): bool;
|
||||
|
||||
public function getPath(): string;
|
||||
}
|
||||
|
|
35
app/Values/SongStorageTypes.php
Normal file
35
app/Values/SongStorageTypes.php
Normal file
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Values;
|
||||
|
||||
use App\Facades\License;
|
||||
use Webmozart\Assert\Assert;
|
||||
|
||||
final class SongStorageTypes
|
||||
{
|
||||
public const S3 = 's3';
|
||||
public const S3_LAMBDA = 's3-lambda';
|
||||
public const DROPBOX = 'dropbox';
|
||||
public const LOCAL = null;
|
||||
|
||||
public const ALL_TYPES = [
|
||||
self::S3,
|
||||
self::S3_LAMBDA,
|
||||
self::DROPBOX,
|
||||
self::LOCAL,
|
||||
];
|
||||
|
||||
public static function assertValidType(?string $type): void
|
||||
{
|
||||
Assert::oneOf($type, self::ALL_TYPES, "Invalid storage type: $type");
|
||||
}
|
||||
|
||||
public static function supported(?string $type): bool
|
||||
{
|
||||
if (!$type || $type === self::S3_LAMBDA) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return License::isPlus();
|
||||
}
|
||||
}
|
|
@ -73,8 +73,9 @@ return [
|
|||
],
|
||||
|
||||
'dropbox' => [
|
||||
'token' => env('DROPBOX_ACCESS_TOKEN'),
|
||||
'folder' => env('DROPBOX_APP_FOLDER'),
|
||||
'app_key' => env('DROPBOX_APP_KEY'),
|
||||
'app_secret' => env('DROPBOX_APP_SECRET'),
|
||||
'refresh_token' => env('DROPBOX_REFRESH_TOKEN'),
|
||||
],
|
||||
|
||||
'rackspace' => [
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
use App\Values\SongStorageTypes;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('songs', static function (Blueprint $table): void {
|
||||
$table->string('storage')->index();
|
||||
});
|
||||
|
||||
DB::table('songs')->where('path', 'like', 's3://%')->update(['storage' => SongStorageTypes::S3_LAMBDA]);
|
||||
}
|
||||
};
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Facades\ITunes;
|
||||
use App\Http\Controllers\AuthorizeDropboxController;
|
||||
use App\Http\Controllers\Download\DownloadAlbumController;
|
||||
use App\Http\Controllers\Download\DownloadArtistController;
|
||||
use App\Http\Controllers\Download\DownloadFavoritesController;
|
||||
|
@ -27,6 +28,8 @@ Route::middleware('web')->group(static function (): void {
|
|||
}
|
||||
});
|
||||
|
||||
Route::get('dropbox/authorize', AuthorizeDropboxController::class)->name('dropbox.authorize');
|
||||
|
||||
Route::middleware('audio.auth')->group(static function (): void {
|
||||
Route::get('play/{song}/{transcode?}/{bitrate?}', PlayController::class)->name('song.play');
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
namespace Tests\Integration\Listeners;
|
||||
|
||||
use App\Events\MediaScanCompleted;
|
||||
use App\Listeners\DeleteNonExistingRecordsPostSync;
|
||||
use App\Listeners\DeleteNonExistingRecordsPostScan;
|
||||
use App\Models\Song;
|
||||
use App\Values\ScanResult;
|
||||
use App\Values\ScanResultCollection;
|
||||
|
@ -12,13 +12,13 @@ use Tests\TestCase;
|
|||
|
||||
class DeleteNonExistingRecordsPostSyncTest extends TestCase
|
||||
{
|
||||
private DeleteNonExistingRecordsPostSync $listener;
|
||||
private DeleteNonExistingRecordsPostScan $listener;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->listener = app(DeleteNonExistingRecordsPostSync::class);
|
||||
$this->listener = app(DeleteNonExistingRecordsPostScan::class);
|
||||
}
|
||||
|
||||
public function testHandleDoesNotDeleteS3Entries(): void
|
||||
|
|
Loading…
Reference in a new issue