feat(plus): create command to setup Dropbox storage

This commit is contained in:
Phan An 2024-02-05 22:17:41 +01:00
parent d313a72619
commit bfd00de9e2
29 changed files with 366 additions and 112 deletions

View file

@ -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=

View file

@ -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', '!=', '');
}
}

View file

@ -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'));

View 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;
}
}

View 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.');
}
}
}

View file

@ -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);
}
}

View file

@ -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)
{

View file

@ -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);
}
}
);
}

View file

@ -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,
],

View file

@ -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');
}
}

View file

@ -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);
}
}

View file

@ -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());

View file

@ -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;
}

View file

@ -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');
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -20,5 +20,5 @@ abstract class Streamer
$this->song = $song;
}
abstract public function stream(): mixed;
abstract public function stream(); // @phpcs:ignore
}

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -4,8 +4,4 @@ namespace App\Values\SongStorageMetadata;
final class LegacyS3Metadata extends S3CompatibleMetadata
{
public function supported(): bool
{
return true;
}
}

View file

@ -17,9 +17,4 @@ final class LocalMetadata implements SongStorageMetadata
{
return $this->path;
}
public function supported(): bool
{
return true;
}
}

View file

@ -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();
}
}

View file

@ -4,7 +4,5 @@ namespace App\Values\SongStorageMetadata;
interface SongStorageMetadata
{
public function supported(): bool;
public function getPath(): string;
}

View 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();
}
}

View file

@ -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' => [

View file

@ -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]);
}
};

View file

@ -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');

View file

@ -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