feat(plus): support Dropbox

This commit is contained in:
Phan An 2024-02-05 12:50:06 +01:00
parent b723f3d7c9
commit 1ac0cbdc67
16 changed files with 391 additions and 58 deletions

View file

@ -3,11 +3,13 @@
namespace App\Factories; namespace App\Factories;
use App\Models\Song; use App\Models\Song;
use App\Services\Streamers\DropboxStreamer;
use App\Services\Streamers\LocalStreamerInterface; use App\Services\Streamers\LocalStreamerInterface;
use App\Services\Streamers\S3CompatibleStreamer; use App\Services\Streamers\S3CompatibleStreamer;
use App\Services\Streamers\StreamerInterface; use App\Services\Streamers\StreamerInterface;
use App\Services\Streamers\TranscodingStreamer; use App\Services\Streamers\TranscodingStreamer;
use App\Services\TranscodingService; use App\Services\TranscodingService;
use App\Values\SongStorageMetadata\DropboxMetadata;
use App\Values\SongStorageMetadata\S3CompatibleMetadata; use App\Values\SongStorageMetadata\S3CompatibleMetadata;
class StreamerFactory class StreamerFactory
@ -23,27 +25,29 @@ class StreamerFactory
float $startTime = 0.0 float $startTime = 0.0
): StreamerInterface { ): StreamerInterface {
if ($song->storage_metadata instanceof S3CompatibleMetadata) { if ($song->storage_metadata instanceof S3CompatibleMetadata) {
return tap( return self::makeStreamerFromClass(S3CompatibleStreamer::class, $song);
app(S3CompatibleStreamer::class), }
static fn (S3CompatibleStreamer $streamer) => $streamer->setSong($song)
); if ($song->storage_metadata instanceof DropboxMetadata) {
return self::makeStreamerFromClass(DropboxStreamer::class, $song);
} }
$transcode ??= $this->transcodingService->songShouldBeTranscoded($song); $transcode ??= $this->transcodingService->songShouldBeTranscoded($song);
if ($transcode) { if ($transcode) {
/** @var TranscodingStreamer $streamer */ /** @var TranscodingStreamer $streamer */
$streamer = app(TranscodingStreamer::class); $streamer = self::makeStreamerFromClass(TranscodingStreamer::class, $song);
$streamer->setSong($song);
$streamer->setBitRate($bitRate ?: config('koel.streaming.bitrate')); $streamer->setBitRate($bitRate ?: config('koel.streaming.bitrate'));
$streamer->setStartTime($startTime); $streamer->setStartTime($startTime);
return $streamer; return $streamer;
} }
return tap( return self::makeStreamerFromClass(LocalStreamerInterface::class, $song);
app(LocalStreamerInterface::class), }
static fn (LocalStreamerInterface $streamer) => $streamer->setSong($song)
); private static function makeStreamerFromClass(string $class, Song $song): StreamerInterface
{
return tap(app($class), static fn (StreamerInterface $streamer) => $streamer->setSong($song));
} }
} }

View file

@ -3,9 +3,10 @@
namespace App\Models; namespace App\Models;
use App\Builders\SongBuilder; use App\Builders\SongBuilder;
use App\Values\SongStorageMetadata\DropboxMetadata;
use App\Values\SongStorageMetadata\LocalMetadata; use App\Values\SongStorageMetadata\LocalMetadata;
use App\Values\SongStorageMetadata\S3CompatibleMetadata; use App\Values\SongStorageMetadata\S3CompatibleMetadata;
use App\Values\SongStorageMetadata\StorageMetadata; use App\Values\SongStorageMetadata\SongStorageMetadata;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -40,7 +41,7 @@ use Laravel\Scout\Searchable;
* @property int $owner_id * @property int $owner_id
* @property bool $is_public * @property bool $is_public
* @property User $owner * @property User $owner
* @property-read StorageMetadata $storage_metadata * @property-read SongStorageMetadata $storage_metadata
* *
* // The following are only available for collaborative playlists * // 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 * @property-read ?string $collaborator_email The email of the user who added the song to the playlist
@ -150,11 +151,15 @@ class Song extends Model
protected function storageMetadata(): Attribute protected function storageMetadata(): Attribute
{ {
return new Attribute( return new Attribute(
get: function (): StorageMetadata { get: function (): SongStorageMetadata {
if (preg_match('/^s3:\\/\\/(.*)\\/(.*)/', $this->path, $matches)) { if (preg_match('/^s3:\\/\\/(.*)\\/(.*)/', $this->path, $matches)) {
return S3CompatibleMetadata::make($matches[1], $matches[2]); return S3CompatibleMetadata::make($matches[1], $matches[2]);
} }
if (preg_match('/^dropbox:\\/\\/(.*)\\/(.*)/', $this->path, $matches)) {
return DropboxMetadata::make($matches[1], $matches[2]);
}
return LocalMetadata::make($this->path); return LocalMetadata::make($this->path);
} }
); );

View file

@ -2,6 +2,7 @@
namespace App\Providers; namespace App\Providers;
use App\Services\SongStorage\DropboxStorage;
use App\Services\SongStorage\LocalStorage; use App\Services\SongStorage\LocalStorage;
use App\Services\SongStorage\S3CompatibleStorage; use App\Services\SongStorage\S3CompatibleStorage;
use App\Services\SongStorage\SongStorage; use App\Services\SongStorage\SongStorage;
@ -14,6 +15,7 @@ class SongStorageServiceProvider extends ServiceProvider
$this->app->bind(SongStorage::class, function () { $this->app->bind(SongStorage::class, function () {
$concrete = match (config('koel.storage_driver')) { $concrete = match (config('koel.storage_driver')) {
's3' => S3CompatibleStorage::class, 's3' => S3CompatibleStorage::class,
'dropbox' => DropboxStorage::class,
default => LocalStorage::class, default => LocalStorage::class,
}; };
@ -23,5 +25,13 @@ class SongStorageServiceProvider extends ServiceProvider
$this->app->when(S3CompatibleStorage::class) $this->app->when(S3CompatibleStorage::class)
->needs('$bucket') ->needs('$bucket')
->giveConfig('filesystems.disks.s3.bucket'); ->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');
} }
} }

View file

@ -0,0 +1,44 @@
<?php
namespace App\Services\SongStorage;
use App\Exceptions\SongUploadFailedException;
use App\Models\User;
use App\Services\FileScanner;
use App\Values\ScanConfiguration;
use App\Values\ScanResult;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Symfony\Component\Uid\Ulid;
abstract class CloudStorage implements SongStorage
{
public function __construct(protected FileScanner $scanner)
{
}
protected function scanUploadedFile(UploadedFile $file, User $uploader): ScanResult
{
// Can't scan the uploaded file directly, as it apparently causes some misbehavior during idv3 tag reading.
// Instead, we copy the file to the tmp directory and scan it from there.
$tmpDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . Str::uuid();
File::makeDirectory($tmpDir);
$tmpFile = $file->move($tmpDir, $file->getClientOriginalName());
$result = $this->scanner->setFile($tmpFile)
->scan(ScanConfiguration::make(
owner: $uploader,
makePublic: $uploader->preferences->makeUploadsPublic
));
throw_if($result->isError(), new SongUploadFailedException($result->error));
return $result;
}
protected function generateStorageKey(string $filename, User $uploader): string
{
return sprintf('%s__%s__%s', $uploader->id, Str::lower(Ulid::generate()), $filename);
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Services\SongStorage;
use App\Models\Song;
use App\Models\User;
use App\Services\FileScanner;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use League\Flysystem\Filesystem;
use Spatie\Dropbox\Client;
use Spatie\FlysystemDropbox\DropboxAdapter;
class DropboxStorage extends CloudStorage
{
private Filesystem $filesystem;
private DropboxAdapter $adapter;
public function __construct(protected FileScanner $scanner, private string $token, private string $folder)
{
parent::__construct($scanner);
$client = new Client($this->token);
$this->adapter = new DropboxAdapter($client);
$this->filesystem = new Filesystem($this->adapter, ['case_sensitive' => false]);
}
public function storeUploadedFile(UploadedFile $file, User $uploader): Song
{
return DB::transaction(function () use ($file, $uploader): Song {
$result = $this->scanUploadedFile($file, $uploader);
$song = $this->scanner->getSong();
$key = $this->generateStorageKey($file->getClientOriginalName(), $uploader);
$this->filesystem->write($key, File::get($result->path));
$song->update(['path' => "dropbox://$this->folder/$key"]);
File::delete($result->path);
return $song;
});
}
public function getSongPresignedUrl(Song $song): string
{
return $this->adapter->getUrl($song->storage_metadata->getPath());
}
}

View file

@ -5,42 +5,29 @@ namespace App\Services\SongStorage;
use App\Models\Song; use App\Models\Song;
use App\Models\User; use App\Models\User;
use App\Services\FileScanner; use App\Services\FileScanner;
use App\Values\ScanConfiguration;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\Uid\Ulid;
class S3CompatibleStorage implements SongStorage class S3CompatibleStorage extends CloudStorage
{ {
public function __construct(private FileScanner $scanner, private string $bucket) public function __construct(protected FileScanner $scanner, private string $bucket)
{ {
parent::__construct($scanner);
} }
public function storeUploadedFile(UploadedFile $file, User $uploader): Song public function storeUploadedFile(UploadedFile $file, User $uploader): Song
{ {
// can't scan the uploaded file directly, as it may cause some misbehavior return DB::transaction(function () use ($file, $uploader): Song {
// instead, we copy the file to the tmp directory and scan it from there $result = $this->scanUploadedFile($file, $uploader);
$tmpDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . Str::uuid();
File::makeDirectory($tmpDir);
$tmpFile = $file->move($tmpDir, $file->getClientOriginalName());
return DB::transaction(function () use ($tmpFile, $uploader) {
$this->scanner->setFile($tmpFile)
->scan(ScanConfiguration::make(
owner: $uploader,
makePublic: $uploader->preferences->makeUploadsPublic
));
$song = $this->scanner->getSong(); $song = $this->scanner->getSong();
$key = $this->generateStorageKey($tmpFile->getFilename(), $uploader); $key = $this->generateStorageKey($file->getClientOriginalName(), $uploader);
Storage::disk('s3')->put($key, $tmpFile->getContent()); Storage::disk('s3')->put($key, File::get($result->path));
$song->update(['path' => "s3://$this->bucket/$key"]); $song->update(['path' => "s3://$this->bucket/$key"]);
File::delete($tmpFile->getRealPath()); File::delete($result->path);
return $song; return $song;
}); });
@ -50,9 +37,4 @@ class S3CompatibleStorage implements SongStorage
{ {
return Storage::disk('s3')->temporaryUrl($song->storage_metadata->getPath(), now()->addHour()); return Storage::disk('s3')->temporaryUrl($song->storage_metadata->getPath(), now()->addHour());
} }
private function generateStorageKey(string $filename, User $uploader): string
{
return sprintf('%s__%s__%s', $uploader->id, Str::lower(Ulid::generate()), $filename);
}
} }

View file

@ -0,0 +1,20 @@
<?php
namespace App\Services\Streamers;
use App\Services\SongStorage\DropboxStorage;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Redirector;
class DropboxStreamer extends Streamer
{
public function __construct(private DropboxStorage $storage)
{
parent::__construct();
}
public function stream(): Redirector|RedirectResponse
{
return redirect($this->storage->getSongPresignedUrl($this->song));
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Values\SongStorageMetadata;
class DropboxMetadata implements SongStorageMetadata
{
private function __construct(public string $appFolder, public string $key)
{
}
public static function make(string $appFolder, string $key): self
{
return new static($appFolder, $key);
}
public function getPath(): string
{
return $this->key;
}
}

View file

@ -2,7 +2,7 @@
namespace App\Values\SongStorageMetadata; namespace App\Values\SongStorageMetadata;
final class LocalMetadata implements StorageMetadata final class LocalMetadata implements SongStorageMetadata
{ {
private function __construct(public string $path) private function __construct(public string $path)
{ {

View file

@ -1,7 +0,0 @@
<?php
namespace App\Values\SongStorageMetadata;
final class R2Metadata extends S3CompatibleMetadata
{
}

View file

@ -2,7 +2,7 @@
namespace App\Values\SongStorageMetadata; namespace App\Values\SongStorageMetadata;
class S3CompatibleMetadata implements StorageMetadata class S3CompatibleMetadata implements SongStorageMetadata
{ {
private function __construct(public string $bucket, public string $key) private function __construct(public string $bucket, public string $key)
{ {

View file

@ -1,7 +0,0 @@
<?php
namespace App\Values\SongStorageMetadata;
final class S3Metadata extends S3CompatibleMetadata
{
}

View file

@ -2,7 +2,7 @@
namespace App\Values\SongStorageMetadata; namespace App\Values\SongStorageMetadata;
interface StorageMetadata interface SongStorageMetadata
{ {
public function getPath(): string; public function getPath(): string;
} }

View file

@ -36,7 +36,8 @@
"jwilsson/spotify-web-api-php": "^5.2", "jwilsson/spotify-web-api-php": "^5.2",
"meilisearch/meilisearch-php": "^0.24.0", "meilisearch/meilisearch-php": "^0.24.0",
"http-interop/http-factory-guzzle": "^1.2", "http-interop/http-factory-guzzle": "^1.2",
"league/flysystem-aws-s3-v3": "^3.0" "league/flysystem-aws-s3-v3": "^3.0",
"spatie/flysystem-dropbox": "^3.0"
}, },
"require-dev": { "require-dev": {
"mockery/mockery": "~1.0", "mockery/mockery": "~1.0",

209
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "6a64a8f7d4da75827254d63d4197bd51", "content-hash": "7cf7d2bb1b51df5f49ca061698b6c4be",
"packages": [ "packages": [
{ {
"name": "algolia/algoliasearch-client-php", "name": "algolia/algoliasearch-client-php",
@ -1302,6 +1302,71 @@
], ],
"time": "2022-02-20T15:07:15+00:00" "time": "2022-02-20T15:07:15+00:00"
}, },
{
"name": "graham-campbell/guzzle-factory",
"version": "v7.0.1",
"source": {
"type": "git",
"url": "https://github.com/GrahamCampbell/Guzzle-Factory.git",
"reference": "134f6ca38ad0c948ed7c22552a286bb56b5a5b35"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/GrahamCampbell/Guzzle-Factory/zipball/134f6ca38ad0c948ed7c22552a286bb56b5a5b35",
"reference": "134f6ca38ad0c948ed7c22552a286bb56b5a5b35",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "^7.8.1",
"guzzlehttp/psr7": "^2.6.2",
"php": "^7.4.15 || ^8.0.2"
},
"require-dev": {
"graham-campbell/analyzer": "^4.1",
"phpunit/phpunit": "^9.6.14 || ^10.5.1"
},
"type": "library",
"autoload": {
"psr-4": {
"GrahamCampbell\\GuzzleFactory\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
}
],
"description": "Provides A Simple Guzzle Factory With Good Defaults",
"keywords": [
"Graham Campbell",
"GrahamCampbell",
"Guzzle",
"Guzzle Factory",
"Guzzle-Factory",
"http"
],
"support": {
"issues": "https://github.com/GrahamCampbell/Guzzle-Factory/issues",
"source": "https://github.com/GrahamCampbell/Guzzle-Factory/tree/v7.0.1"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/graham-campbell/guzzle-factory",
"type": "tidelift"
}
],
"time": "2023-12-03T20:50:24+00:00"
},
{ {
"name": "graham-campbell/result-type", "name": "graham-campbell/result-type",
"version": "v1.1.0", "version": "v1.1.0",
@ -5294,6 +5359,148 @@
], ],
"time": "2023-01-12T18:13:24+00:00" "time": "2023-01-12T18:13:24+00:00"
}, },
{
"name": "spatie/dropbox-api",
"version": "1.22.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/dropbox-api.git",
"reference": "5b012d68568a560d1cd8888c0be2c2805c4b7c65"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/dropbox-api/zipball/5b012d68568a560d1cd8888c0be2c2805c4b7c65",
"reference": "5b012d68568a560d1cd8888c0be2c2805c4b7c65",
"shasum": ""
},
"require": {
"ext-json": "*",
"graham-campbell/guzzle-factory": "^4.0.2|^5.0|^6.0|^7.0",
"guzzlehttp/guzzle": "^6.2|^7.0",
"php": "^8.1"
},
"require-dev": {
"laravel/pint": "^1.10.1",
"phpstan/phpstan": "^1.10.16",
"phpunit/phpunit": "^9.4"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\Dropbox\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alex Vanderbist",
"email": "alex.vanderbist@gmail.com",
"homepage": "https://spatie.be",
"role": "Developer"
},
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "A minimal implementation of Dropbox API v2",
"homepage": "https://github.com/spatie/dropbox-api",
"keywords": [
"Dropbox-API",
"api",
"dropbox",
"spatie",
"v2"
],
"support": {
"issues": "https://github.com/spatie/dropbox-api/issues",
"source": "https://github.com/spatie/dropbox-api/tree/1.22.0"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2023-06-08T07:13:00+00:00"
},
{
"name": "spatie/flysystem-dropbox",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/spatie/flysystem-dropbox.git",
"reference": "766879111204a6e49412b5ff5989a6654e1b8ae0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/flysystem-dropbox/zipball/766879111204a6e49412b5ff5989a6654e1b8ae0",
"reference": "766879111204a6e49412b5ff5989a6654e1b8ae0",
"shasum": ""
},
"require": {
"league/flysystem": "^3.7.0",
"php": "^8.0",
"spatie/dropbox-api": "^1.17.1"
},
"require-dev": {
"pestphp/pest": "^1.22",
"phpspec/prophecy-phpunit": "^2.0.1",
"phpunit/phpunit": "^9.5.4"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\FlysystemDropbox\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alex Vanderbist",
"email": "alex.vanderbist@gmail.com",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Flysystem Adapter for the Dropbox v2 API",
"homepage": "https://github.com/spatie/flysystem-dropbox",
"keywords": [
"Flysystem",
"api",
"dropbox",
"flysystem-dropbox",
"spatie",
"v2"
],
"support": {
"issues": "https://github.com/spatie/flysystem-dropbox/issues",
"source": "https://github.com/spatie/flysystem-dropbox/tree/3.0.1"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2023-07-17T07:48:52+00:00"
},
{ {
"name": "symfony/console", "name": "symfony/console",
"version": "v6.0.19", "version": "v6.0.19",

View file

@ -72,6 +72,11 @@ return [
'throw' => true, 'throw' => true,
], ],
'dropbox' => [
'token' => env('DROPBOX_ACCESS_TOKEN'),
'folder' => env('DROPBOX_APP_FOLDER'),
],
'rackspace' => [ 'rackspace' => [
'driver' => 'rackspace', 'driver' => 'rackspace',
'username' => 'your-username', 'username' => 'your-username',