From 6de636f4755837ee01873ec2c11842e5cb52c317 Mon Sep 17 00:00:00 2001 From: "Buster \"Silver Eagle\" Neece" Date: Mon, 9 Nov 2020 21:06:48 -0600 Subject: [PATCH] Unified Filesystem Overhaul (#3341) This migration adds "Storage Locations", managed via a new System Administration panel, that can hold Station Media data, live broadcast recordings, and backups. These storage locations can be local (as they are by default) or remote via any S3-compatible service. --- CHANGELOG.md | 6 + config/assets.php | 6 + config/cli.php | 2 +- config/forms/backup.php | 22 +- config/forms/backup_run.php | 19 +- config/forms/station.php | 27 +- config/forms/station_clone.php | 17 +- config/menus/admin.php | 5 + config/menus/station.php | 2 +- config/routes/admin.php | 4 + config/routes/api.php | 6 + frontend/vue/AdminStorageLocations.vue | 147 +++++ .../StorageLocationEditModal.vue | 208 ++++++++ .../form/StorageLocationForm.vue | 146 +++++ frontend/webpack.config.js | 5 +- src/Acl.php | 2 + src/Console/Command/Backup/BackupCommand.php | 51 +- src/Console/Command/Backup/RestoreCommand.php | 3 +- .../Command/Internal/SftpAuthCommand.php | 5 +- .../Command/Internal/SftpUploadCommand.php | 35 +- .../AbstractLogViewerController.php | 18 +- src/Controller/Admin/BackupsController.php | 82 ++- .../Admin/StorageLocationsController.php | 15 + .../Api/AbstractApiCrudController.php | 3 + .../Api/Admin/StorageLocationsController.php | 170 ++++++ .../AbstractStationApiCrudController.php | 8 - .../Api/Stations/Art/GetArtAction.php | 14 +- .../Api/Stations/Art/PostArtAction.php | 4 +- .../Api/Stations/Files/BatchAction.php | 41 +- .../Api/Stations/Files/DownloadAction.php | 8 +- .../Api/Stations/Files/FlowUploadAction.php | 9 +- .../Api/Stations/Files/ListAction.php | 38 +- .../Stations/Files/ListDirectoriesAction.php | 12 +- .../Stations/Files/MakeDirectoryAction.php | 4 +- .../Api/Stations/Files/RenameAction.php | 6 +- .../Api/Stations/FilesController.php | 243 +++++---- .../Api/Stations/OnDemand/DownloadAction.php | 6 +- .../Api/Stations/OnDemand/ListAction.php | 2 +- .../Api/Stations/RequestsController.php | 6 +- .../Streamers/BroadcastsController.php | 22 +- .../Stations/Waveform/GetWaveformAction.php | 17 +- src/Controller/Stations/FilesController.php | 14 +- src/Controller/Stations/ProfileController.php | 2 +- .../Stations/Reports/DuplicatesController.php | 12 +- .../Stations/SftpUsersController.php | 8 +- src/Doctrine/Repository.php | 2 +- src/Entity/Api/Admin/StorageLocation.php | 33 ++ src/Entity/Api/StationQueueDetailed.php | 13 +- src/Entity/Api/Traits/HasLinks.php | 19 + src/Entity/ApiGenerator/SongApiGenerator.php | 6 +- .../ApiGenerator/SongHistoryApiGenerator.php | 3 +- .../ApiGenerator/StationQueueApiGenerator.php | 3 +- src/Entity/Fixture/Station.php | 28 +- src/Entity/Fixture/StationMedia.php | 25 +- .../Migration/Version20201027130404.php | 184 +++++++ .../Migration/Version20201027130504.php | 38 ++ .../Repository/StationMediaRepository.php | 207 +++---- src/Entity/Repository/StationRepository.php | 91 +++- .../Repository/StationRequestRepository.php | 21 +- .../StationStreamerBroadcastRepository.php | 24 + .../Repository/StationStreamerRepository.php | 47 +- .../Repository/StorageLocationRepository.php | 85 +++ src/Entity/Settings.php | 1 + src/Entity/Station.php | 366 +++++-------- src/Entity/StationMedia.php | 66 ++- src/Entity/StationPlaylist.php | 11 +- src/Entity/StorageLocation.php | 505 ++++++++++++++++++ src/Flysystem/Filesystem.php | 233 ++++++-- src/Flysystem/FilesystemGroup.php | 139 ----- src/Flysystem/FilesystemInterface.php | 40 ++ src/Flysystem/FilesystemManager.php | 103 ++++ src/Flysystem/StationFilesystem.php | 61 --- src/Flysystem/StationFilesystemGroup.php | 124 +++++ src/Form/BackupSettingsForm.php | 2 + src/Form/StationCloneForm.php | 83 +-- src/Form/StationForm.php | 82 ++- src/Http/Response.php | 57 -- src/Message/AddNewMediaMessage.php | 4 +- src/Message/BackupMessage.php | 5 +- src/Middleware/Module/StationFiles.php | 4 +- src/Radio/AutoDJ/Annotations.php | 10 +- src/Radio/Backend/Liquidsoap/ConfigWriter.php | 40 +- src/Radio/Configuration.php | 16 +- src/Service/SftpGo.php | 7 + src/Sync/Task/Backup.php | 24 +- src/Sync/Task/FolderPlaylists.php | 8 +- src/Sync/Task/Media.php | 132 ++--- src/Sync/Task/RadioAutomation.php | 4 +- src/Sync/Task/RotateLogs.php | 35 +- src/Sync/Task/StorageCleanupTask.php | 66 ++- src/Validator/Constraints/StorageLocation.php | 25 + .../Constraints/StorageLocationValidator.php | 38 ++ templates/admin/backups/index.phtml | 4 +- .../admin/storage_locations/index.js.phtml | 17 + templates/admin/storage_locations/index.phtml | 12 + templates/stations/files/index.phtml | 18 +- .../functional/C05_Station_AutomationCest.php | 13 +- tests/functional/CestAbstract.php | 17 + tests/functional/D02_Api_RequestsCest.php | 13 +- web/static/api/openapi.yml | 287 +++++++--- 100 files changed, 3598 insertions(+), 1385 deletions(-) create mode 100644 frontend/vue/AdminStorageLocations.vue create mode 100644 frontend/vue/admin_storage_locations/StorageLocationEditModal.vue create mode 100644 frontend/vue/admin_storage_locations/form/StorageLocationForm.vue create mode 100644 src/Controller/Admin/StorageLocationsController.php create mode 100644 src/Controller/Api/Admin/StorageLocationsController.php create mode 100644 src/Entity/Api/Admin/StorageLocation.php create mode 100644 src/Entity/Api/Traits/HasLinks.php create mode 100644 src/Entity/Migration/Version20201027130404.php create mode 100644 src/Entity/Migration/Version20201027130504.php create mode 100644 src/Entity/Repository/StorageLocationRepository.php create mode 100644 src/Entity/StorageLocation.php delete mode 100644 src/Flysystem/FilesystemGroup.php create mode 100644 src/Flysystem/FilesystemInterface.php create mode 100644 src/Flysystem/FilesystemManager.php delete mode 100644 src/Flysystem/StationFilesystem.php create mode 100644 src/Flysystem/StationFilesystemGroup.php create mode 100644 src/Validator/Constraints/StorageLocation.php create mode 100644 src/Validator/Constraints/StorageLocationValidator.php create mode 100644 templates/admin/storage_locations/index.js.phtml create mode 100644 templates/admin/storage_locations/index.phtml diff --git a/CHANGELOG.md b/CHANGELOG.md index a201cabb1..c5528dae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ This release includes many contributions from members of our community as part o ## New Features/Changes +- **Media storage overhaul**: The way media is stored and managed has been completely changed: + - Station media, live recordings, and backups have "Storage Locations" that you can manage via System Administration. + - Storage locations can either be local to the server or using a remote storage location that uses the Amazon S3 protocol (S3, DigitalOcean Spaces, Wasabi, etc) + - Existing stations have automatically been migrated to Storage Locations. + - If more than one station shares a storage location, media is only processed once for all of the stations, instead of being processed separately. + - Statistics now include the total _unique_ listeners for a given station in a given day. On the dashboard, you can switch from the average listener statistics to the unique listener totals from a new tab selector above the charts. - There is a new, much friendlier animation that displays when Docker installations of AzuraCast are waiting for their dependent services to be fully ready. This avoids showing the previous messages, which often looked like errors, even though they weren't. diff --git a/config/assets.php b/config/assets.php index 19a7989c3..56e0703e3 100644 --- a/config/assets.php +++ b/config/assets.php @@ -508,4 +508,10 @@ return [ 'require' => ['vue-component-common', 'bootstrap-vue', 'moment'], // Auto-managed by Assets ], + + 'AdminStorageLocations' => [ + 'order' => 10, + 'require' => ['vue-component-common', 'bootstrap-vue'], + // Auto-managed by Assets + ], ]; diff --git a/config/cli.php b/config/cli.php index 1e8749a37..26c51ce1e 100644 --- a/config/cli.php +++ b/config/cli.php @@ -149,7 +149,7 @@ return function (Application $console) { )->setDescription('Set the value of a setting in the AzuraCast settings database.'); $console->command( - 'azuracast:backup [path] [--exclude-media]', + 'azuracast:backup [path] [--storage-location-id=] [--exclude-media]', Command\Backup\BackupCommand::class )->setDescription(__('Back up the AzuraCast database and statistics (and optionally media).')); diff --git a/config/forms/backup.php b/config/forms/backup.php index a2068733b..3524e2144 100644 --- a/config/forms/backup.php +++ b/config/forms/backup.php @@ -1,4 +1,5 @@ __('No'), 'default' => false, 'form_group_class' => 'col-md-6', - ] + ], ], Entity\Settings::BACKUP_TIME => [ @@ -27,19 +28,19 @@ return [ 'label' => __('Scheduled Backup Time'), 'description' => __('The time (in UTC) to run the automated backup, if enabled.'), 'form_group_class' => 'col-md-6', - ] + ], ], Entity\Settings::BACKUP_EXCLUDE_MEDIA => [ 'toggle', [ 'label' => __('Exclude Media from Backups'), - 'description' => __('Excluding media from automated backups will save space, but you should make sure to back up your media elsewhere.'), + 'description' => __('Excluding media from automated backups will save space, but you should make sure to back up your media elsewhere. Note that only locally stored media will be backed up.'), 'selected_text' => __('Yes'), 'deselected_text' => __('No'), 'default' => false, 'form_group_class' => 'col-md-6', - ] + ], ], Entity\Settings::BACKUP_KEEP_COPIES => [ @@ -51,7 +52,16 @@ return [ 'max' => 365, 'default' => 0, 'form_group_class' => 'col-md-6', - ] + ], + ], + + Entity\Settings::BACKUP_STORAGE_LOCATION => [ + 'select', + [ + 'label' => __('Storage Location'), + 'choices' => $storageLocations, + 'form_group_class' => 'col-md-12', + ], ], ], @@ -65,7 +75,7 @@ return [ 'type' => 'submit', 'label' => __('Save Changes'), 'class' => 'btn btn-lg btn-primary', - ] + ], ], ], ], diff --git a/config/forms/backup_run.php b/config/forms/backup_run.php index 41df592b2..48d377407 100644 --- a/config/forms/backup_run.php +++ b/config/forms/backup_run.php @@ -1,26 +1,33 @@ [ + 'storage_location' => [ + 'select', + [ + 'label' => __('Storage Location'), + 'choices' => $storageLocations, + ], + ], + 'path' => [ 'text', [ 'label' => __('Backup Filename'), - 'description' => __('Optional absolute or relative path where the backup file should be located.'), - ] + 'description' => __('Path where the backup file should be located.'), + ], ], 'exclude_media' => [ 'toggle', [ 'label' => __('Exclude Media from Backup'), - 'description' => __('This will produce a significantly smaller backup, but you should make sure to back up your media elsewhere.'), + 'description' => __('This will produce a significantly smaller backup, but you should make sure to back up your media elsewhere. Note that only locally stored media will be backed up.'), 'selected_text' => __('Yes'), 'deselected_text' => __('No'), 'default' => false, - ] + ], ], 'submit' => [ @@ -29,7 +36,7 @@ return [ 'type' => 'submit', 'label' => __('Save Changes'), 'class' => 'btn btn-lg btn-primary', - ] + ], ], ], diff --git a/config/forms/station.php b/config/forms/station.php index 0f1761524..fd1834d95 100644 --- a/config/forms/station.php +++ b/config/forms/station.php @@ -571,15 +571,6 @@ return [ ], ], - 'storage_quota' => [ - 'text', - [ - 'label' => __('Storage Quota'), - 'description' => __('Set a maximum disk space that this station can use. Specify the size with unit, i.e. "8 GB". Units are measured in 1024 bytes. Leave blank to default to the available space on the disk.'), - 'form_group_class' => 'col-md-6 ', - ], - ], - 'radio_base_dir' => [ 'text', [ @@ -590,12 +581,22 @@ return [ ], ], - 'radio_media_dir' => [ - 'text', + 'media_storage_location_id' => [ + 'select', [ - 'label' => __('Custom Media Directory'), + 'label' => __('Media Storage Location'), + 'choices' => [], + 'label_class' => 'advanced', + 'form_group_class' => 'col-md-6', + ], + ], + + 'recordings_storage_location_id' => [ + 'select', + [ + 'label' => __('Live Recordings Storage Location'), + 'choices' => [], 'label_class' => 'advanced', - 'description' => __('The directory where media files are stored. Leave blank to use default directory.'), 'form_group_class' => 'col-md-6', ], ], diff --git a/config/forms/station_clone.php b/config/forms/station_clone.php index 413954f24..73399b2bb 100644 --- a/config/forms/station_clone.php +++ b/config/forms/station_clone.php @@ -14,7 +14,7 @@ return [ 'label' => __('New Station Name'), 'class' => 'half-width', 'required' => true, - ] + ], ], 'description' => [ @@ -22,7 +22,7 @@ return [ [ 'label' => __('New Station Description'), 'class' => 'full-width full-height', - ] + ], ], ], @@ -39,13 +39,12 @@ return [ 'label' => __('Copy Media?'), 'description' => __('Choose how media should be duplicated from the old station.'), 'choices' => [ - 'none' => __('Do not share or copy media between the stations'), + 'none' => __('Do not share media between the stations'), 'share' => __('Share the same folder on disk between the stations'), - 'copy' => __('Copy the existing station\'s media to the new station'), ], 'form_group_class' => 'col-sm-12', 'default' => 'none', - ] + ], ], 'clone_playlists' => [ @@ -58,7 +57,7 @@ return [ ], 'form_group_class' => 'col-sm-4', 'default' => 0, - ] + ], ], 'clone_streamers' => [ @@ -71,7 +70,7 @@ return [ ], 'default' => 0, 'form_group_class' => 'col-sm-4', - ] + ], ], 'clone_permissions' => [ @@ -85,7 +84,7 @@ return [ ], 'default' => 0, 'form_group_class' => 'col-sm-4', - ] + ], ], ], @@ -99,7 +98,7 @@ return [ 'type' => 'submit', 'label' => __('Create New Station'), 'class' => 'btn btn-lg btn-primary', - ] + ], ], ], ], diff --git a/config/menus/admin.php b/config/menus/admin.php index dd833c488..f921cce2e 100644 --- a/config/menus/admin.php +++ b/config/menus/admin.php @@ -33,6 +33,11 @@ return function (App\Event\BuildAdminMenu $e) { 'url' => $router->named('admin:logs:index'), 'permission' => Acl::GLOBAL_LOGS, ], + 'storage_locations' => [ + 'label' => __('Storage Locations'), + 'url' => $router->named('admin:storage_locations:index'), + 'permission' => Acl::GLOBAL_STORAGE_LOCATIONS, + ], 'backups' => [ 'label' => __('Backups'), 'url' => $router->named('admin:backups:index'), diff --git a/config/menus/station.php b/config/menus/station.php index 0867081c7..3fce69119 100644 --- a/config/menus/station.php +++ b/config/menus/station.php @@ -148,7 +148,7 @@ return function (App\Event\BuildStationMenu $e) { 'sftp_users' => [ 'label' => __('SFTP Users'), 'url' => $router->fromHere('stations:sftp_users:index'), - 'visible' => App\Service\SftpGo::isSupported(), + 'visible' => App\Service\SftpGo::isSupportedForStation($station), 'permission' => Acl::STATION_MEDIA, ], 'automation' => [ diff --git a/config/routes/admin.php b/config/routes/admin.php index e600b81d0..0285d92b1 100644 --- a/config/routes/admin.php +++ b/config/routes/admin.php @@ -165,6 +165,10 @@ return function (App $app) { })->add(new Middleware\Permissions(Acl::GLOBAL_STATIONS)); + $group->get('/storage_locations', Controller\Admin\StorageLocationsController::class) + ->setName('admin:storage_locations:index') + ->add(new Middleware\Permissions(Acl::GLOBAL_STORAGE_LOCATIONS)); + $group->group('/users', function (RouteCollectorProxy $group) { $group->get('', Controller\Admin\UsersController::class . ':indexAction') diff --git a/config/routes/api.php b/config/routes/api.php index 18cb94ee8..51f8a4083 100644 --- a/config/routes/api.php +++ b/config/routes/api.php @@ -100,6 +100,12 @@ return function (App $app) { ['role', 'roles', Controller\Api\Admin\RolesController::class, Acl::GLOBAL_ALL], ['station', 'stations', Controller\Api\Admin\StationsController::class, Acl::GLOBAL_STATIONS], ['user', 'users', Controller\Api\Admin\UsersController::class, Acl::GLOBAL_ALL], + [ + 'storage_location', + 'storage_locations', + Controller\Api\Admin\StorageLocationsController::class, + Acl::GLOBAL_STORAGE_LOCATIONS, + ], ]; foreach ($admin_api_endpoints as [$singular, $plural, $class, $permission]) { diff --git a/frontend/vue/AdminStorageLocations.vue b/frontend/vue/AdminStorageLocations.vue new file mode 100644 index 000000000..2ad3e03f1 --- /dev/null +++ b/frontend/vue/AdminStorageLocations.vue @@ -0,0 +1,147 @@ + + + diff --git a/frontend/vue/admin_storage_locations/StorageLocationEditModal.vue b/frontend/vue/admin_storage_locations/StorageLocationEditModal.vue new file mode 100644 index 000000000..d452a9d3e --- /dev/null +++ b/frontend/vue/admin_storage_locations/StorageLocationEditModal.vue @@ -0,0 +1,208 @@ + + + diff --git a/frontend/vue/admin_storage_locations/form/StorageLocationForm.vue b/frontend/vue/admin_storage_locations/form/StorageLocationForm.vue new file mode 100644 index 000000000..731c0b9ce --- /dev/null +++ b/frontend/vue/admin_storage_locations/form/StorageLocationForm.vue @@ -0,0 +1,146 @@ + + + diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 02248bb93..1d8a9b823 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -7,13 +7,14 @@ module.exports = { VueTranslations: './vue/VueTranslations.js', Webcaster: './vue/Webcaster.vue', RadioPlayer: './vue/RadioPlayer.vue', + PublicRadioPlayer: './vue/PublicRadioPlayer.vue', InlinePlayer: './vue/InlinePlayer.vue', + SongRequest: './vue/SongRequest.vue', + AdminStorageLocations: './vue/AdminStorageLocations.vue', StationMedia: './vue/StationMedia.vue', StationPlaylists: './vue/StationPlaylists.vue', StationStreamers: './vue/StationStreamers.vue', StationOnDemand: './vue/StationOnDemand.vue', - PublicRadioPlayer: './vue/PublicRadioPlayer.vue', - SongRequest: './vue/SongRequest.vue', StationProfile: './vue/StationProfile.vue' }, resolve: { diff --git a/src/Acl.php b/src/Acl.php index d3b650c00..e6e0b2235 100644 --- a/src/Acl.php +++ b/src/Acl.php @@ -19,6 +19,7 @@ class Acl public const GLOBAL_STATIONS = 'administer stations'; public const GLOBAL_CUSTOM_FIELDS = 'administer custom fields'; public const GLOBAL_BACKUPS = 'administer backups'; + public const GLOBAL_STORAGE_LOCATIONS = 'administer storage locations'; public const STATION_ALL = 'administer all'; public const STATION_VIEW = 'view station management'; @@ -89,6 +90,7 @@ class Acl self::GLOBAL_STATIONS => __('Administer Stations'), self::GLOBAL_CUSTOM_FIELDS => __('Administer Custom Fields'), self::GLOBAL_BACKUPS => __('Administer Backups'), + self::GLOBAL_STORAGE_LOCATIONS => __('Administer Storage Locations'), ], 'station' => [ self::STATION_ALL => __('All Permissions'), diff --git a/src/Console/Command/Backup/BackupCommand.php b/src/Console/Command/Backup/BackupCommand.php index 38f36523e..c8750a4a8 100644 --- a/src/Console/Command/Backup/BackupCommand.php +++ b/src/Console/Command/Backup/BackupCommand.php @@ -5,7 +5,6 @@ namespace App\Console\Command\Backup; use App\Console\Command\CommandAbstract; use App\Console\Command\Traits; use App\Entity; -use App\Sync\Task\Backup; use App\Utilities; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -19,16 +18,38 @@ class BackupCommand extends CommandAbstract public function __invoke( SymfonyStyle $io, EntityManagerInterface $em, + Entity\Repository\StorageLocationRepository $storageLocationRepo, ?string $path = '', - bool $excludeMedia = false + bool $excludeMedia = false, + ?int $storageLocationId = null ): int { $start_time = microtime(true); if (empty($path)) { $path = 'manual_backup_' . gmdate('Ymd_Hi') . '.zip'; } - if ('/' !== $path[0]) { - $path = Backup::BASE_DIR . '/' . $path; + + $file_ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + + if ('/' === $path[0]) { + $tmpPath = $path; + $storageLocation = null; + } else { + $tmpPath = tempnam(sys_get_temp_dir(), 'backup_') . '.' . $file_ext; + + if (null === $storageLocationId) { + $io->error('You must specify a storage location when providing a relative path.'); + return 1; + } + + $storageLocation = $storageLocationRepo->findByType( + Entity\StorageLocation::TYPE_BACKUP, + $storageLocationId + ); + if (!($storageLocation instanceof Entity\StorageLocation)) { + $io->error('Invalid storage location specified.'); + return 1; + } } $includeMedia = !$excludeMedia; @@ -82,14 +103,9 @@ class BackupCommand extends CommandAbstract foreach ($stations as $station) { /** @var Entity\Station $station */ - $media_dir = $station->getRadioMediaDir(); - if (!in_array($media_dir, $files_to_backup, true)) { - $files_to_backup[] = $media_dir; - } - - $art_dir = $station->getRadioAlbumArtDir(); - if (!in_array($art_dir, $files_to_backup, true)) { - $files_to_backup[] = $art_dir; + $mediaAdapter = $station->getMediaStorageLocation(); + if ($mediaAdapter->isLocal()) { + $files_to_backup[] = $mediaAdapter->getPath(); } } } @@ -105,15 +121,13 @@ class BackupCommand extends CommandAbstract return $val; }, $files_to_backup); - $file_ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); - switch ($file_ext) { case 'gz': case 'tgz': $this->passThruProcess($io, array_merge([ 'tar', 'zcvf', - $path, + $tmpPath, ], $files_to_backup), '/'); break; @@ -126,11 +140,16 @@ class BackupCommand extends CommandAbstract '-r', '-n', implode(':', $dont_compress), - $path, + $tmpPath, ], $files_to_backup), '/'); break; } + if (null !== $storageLocation) { + $fs = $storageLocation->getFilesystem(); + $fs->putFromLocal($tmpPath, $path); + } + $io->newLine(); // Cleanup diff --git a/src/Console/Command/Backup/RestoreCommand.php b/src/Console/Command/Backup/RestoreCommand.php index b77c9bb2c..a7be20020 100644 --- a/src/Console/Command/Backup/RestoreCommand.php +++ b/src/Console/Command/Backup/RestoreCommand.php @@ -4,7 +4,6 @@ namespace App\Console\Command\Backup; use App\Console\Command\CommandAbstract; use App\Console\Command\Traits; -use App\Sync\Task\Backup; use App\Utilities; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -28,7 +27,7 @@ class RestoreCommand extends CommandAbstract $io->writeln('Please wait while the backup is restored...'); if ('/' !== $path[0]) { - $path = Backup::BASE_DIR . '/' . $path; + $path = '/var/azuracast/backups/' . $path; } if (!file_exists($path)) { diff --git a/src/Console/Command/Internal/SftpAuthCommand.php b/src/Console/Command/Internal/SftpAuthCommand.php index ab2dd13b3..bff269a3e 100644 --- a/src/Console/Command/Internal/SftpAuthCommand.php +++ b/src/Console/Command/Internal/SftpAuthCommand.php @@ -27,8 +27,9 @@ class SftpAuthCommand extends CommandAbstract if ($sftpUser instanceof SftpUser && $sftpUser->authenticate($password, $pubKey)) { $station = $sftpUser->getStation(); + $storageLocation = $station->getMediaStorageLocation(); - $quotaRaw = $station->getStorageQuotaBytes(); + $quotaRaw = $storageLocation->getStorageQuotaBytes(); $quota = ($quotaRaw instanceof BigInteger) ? (string)$quotaRaw : 0; @@ -37,7 +38,7 @@ class SftpAuthCommand extends CommandAbstract 'status' => 1, 'username' => $sftpUser->getUsername(), 'expiration_date' => 0, - 'home_dir' => $station->getRadioMediaDir(), + 'home_dir' => $storageLocation->getPath(), 'uid' => 0, 'gid' => 0, 'quota_size' => $quota, diff --git a/src/Console/Command/Internal/SftpUploadCommand.php b/src/Console/Command/Internal/SftpUploadCommand.php index 69aa83cca..40bee2351 100644 --- a/src/Console/Command/Internal/SftpUploadCommand.php +++ b/src/Console/Command/Internal/SftpUploadCommand.php @@ -5,7 +5,7 @@ namespace App\Console\Command\Internal; use App\Console\Application; use App\Console\Command\CommandAbstract; use App\Entity; -use App\Flysystem\Filesystem; +use App\Flysystem\FilesystemManager; use App\Message; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; @@ -18,13 +18,13 @@ class SftpUploadCommand extends CommandAbstract protected LoggerInterface $logger; - protected Filesystem $filesystem; + protected FilesystemManager $filesystem; public function __construct( Application $application, MessageBus $messageBus, LoggerInterface $logger, - Filesystem $filesystem + FilesystemManager $filesystem ) { parent::__construct($application); @@ -63,23 +63,36 @@ class SftpUploadCommand extends CommandAbstract } $station = $sftpUser->getStation(); - return $this->handleNewUpload($station, $path); + $storageLocation = $station->getMediaStorageLocation(); + + if (!$storageLocation->isLocal()) { + $this->logger->error(sprintf('Storage location "%s" is not local.', (string)$storageLocation)); + return 1; + } + + $this->flushCache($storageLocation); + + return $this->handleNewUpload($storageLocation, $path); } - protected function handleNewUpload(Entity\Station $station, $path): int + protected function flushCache(Entity\StorageLocation $storageLocation): void { - $fs = $this->filesystem->getForStation($station); - $fs->flushAllCaches(); + $adapter = $storageLocation->getStorageAdapter(); + $fs = $this->filesystem->getFilesystemForAdapter($adapter); + $fs->clearCache(false); + } - $relativePath = str_replace($station->getRadioMediaDir() . '/', '', $path); + protected function handleNewUpload(Entity\StorageLocation $storageLocation, $path): int + { + $relativePath = str_replace($storageLocation->getPath() . '/', '', $path); - $this->logger->notice('Processing new SFTP upload for station.', [ - 'station' => $station->getName(), + $this->logger->notice('Processing new SFTP upload.', [ + 'storageLocation' => (string)$storageLocation, 'path' => $relativePath, ]); $message = new Message\AddNewMediaMessage(); - $message->station_id = $station->getId(); + $message->storage_location_id = $storageLocation->getId(); $message->path = $relativePath; $this->messageBus->dispatch($message); diff --git a/src/Controller/AbstractLogViewerController.php b/src/Controller/AbstractLogViewerController.php index 02f87f89f..1af68fb81 100644 --- a/src/Controller/AbstractLogViewerController.php +++ b/src/Controller/AbstractLogViewerController.php @@ -96,19 +96,19 @@ abstract class AbstractLogViewerController protected function getStationLogs(Entity\Station $station): array { $log_paths = []; - - $station_config_dir = $station->getRadioConfigDir(); + + $stationConfigDir = $station->getRadioConfigDir(); switch ($station->getBackendType()) { case Adapters::BACKEND_LIQUIDSOAP: $log_paths['liquidsoap_log'] = [ 'name' => __('Liquidsoap Log'), - 'path' => $station_config_dir . '/liquidsoap.log', + 'path' => $stationConfigDir . '/liquidsoap.log', 'tail' => true, ]; $log_paths['liquidsoap_liq'] = [ 'name' => __('Liquidsoap Configuration'), - 'path' => $station_config_dir . '/liquidsoap.liq', + 'path' => $stationConfigDir . '/liquidsoap.liq', 'tail' => false, ]; break; @@ -118,17 +118,17 @@ abstract class AbstractLogViewerController case Adapters::FRONTEND_ICECAST: $log_paths['icecast_access_log'] = [ 'name' => __('Icecast Access Log'), - 'path' => $station_config_dir . '/icecast_access.log', + 'path' => $stationConfigDir . '/icecast_access.log', 'tail' => true, ]; $log_paths['icecast_error_log'] = [ 'name' => __('Icecast Error Log'), - 'path' => $station_config_dir . '/icecast.log', + 'path' => $stationConfigDir . '/icecast.log', 'tail' => true, ]; $log_paths['icecast_xml'] = [ 'name' => __('Icecast Configuration'), - 'path' => $station_config_dir . '/icecast.xml', + 'path' => $stationConfigDir . '/icecast.xml', 'tail' => false, ]; break; @@ -136,12 +136,12 @@ abstract class AbstractLogViewerController case Adapters::FRONTEND_SHOUTCAST: $log_paths['shoutcast_log'] = [ 'name' => __('SHOUTcast Log'), - 'path' => $station_config_dir . '/shoutcast.log', + 'path' => $stationConfigDir . '/shoutcast.log', 'tail' => true, ]; $log_paths['shoutcast_conf'] = [ 'name' => __('SHOUTcast Configuration'), - 'path' => $station_config_dir . '/sc_serv.conf', + 'path' => $stationConfigDir . '/sc_serv.conf', 'tail' => false, ]; break; diff --git a/src/Controller/Admin/BackupsController.php b/src/Controller/Admin/BackupsController.php index 1012c9025..e41cf2edb 100644 --- a/src/Controller/Admin/BackupsController.php +++ b/src/Controller/Admin/BackupsController.php @@ -5,9 +5,11 @@ namespace App\Controller\Admin; use App\Config; use App\Controller\AbstractLogViewerController; use App\Entity\Repository\SettingsRepository; +use App\Entity\Repository\StorageLocationRepository; use App\Entity\Settings; +use App\Entity\StorageLocation; use App\Exception\NotFoundException; -use App\Flysystem\FilesystemGroup; +use App\Flysystem\Filesystem; use App\Form\BackupSettingsForm; use App\Form\Form; use App\Http\Response; @@ -15,8 +17,6 @@ use App\Http\ServerRequest; use App\Message\BackupMessage; use App\Session\Flash; use App\Sync\Task\Backup; -use League\Flysystem\Adapter\Local; -use League\Flysystem\Filesystem; use Psr\Http\Message\ResponseInterface; use Symfony\Component\Messenger\MessageBus; @@ -24,30 +24,42 @@ class BackupsController extends AbstractLogViewerController { protected SettingsRepository $settingsRepo; + protected StorageLocationRepository $storageLocationRepo; + protected Backup $backupTask; protected MessageBus $messageBus; - protected Filesystem $backupFs; - protected string $csrfNamespace = 'admin_backups'; public function __construct( SettingsRepository $settings_repo, + StorageLocationRepository $storageLocationRepo, Backup $backup_task, MessageBus $messageBus ) { $this->settingsRepo = $settings_repo; - $this->backupTask = $backup_task; - $this->backupFs = new Filesystem(new Local(Backup::BASE_DIR)); + $this->storageLocationRepo = $storageLocationRepo; + $this->backupTask = $backup_task; $this->messageBus = $messageBus; } public function __invoke(ServerRequest $request, Response $response): ResponseInterface { + $backups = []; + foreach ($this->storageLocationRepo->findAllByType(StorageLocation::TYPE_BACKUP) as $storageLocation) { + $fs = $storageLocation->getFilesystem(); + foreach ($fs->listContents('', true) as $file) { + $file['storageLocationId'] = $storageLocation->getId(); + $file['pathEncoded'] = base64_encode($storageLocation->getId() . '|' . $file['path']); + $backups[] = $file; + } + } + $backups = array_reverse($backups); + return $request->getView()->renderToResponse($response, 'admin/backups/index', [ - 'backups' => array_reverse($this->backupFs->listContents('', false)), + 'backups' => $backups, 'is_enabled' => (bool)$this->settingsRepo->getSetting(Settings::BACKUP_ENABLED, false), 'last_run' => $this->settingsRepo->getSetting(Settings::BACKUP_LAST_RUN, 0), 'last_result' => $this->settingsRepo->getSetting(Settings::BACKUP_LAST_RESULT, 0), @@ -78,7 +90,9 @@ class BackupsController extends AbstractLogViewerController Response $response, Config $config ): ResponseInterface { - $runForm = new Form($config->get('forms/backup_run')); + $runForm = new Form($config->get('forms/backup_run', [ + 'storageLocations' => $this->storageLocationRepo->fetchSelectByType(StorageLocation::TYPE_BACKUP, true), + ])); // Handle submission. if ($request->isPost() && $runForm->isValid($request->getParsedBody())) { @@ -86,7 +100,13 @@ class BackupsController extends AbstractLogViewerController $tempFile = tempnam('/tmp', 'backup_'); + $storageLocationId = (int)$data['storage_location']; + if ($storageLocationId <= 0) { + $storageLocationId = null; + } + $message = new BackupMessage(); + $message->storageLocationId = $storageLocationId; $message->path = $data['path']; $message->excludeMedia = $data['exclude_media']; $message->outputPath = $tempFile; @@ -120,36 +140,52 @@ class BackupsController extends AbstractLogViewerController Response $response, $path ): ResponseInterface { - $path = $this->getFilePath($path); - $path = 'backup://' . $path; + [$path, $fs] = $this->getFile($path); - $fsGroup = new FilesystemGroup([ - 'backup' => $this->backupFs, - ]); - - return $response->withNoCache() - ->withFlysystemFile($fsGroup, $path); + /** @var Filesystem $fs */ + return $fs->streamToResponse($response->withNoCache(), $path); } public function deleteAction(ServerRequest $request, Response $response, $path, $csrf): ResponseInterface { $request->getCsrf()->verify($csrf, $this->csrfNamespace); - $path = $this->getFilePath($path); - $this->backupFs->delete($path); + [$path, $fs] = $this->getFile($path); + + /** @var Filesystem $fs */ + $fs->delete($path); $request->getFlash()->addMessage('' . __('Backup deleted.') . '', Flash::SUCCESS); return $response->withRedirect($request->getRouter()->named('admin:backups:index')); } - protected function getFilePath($raw_path): string + /** + * @param string $rawPath + * + * @return array{0: string, 1: Filesystem} + * @throws NotFoundException + */ + protected function getFile(string $rawPath): array { - $path = basename(base64_decode($raw_path)); + $pathStr = base64_decode($rawPath); + [$storageLocationId, $path] = explode('|', $pathStr); - if (!$this->backupFs->has($path)) { + $storageLocation = $this->storageLocationRepo->findByType( + StorageLocation::TYPE_BACKUP, + (int)$storageLocationId + ); + + + if (!($storageLocation instanceof StorageLocation)) { + throw new \InvalidArgumentException('Invalid storage location.'); + } + + $fs = $storageLocation->getFilesystem(); + + if (!$fs->has($path)) { throw new NotFoundException(__('Backup not found.')); } - return $path; + return [$path, $fs]; } } diff --git a/src/Controller/Admin/StorageLocationsController.php b/src/Controller/Admin/StorageLocationsController.php new file mode 100644 index 000000000..55db38969 --- /dev/null +++ b/src/Controller/Admin/StorageLocationsController.php @@ -0,0 +1,15 @@ +getView()->renderToResponse($response, 'admin/storage_locations/index'); + } +} diff --git a/src/Controller/Api/AbstractApiCrudController.php b/src/Controller/Api/AbstractApiCrudController.php index 2d7e6e9d8..ba9c6b2ea 100644 --- a/src/Controller/Api/AbstractApiCrudController.php +++ b/src/Controller/Api/AbstractApiCrudController.php @@ -65,6 +65,9 @@ abstract class AbstractApiCrudController } /** + * @param object $record + * @param ServerRequest $request + * * @return mixed */ protected function viewRecord($record, ServerRequest $request) diff --git a/src/Controller/Api/Admin/StorageLocationsController.php b/src/Controller/Api/Admin/StorageLocationsController.php new file mode 100644 index 000000000..a5f067186 --- /dev/null +++ b/src/Controller/Api/Admin/StorageLocationsController.php @@ -0,0 +1,170 @@ +storageLocationRepo = $storageLocationRepo; + } + + /** + * @OA\Get(path="/admin/storage_locations", + * tags={"Administration: Storage Locations"}, + * description="List all current storage locations in the system.", + * @OA\Response(response=200, description="Success", + * @OA\JsonContent(type="array", @OA\Items(ref="#/components/schemas/Admin_Api_StorageLocation")) + * ), + * @OA\Response(response=403, description="Access denied"), + * security={{"api_key": {}}}, + * ) + * + * @OA\Post(path="/admin/storage_locations", + * tags={"Administration: Storage Locations"}, + * description="Create a new storage location.", + * @OA\RequestBody( + * @OA\JsonContent(ref="#/components/schemas/StorageLocation") + * ), + * @OA\Response(response=200, description="Success", + * @OA\JsonContent(ref="#/components/schemas/StorageLocation") + * ), + * @OA\Response(response=403, description="Access denied"), + * security={{"api_key": {}}}, + * ) + * + * @OA\Get(path="/admin/storage_location/{id}", + * tags={"Administration: Storage Locations"}, + * description="Retrieve details for a single storage location.", + * @OA\Parameter( + * name="id", + * in="path", + * description="User ID", + * required=true, + * @OA\Schema(type="integer", format="int64") + * ), + * @OA\Response(response=200, description="Success", + * @OA\JsonContent(ref="#/components/schemas/Admin_Api_StorageLocation") + * ), + * @OA\Response(response=403, description="Access denied"), + * security={{"api_key": {}}}, + * ) + * + * @OA\Put(path="/admin/storage_location/{id}", + * tags={"Administration: Storage Locations"}, + * description="Update details of a single storage location.", + * @OA\RequestBody( + * @OA\JsonContent(ref="#/components/schemas/StorageLocation") + * ), + * @OA\Parameter( + * name="id", + * in="path", + * description="Storage Location ID", + * required=true, + * @OA\Schema(type="integer", format="int64") + * ), + * @OA\Response(response=200, description="Success", + * @OA\JsonContent(ref="#/components/schemas/Api_Status") + * ), + * @OA\Response(response=403, description="Access denied"), + * security={{"api_key": {}}}, + * ) + * + * @OA\Delete(path="/admin/storage_location/{id}", + * tags={"Administration: Storage Locations"}, + * description="Delete a single storage location.", + * @OA\Parameter( + * name="id", + * in="path", + * description="Storage Location ID", + * required=true, + * @OA\Schema(type="integer", format="int64") + * ), + * @OA\Response(response=200, description="Success", + * @OA\JsonContent(ref="#/components/schemas/Api_Status") + * ), + * @OA\Response(response=403, description="Access denied"), + * security={{"api_key": {}}}, + * ) + */ + + public function listAction(ServerRequest $request, Response $response): ResponseInterface + { + $qb = $this->em->createQueryBuilder(); + + $qb->select('sl') + ->from(Entity\StorageLocation::class, 'sl'); + + $type = $request->getQueryParam('type', null); + if (!empty($type)) { + $qb->andWhere('sl.type = :type') + ->setParameter('type', $type); + } + + $query = $qb->getQuery(); + + return $this->listPaginatedFromQuery($request, $response, $query); + } + + /** @inheritDoc */ + protected function viewRecord($record, ServerRequest $request) + { + /** @var Entity\StorageLocation $record */ + $return = parent::viewRecord($record, $request); + + $return['uri'] = $record->getUri(); + + $stationsRaw = $this->storageLocationRepo->getStationsUsingLocation($record); + $stations = []; + + foreach ($stationsRaw as $station) { + $stations[] = $station->getName(); + } + $return['stations'] = $stations; + + return $return; + } + + protected function deleteRecord($record): void + { + if (!($record instanceof Entity\StorageLocation)) { + throw new InvalidArgumentException(sprintf('Record must be an instance of %s.', $this->entityClass)); + } + + $stations = $this->storageLocationRepo->getStationsUsingLocation($record); + + if (0 !== count($stations)) { + $stationNames = []; + foreach ($stations as $station) { + $stationNames[] = $station->getName(); + } + + throw new RuntimeException('This storage location has stations associated with it, and cannot be ' + . ' deleted until these stations are updated: ' . implode(', ', $stationNames)); + } + + parent::deleteRecord($record); + } +} diff --git a/src/Controller/Api/Stations/AbstractStationApiCrudController.php b/src/Controller/Api/Stations/AbstractStationApiCrudController.php index d116d0720..40651a489 100644 --- a/src/Controller/Api/Stations/AbstractStationApiCrudController.php +++ b/src/Controller/Api/Stations/AbstractStationApiCrudController.php @@ -68,14 +68,6 @@ abstract class AbstractStationApiCrudController extends AbstractApiCrudControlle ]); } - protected function editRecord($data, $record = null, array $context = []): object - { - // Force an unset of the `station` parameter as it supercedes the default constructor arguments. - unset($data['station']); - - return parent::editRecord($data, $record, $context); - } - /** * @param ServerRequest $request * @param Response $response diff --git a/src/Controller/Api/Stations/Art/GetArtAction.php b/src/Controller/Api/Stations/Art/GetArtAction.php index 422596dbe..62f279440 100644 --- a/src/Controller/Api/Stations/Art/GetArtAction.php +++ b/src/Controller/Api/Stations/Art/GetArtAction.php @@ -5,7 +5,7 @@ namespace App\Controller\Api\Stations\Art; use App\Entity\Repository\StationMediaRepository; use App\Entity\Repository\StationRepository; use App\Entity\StationMedia; -use App\Flysystem\Filesystem; +use App\Flysystem\FilesystemManager; use App\Http\Response; use App\Http\ServerRequest; use OpenApi\Annotations as OA; @@ -33,7 +33,7 @@ class GetArtAction * * @param ServerRequest $request * @param Response $response - * @param Filesystem $filesystem + * @param FilesystemManager $filesystem * @param StationRepository $stationRepo * @param StationMediaRepository $mediaRepo * @param string $media_id @@ -41,7 +41,7 @@ class GetArtAction public function __invoke( ServerRequest $request, Response $response, - Filesystem $filesystem, + FilesystemManager $filesystem, StationRepository $stationRepo, StationMediaRepository $mediaRepo, $media_id @@ -49,25 +49,25 @@ class GetArtAction $station = $request->getStation(); $defaultArtRedirect = $response->withRedirect($stationRepo->getDefaultAlbumArtUrl($station), 302); - $fs = $filesystem->getForStation($station); + $fs = $filesystem->getForStation($station, true); // If a timestamp delimiter is added, strip it automatically. $media_id = explode('-', $media_id)[0]; if (StationMedia::UNIQUE_ID_LENGTH === strlen($media_id)) { $response = $response->withCacheLifetime(Response::CACHE_ONE_YEAR); - $mediaPath = Filesystem::PREFIX_ALBUM_ART . '://' . $media_id . '.jpg'; + $mediaPath = StationMedia::getArtUri($media_id); } else { $media = $mediaRepo->find($media_id, $station); if ($media instanceof StationMedia) { - $mediaPath = $media->getArtPath(); + $mediaPath = StationMedia::getArtUri($media->getUniqueId()); } else { return $defaultArtRedirect; } } if ($fs->has($mediaPath)) { - return $response->withFlysystemFile($fs, $mediaPath, null, 'inline'); + return $fs->streamToResponse($response, $mediaPath, null, 'inline'); } return $defaultArtRedirect; diff --git a/src/Controller/Api/Stations/Art/PostArtAction.php b/src/Controller/Api/Stations/Art/PostArtAction.php index c0fba56f0..9ae801ab7 100644 --- a/src/Controller/Api/Stations/Art/PostArtAction.php +++ b/src/Controller/Api/Stations/Art/PostArtAction.php @@ -3,7 +3,7 @@ namespace App\Controller\Api\Stations\Art; use App\Entity; -use App\Flysystem\Filesystem; +use App\Flysystem\FilesystemManager; use App\Http\Response; use App\Http\ServerRequest; use Doctrine\ORM\EntityManagerInterface; @@ -15,7 +15,7 @@ class PostArtAction public function __invoke( ServerRequest $request, Response $response, - Filesystem $filesystem, + FilesystemManager $filesystem, Entity\Repository\StationMediaRepository $mediaRepo, EntityManagerInterface $em, $media_id diff --git a/src/Controller/Api/Stations/Files/BatchAction.php b/src/Controller/Api/Stations/Files/BatchAction.php index 92b987274..641ce8441 100644 --- a/src/Controller/Api/Stations/Files/BatchAction.php +++ b/src/Controller/Api/Stations/Files/BatchAction.php @@ -3,8 +3,8 @@ namespace App\Controller\Api\Stations\Files; use App\Entity; -use App\Flysystem\Filesystem; -use App\Flysystem\StationFilesystem; +use App\Flysystem\FilesystemManager; +use App\Flysystem\StationFilesystemGroup; use App\Http\Response; use App\Http\ServerRequest; use App\Message\WritePlaylistFileMessage; @@ -23,7 +23,7 @@ class BatchAction Entity\Repository\StationMediaRepository $mediaRepo, Entity\Repository\StationPlaylistMediaRepository $playlistMediaRepo, Entity\Repository\StationPlaylistFolderRepository $playlistFolderRepo, - Filesystem $filesystem, + FilesystemManager $filesystem, MessageBus $messageBus ): ResponseInterface { $station = $request->getStation(); @@ -35,7 +35,7 @@ class BatchAction $files = []; foreach ($files_raw as $file) { - $file_path = Filesystem::PREFIX_MEDIA . '://' . $file; + $file_path = FilesystemManager::PREFIX_MEDIA . '://' . $file; if ($fs->has($file_path)) { $files[] = $file_path; @@ -82,19 +82,25 @@ class BatchAction } // Delete all selected files. + $mediaStorage = $station->getMediaStorageLocation(); + foreach ($files as $file) { - $file_meta = $fs->getMetadata($file); + try { + $file_meta = $fs->getMetadata($file); - if ('dir' === $file_meta['type']) { - $fs->deleteDir($file); - } else { - $station->removeStorageUsed($file_meta['size']); - - $fs->delete($file); + if ('file' === $file_meta['type']) { + $mediaStorage->removeStorageUsed($file_meta['size']); + $fs->delete($file); + } else { + $fs->deleteDir($file); + } + } catch (Exception $e) { + $errors[] = $file . ': ' . $e->getMessage(); } } $em->persist($station); + $em->persist($mediaStorage); $em->flush(); // Write new PLS playlist configuration. @@ -210,17 +216,10 @@ class BatchAction $files_found = count($music_files); $directory_path = $request->getParam('directory'); - $directory_path_full = Filesystem::PREFIX_MEDIA . '://' . $directory_path; + $directory_path_full = FilesystemManager::PREFIX_MEDIA . '://' . $directory_path; try { // Verify that you're moving to a directory (if it's not the root dir). - if ('' !== $directory_path) { - $directory_path_meta = $fs->getMetadata($directory_path_full); - if ('dir' !== $directory_path_meta['type']) { - throw new \App\Exception(__('Path "%s" is not a folder.', $directory_path_full)); - } - } - foreach ($music_files as $file) { $media = $mediaRepo->getOrCreate($station, $file['path']); @@ -290,7 +289,7 @@ class BatchAction /** * @return mixed[] */ - protected function getMusicFiles(StationFilesystem $fs, array $files): array + protected function getMusicFiles(StationFilesystemGroup $fs, array $files): array { $musicFiles = []; @@ -316,7 +315,7 @@ class BatchAction /** * @return mixed[] */ - protected function getDirectories(StationFilesystem $fs, array $files): array + protected function getDirectories(StationFilesystemGroup $fs, array $files): array { $directories = []; diff --git a/src/Controller/Api/Stations/Files/DownloadAction.php b/src/Controller/Api/Stations/Files/DownloadAction.php index c8b250bf8..9d465ed01 100644 --- a/src/Controller/Api/Stations/Files/DownloadAction.php +++ b/src/Controller/Api/Stations/Files/DownloadAction.php @@ -3,7 +3,7 @@ namespace App\Controller\Api\Stations\Files; use App\Entity; -use App\Flysystem\Filesystem; +use App\Flysystem\FilesystemManager; use App\Http\Response; use App\Http\ServerRequest; use Psr\Http\Message\ResponseInterface; @@ -14,7 +14,7 @@ class DownloadAction ServerRequest $request, Response $response, int $id, - Filesystem $filesystem, + FilesystemManager $filesystem, Entity\Repository\StationMediaRepository $mediaRepo ): ResponseInterface { set_time_limit(600); @@ -28,8 +28,8 @@ class DownloadAction ->withJson(new Entity\Api\Error(404, 'Not Found')); } - $fs = $filesystem->getForStation($station); + $fs = $filesystem->getForStation($station, false); - return $response->withFlysystemFile($fs, $media->getPathUri()); + return $fs->streamToResponse($response, $media->getPathUri()); } } diff --git a/src/Controller/Api/Stations/Files/FlowUploadAction.php b/src/Controller/Api/Stations/Files/FlowUploadAction.php index 05f61368f..79fb792b9 100644 --- a/src/Controller/Api/Stations/Files/FlowUploadAction.php +++ b/src/Controller/Api/Stations/Files/FlowUploadAction.php @@ -21,7 +21,9 @@ class FlowUploadAction $params = $request->getParams(); $station = $request->getStation(); - if ($station->isStorageFull()) { + $mediaStorage = $station->getMediaStorageLocation(); + + if ($mediaStorage->isStorageFull()) { return $response->withStatus(500) ->withJson(new Entity\Api\Error(500, __('This station is out of available storage space.'))); } @@ -61,8 +63,9 @@ class FlowUploadAction } } } - - $station->addStorageUsed($flowResponse['size']); + + $mediaStorage->addStorageUsed($flowResponse['size']); + $em->persist($mediaStorage); $em->flush(); return $response->withJson(new Entity\Api\Status()); diff --git a/src/Controller/Api/Stations/Files/ListAction.php b/src/Controller/Api/Stations/Files/ListAction.php index 0add98f3f..a4e0f9015 100644 --- a/src/Controller/Api/Stations/Files/ListAction.php +++ b/src/Controller/Api/Stations/Files/ListAction.php @@ -3,11 +3,12 @@ namespace App\Controller\Api\Stations\Files; use App\Entity; -use App\Flysystem\Filesystem; +use App\Flysystem\FilesystemManager; use App\Http\Response; use App\Http\ServerRequest; use App\Utilities; use Doctrine\ORM\EntityManagerInterface; +use Jhofm\FlysystemIterator\Options\Options; use Psr\Http\Message\ResponseInterface; use const SORT_ASC; @@ -19,7 +20,7 @@ class ListAction ServerRequest $request, Response $response, EntityManagerInterface $em, - Filesystem $filesystem, + FilesystemManager $filesystem, Entity\Repository\StationRepository $stationRepo ): ResponseInterface { $station = $request->getStation(); @@ -29,7 +30,7 @@ class ListAction $params = $request->getParams(); if ($params['flushCache'] ?? false) { - $fs->flushAllCaches(); + $fs->clearCache(); } $result = []; @@ -60,9 +61,11 @@ class ListAction ->leftJoin('sm.custom_fields', 'smcf') ->leftJoin('sm.playlists', 'spm') ->leftJoin('spm.playlist', 'sp') - ->where('sm.station_id = :station_id') + ->where('sm.storage_location = :storageLocation') ->andWhere('sm.path LIKE :path') - ->setParameter('station_id', $station->getId()) + ->andWhere('(sp.station IS NULL OR sp.station = :station)') + ->setParameter('storageLocation', $station->getMediaStorageLocation()) + ->setParameter('station', $station) ->setParameter('path', $pathLike); // Apply searching @@ -117,8 +120,7 @@ class ListAction 'station_id' => $station->getId(), 'media_id' => $media_row['unique_id'] . '-' . $media_row['art_updated_at'], ] - ) - ; + ); $media_in_dir[$media_row['path']] = [ 'is_playable' => ($media_row['length'] !== 0), @@ -170,18 +172,26 @@ class ListAction $files = []; if (!empty($search_phrase)) { foreach ($media_in_dir as $short_path => $media_row) { - $files[] = Filesystem::PREFIX_MEDIA . '://' . $short_path; + $files[] = $short_path; } } else { - $files_raw = $fs->listContents($filePath); - foreach ($files_raw as $file) { - $files[] = $file['filesystem'] . '://' . $file['path']; + $filesIterator = $fs->createIterator($filePath, [ + Options::OPTION_IS_RECURSIVE => false, + ]); + + $protectedPaths = [Entity\StationMedia::DIR_ALBUM_ART, Entity\StationMedia::DIR_WAVEFORMS]; + + foreach ($filesIterator as $fileRow) { + if ($file === '' && in_array($fileRow['path'], $protectedPaths, true)) { + continue; + } + + $files[] = $fileRow['path']; } } - foreach ($files as $i) { - $short = str_replace(Filesystem::PREFIX_MEDIA . '://', '', $i); - $meta = $fs->getMetadata($i); + foreach ($files as $short) { + $meta = $fs->getMetadata(FilesystemManager::PREFIX_MEDIA . '://' . $short); if ('dir' === $meta['type']) { $media = ['name' => __('Directory'), 'playlists' => [], 'is_playable' => false]; diff --git a/src/Controller/Api/Stations/Files/ListDirectoriesAction.php b/src/Controller/Api/Stations/Files/ListDirectoriesAction.php index c41cbb9fc..e4124fc26 100644 --- a/src/Controller/Api/Stations/Files/ListDirectoriesAction.php +++ b/src/Controller/Api/Stations/Files/ListDirectoriesAction.php @@ -3,7 +3,7 @@ namespace App\Controller\Api\Stations\Files; use App\Entity; -use App\Flysystem\Filesystem; +use App\Flysystem\FilesystemManager; use App\Http\Response; use App\Http\ServerRequest; use Psr\Http\Message\ResponseInterface; @@ -13,7 +13,7 @@ class ListDirectoriesAction public function __invoke( ServerRequest $request, Response $response, - Filesystem $filesystem + FilesystemManager $filesystem ): ResponseInterface { $station = $request->getStation(); $fs = $filesystem->getForStation($station); @@ -29,11 +29,17 @@ class ListDirectoriesAction } } - $directories = array_filter(array_map(function ($file) { + $protectedPaths = [Entity\StationMedia::DIR_ALBUM_ART, Entity\StationMedia::DIR_WAVEFORMS]; + + $directories = array_filter(array_map(function ($file) use ($protectedPaths) { if ('dir' !== $file['type']) { return null; } + if (in_array($file['path'], $protectedPaths, true)) { + return null; + } + return [ 'name' => $file['basename'], 'path' => $file['path'], diff --git a/src/Controller/Api/Stations/Files/MakeDirectoryAction.php b/src/Controller/Api/Stations/Files/MakeDirectoryAction.php index 84bbe743b..76abf7b41 100644 --- a/src/Controller/Api/Stations/Files/MakeDirectoryAction.php +++ b/src/Controller/Api/Stations/Files/MakeDirectoryAction.php @@ -3,7 +3,7 @@ namespace App\Controller\Api\Stations\Files; use App\Entity; -use App\Flysystem\Filesystem; +use App\Flysystem\FilesystemManager; use App\Http\Response; use App\Http\ServerRequest; use Psr\Http\Message\ResponseInterface; @@ -13,7 +13,7 @@ class MakeDirectoryAction public function __invoke( ServerRequest $request, Response $response, - Filesystem $filesystem + FilesystemManager $filesystem ): ResponseInterface { $params = $request->getParams(); diff --git a/src/Controller/Api/Stations/Files/RenameAction.php b/src/Controller/Api/Stations/Files/RenameAction.php index b2b0462ab..4d6ea5f63 100644 --- a/src/Controller/Api/Stations/Files/RenameAction.php +++ b/src/Controller/Api/Stations/Files/RenameAction.php @@ -3,7 +3,7 @@ namespace App\Controller\Api\Stations\Files; use App\Entity; -use App\Flysystem\Filesystem; +use App\Flysystem\FilesystemManager; use App\Http\Response; use App\Http\ServerRequest; use Doctrine\ORM\EntityManagerInterface; @@ -14,7 +14,7 @@ class RenameAction public function __invoke( ServerRequest $request, Response $response, - Filesystem $filesystem, + FilesystemManager $filesystem, EntityManagerInterface $em, Entity\Repository\StationMediaRepository $mediaRepo ): ResponseInterface { @@ -40,7 +40,7 @@ class RenameAction $fs = $filesystem->getForStation($station, false); $originalPathFull = $request->getAttribute('file_path'); - $newPathFull = Filesystem::PREFIX_MEDIA . '://' . $newPath; + $newPathFull = FilesystemManager::PREFIX_MEDIA . '://' . $newPath; // MountManager::rename's second argument is NOT the full URI >:( $fs->rename($originalPathFull, $newPath); diff --git a/src/Controller/Api/Stations/FilesController.php b/src/Controller/Api/Stations/FilesController.php index 26b51627c..0e36ed715 100644 --- a/src/Controller/Api/Stations/FilesController.php +++ b/src/Controller/Api/Stations/FilesController.php @@ -4,7 +4,7 @@ namespace App\Controller\Api\Stations; use App\Entity; use App\Exception\ValidationException; -use App\Flysystem\Filesystem; +use App\Flysystem\FilesystemManager; use App\Http\Response; use App\Http\ServerRequest; use App\Message\WritePlaylistFileMessage; @@ -24,28 +24,28 @@ class FilesController extends AbstractStationApiCrudController protected string $entityClass = Entity\StationMedia::class; protected string $resourceRouteName = 'api:stations:file'; - protected Filesystem $filesystem; + protected FilesystemManager $filesystem; protected Adapters $adapters; protected MessageBus $messageBus; - protected Entity\Repository\CustomFieldRepository $custom_fields_repo; + protected Entity\Repository\CustomFieldRepository $customFieldsRepo; - protected Entity\Repository\StationMediaRepository $media_repo; + protected Entity\Repository\StationMediaRepository $mediaRepo; - protected Entity\Repository\StationPlaylistMediaRepository $playlist_media_repo; + protected Entity\Repository\StationPlaylistMediaRepository $playlistMediaRepo; public function __construct( EntityManagerInterface $em, Serializer $serializer, ValidatorInterface $validator, - Filesystem $filesystem, + FilesystemManager $filesystem, Adapters $adapters, MessageBus $messageBus, - Entity\Repository\CustomFieldRepository $custom_fields_repo, - Entity\Repository\StationMediaRepository $media_repo, - Entity\Repository\StationPlaylistMediaRepository $playlist_media_repo + Entity\Repository\CustomFieldRepository $customFieldsRepo, + Entity\Repository\StationMediaRepository $mediaRepo, + Entity\Repository\StationPlaylistMediaRepository $playlistMediaRepo ) { parent::__construct($em, $serializer, $validator); @@ -53,9 +53,9 @@ class FilesController extends AbstractStationApiCrudController $this->adapters = $adapters; $this->messageBus = $messageBus; - $this->custom_fields_repo = $custom_fields_repo; - $this->media_repo = $media_repo; - $this->playlist_media_repo = $playlist_media_repo; + $this->customFieldsRepo = $customFieldsRepo; + $this->mediaRepo = $mediaRepo; + $this->playlistMediaRepo = $playlistMediaRepo; } /** @@ -69,9 +69,7 @@ class FilesController extends AbstractStationApiCrudController * @OA\Response(response=403, description="Access denied"), * security={{"api_key": {}}}, * ) - */ - - /** + * * @OA\Post(path="/station/{station_id}/files", * tags={"Stations: Media"}, * description="Upload a new file.", @@ -86,47 +84,6 @@ class FilesController extends AbstractStationApiCrudController * security={{"api_key": {}}}, * ) * - * @param ServerRequest $request - * @param Response $response - */ - public function createAction(ServerRequest $request, Response $response): ResponseInterface - { - $station = $this->getStation($request); - - if ($station->isStorageFull()) { - return $response->withStatus(500) - ->withJson(new Entity\Api\Error(500, __('This station is out of available storage space.'))); - } - - $request->getParsedBody(); - - // Convert the body into an UploadFile API entity first. - /** @var Entity\Api\UploadFile $api_record */ - $api_record = $this->serializer->denormalize($request->getParsedBody(), Entity\Api\UploadFile::class, null, []); - - // Validate the UploadFile API record. - $errors = $this->validator->validate($api_record); - if (count($errors) > 0) { - $e = new ValidationException((string)$errors); - $e->setDetailedErrors($errors); - throw $e; - } - - // Write file to temp path. - $temp_path = $station->getRadioTempDir() . '/' . $api_record->getSanitizedFilename(); - file_put_contents($temp_path, $api_record->getFileContents()); - - $sanitized_path = Filesystem::PREFIX_MEDIA . '://' . $api_record->getSanitizedPath(); - - // Process temp path as regular media record. - $record = $this->media_repo->getOrCreate($station, $sanitized_path, $temp_path); - - $return = $this->viewRecord($record, $request); - - return $response->withJson($return); - } - - /** * @OA\Get(path="/station/{station_id}/file/{id}", * tags={"Stations: Media"}, * description="Retrieve details for a single file.", @@ -186,87 +143,121 @@ class FilesController extends AbstractStationApiCrudController */ /** - * @inheritDoc + * @param ServerRequest $request + * @param Response $response + * */ - protected function getRecord(Entity\Station $station, $id): ?object + public function listAction(ServerRequest $request, Response $response): ResponseInterface { - $repo = $this->em->getRepository($this->entityClass); + $station = $this->getStation($request); + $storageLocation = $station->getMediaStorageLocation(); - $fieldsToCheck = ['id', 'unique_id', 'song_id']; + $query = $this->em->createQuery(/** @lang DQL */ 'SELECT e + FROM App\Entity\StationMedia e + WHERE e.storage_location = :storageLocation') + ->setParameter('storageLocation', $storageLocation); - foreach ($fieldsToCheck as $field) { - $record = $repo->findOneBy([ - 'station' => $station, - $field => $id, - ]); - - if ($record instanceof $this->entityClass) { - return $record; - } - } - - return null; + return $this->listPaginatedFromQuery($request, $response, $query); } - /** - * @inheritDoc - */ - protected function toArray($record, array $context = []): array + public function createAction(ServerRequest $request, Response $response): ResponseInterface { - $row = parent::toArray($record, $context); + $station = $this->getStation($request); - if ($record instanceof Entity\StationMedia) { - $row['custom_fields'] = $this->custom_fields_repo->getCustomFields($record); + $mediaStorage = $station->getMediaStorageLocation(); + if ($mediaStorage->isStorageFull()) { + return $response->withStatus(500) + ->withJson(new Entity\Api\Error(500, __('This station is out of available storage space.'))); } - return $row; + $request->getParsedBody(); + + // Convert the body into an UploadFile API entity first. + /** @var Entity\Api\UploadFile $api_record */ + $api_record = $this->serializer->denormalize($request->getParsedBody(), Entity\Api\UploadFile::class, null, []); + + // Validate the UploadFile API record. + $errors = $this->validator->validate($api_record); + if (count($errors) > 0) { + $e = new ValidationException((string)$errors); + $e->setDetailedErrors($errors); + throw $e; + } + + // Write file to temp path. + $temp_path = $station->getRadioTempDir() . '/' . $api_record->getSanitizedFilename(); + file_put_contents($temp_path, $api_record->getFileContents()); + + $sanitized_path = FilesystemManager::PREFIX_MEDIA . '://' . $api_record->getSanitizedPath(); + + // Process temp path as regular media record. + $record = $this->mediaRepo->getOrCreate($station, $sanitized_path, $temp_path); + + $return = $this->viewRecord($record, $request); + + return $response->withJson($return); } - /** - * @inheritDoc - */ - protected function fromArray($data, $record = null, array $context = []): object + public function editAction(ServerRequest $request, Response $response, $station_id, $id): ResponseInterface { + $station = $this->getStation($request); + $record = $this->getRecord($station, $id); + + if (null === $record) { + return $response->withStatus(404) + ->withJson(new Entity\Api\Error(404, __('Record not found!'))); + } + + $data = $request->getParsedBody(); + if (null === $data) { + throw new InvalidArgumentException('Could not parse input data.'); + } + $custom_fields = $data['custom_fields'] ?? null; $playlists = $data['playlists'] ?? null; unset($data['custom_fields'], $data['playlists']); - $record = parent::fromArray($data, $record, array_merge($context, [ + $record = $this->fromArray($data, $record, [ AbstractNormalizer::CALLBACKS => [ 'path' => function ($new_value, $record) { // Detect and handle a rename. if (($record instanceof Entity\StationMedia) && $new_value !== $record->getPath()) { - $path_full = Filesystem::PREFIX_MEDIA . '://' . $new_value; + $path_full = FilesystemManager::PREFIX_MEDIA . '://' . $new_value; - $fs = $this->filesystem->getForStation($record->getStation()); + $fs = $record->getStorageLocation()->getFilesystem(); $fs->rename($record->getPathUri(), $path_full); } return $new_value; }, ], - ])); + ]); + + $errors = $this->validator->validate($record); + if (count($errors) > 0) { + $e = new ValidationException((string)$errors); + $e->setDetailedErrors($errors); + throw $e; + } if ($record instanceof Entity\StationMedia) { $this->em->persist($record); $this->em->flush(); - if ($this->media_repo->writeToFile($record)) { + if ($this->mediaRepo->writeToFile($record)) { $record->updateSongId(); } if (null !== $custom_fields) { - $this->custom_fields_repo->setCustomFields($record, $custom_fields); + $this->customFieldsRepo->setCustomFields($record, $custom_fields); } if (null !== $playlists) { - $station = $record->getStation(); - /** @var Entity\StationPlaylist[] $affected_playlists */ $affected_playlists = []; // Remove existing playlists. - $media_playlists = $this->playlist_media_repo->clearPlaylistsFromMedia($record); + $media_playlists = $this->playlistMediaRepo->clearPlaylistsFromMedia($record); $this->em->flush(); foreach ($media_playlists as $playlist_id => $playlist) { @@ -292,7 +283,7 @@ class FilesController extends AbstractStationApiCrudController if ($playlist instanceof Entity\StationPlaylist) { $affected_playlists[$playlist->getId()] = $playlist; - $this->playlist_media_repo->addMediaToPlaylist($record, $playlist, $playlist_weight); + $this->playlistMediaRepo->addMediaToPlaylist($record, $playlist, $playlist_weight); } } @@ -310,7 +301,52 @@ class FilesController extends AbstractStationApiCrudController } } - return $record; + return $response->withJson(new Entity\Api\Status(true, __('Changes saved successfully.'))); + } + + protected function createRecord($data, Entity\Station $station): object + { + $mediaStorage = $station->getMediaStorageLocation(); + + return $this->editRecord($data, null, [ + AbstractNormalizer::DEFAULT_CONSTRUCTOR_ARGUMENTS => [ + $this->entityClass => [ + 'station' => $station, + 'storageLocation' => $mediaStorage, + ], + ], + ]); + } + + protected function getRecord(Entity\Station $station, $id): ?object + { + $mediaStorage = $station->getMediaStorageLocation(); + $repo = $this->em->getRepository($this->entityClass); + + $fieldsToCheck = ['id', 'unique_id', 'song_id']; + foreach ($fieldsToCheck as $field) { + $record = $repo->findOneBy([ + 'storage_location' => $mediaStorage, + $field => $id, + ]); + + if ($record instanceof $this->entityClass) { + return $record; + } + } + + return null; + } + + /** @inheritDoc */ + protected function toArray($record, array $context = []): array + { + $row = parent::toArray($record, $context); + + if ($record instanceof Entity\StationMedia) { + $row['custom_fields'] = $this->customFieldsRepo->getCustomFields($record); + } + return $row; } /** @@ -322,12 +358,10 @@ class FilesController extends AbstractStationApiCrudController throw new InvalidArgumentException(sprintf('Record must be an instance of %s.', $this->entityClass)); } - $station = $record->getStation(); - /** @var Entity\StationPlaylist[] $affected_playlists */ $affected_playlists = []; - $media_playlists = $this->playlist_media_repo->clearPlaylistsFromMedia($record); + $media_playlists = $this->playlistMediaRepo->clearPlaylistsFromMedia($record); foreach ($media_playlists as $playlist_id => $playlist) { if (!isset($affected_playlists[$playlist_id])) { $affected_playlists[$playlist_id] = $playlist; @@ -335,15 +369,12 @@ class FilesController extends AbstractStationApiCrudController } // Delete the media file off the filesystem. - $fs = $this->filesystem->getForStation($station); - - $fs->delete($record->getPathUri()); - $fs->delete($record->getArtPath()); + $this->mediaRepo->remove($record); // Write new PLS playlist configuration. - $backend = $this->adapters->getBackendAdapter($station); - if ($backend instanceof Liquidsoap) { - foreach ($affected_playlists as $playlist_id => $playlist_row) { + foreach ($affected_playlists as $playlist_id => $playlist) { + $backend = $this->adapters->getBackendAdapter($playlist->getStation()); + if ($backend instanceof Liquidsoap) { // Instruct the message queue to start a new "write playlist to file" task. $message = new WritePlaylistFileMessage(); $message->playlist_id = $playlist_id; @@ -351,7 +382,5 @@ class FilesController extends AbstractStationApiCrudController $this->messageBus->dispatch($message); } } - - parent::deleteRecord($record); } } diff --git a/src/Controller/Api/Stations/OnDemand/DownloadAction.php b/src/Controller/Api/Stations/OnDemand/DownloadAction.php index 772d1ffe7..086726460 100644 --- a/src/Controller/Api/Stations/OnDemand/DownloadAction.php +++ b/src/Controller/Api/Stations/OnDemand/DownloadAction.php @@ -3,7 +3,7 @@ namespace App\Controller\Api\Stations\OnDemand; use App\Entity; -use App\Flysystem\Filesystem; +use App\Flysystem\FilesystemManager; use App\Http\Response; use App\Http\ServerRequest; use Psr\Http\Message\ResponseInterface; @@ -15,7 +15,7 @@ class DownloadAction Response $response, string $media_id, Entity\Repository\StationMediaRepository $mediaRepo, - Filesystem $filesystem + FilesystemManager $filesystem ): ResponseInterface { $station = $request->getStation(); @@ -36,6 +36,6 @@ class DownloadAction $fs = $filesystem->getForStation($station); set_time_limit(600); - return $response->withFlysystemFile($fs, $filePath); + return $fs->streamToResponse($response, $filePath); } } diff --git a/src/Controller/Api/Stations/OnDemand/ListAction.php b/src/Controller/Api/Stations/OnDemand/ListAction.php index ca797040f..c9f03b0da 100644 --- a/src/Controller/Api/Stations/OnDemand/ListAction.php +++ b/src/Controller/Api/Stations/OnDemand/ListAction.php @@ -131,7 +131,7 @@ class ListAction $row = new Entity\Api\StationOnDemand(); $row->track_id = $media->getUniqueId(); - $row->media = ($this->songApiGenerator)($media); + $row->media = ($this->songApiGenerator)($media, $station); $row->playlist = $playlist['name']; $row->download_url = (string)$router->named('api:stations:ondemand:download', [ 'station_id' => $station->getId(), diff --git a/src/Controller/Api/Stations/RequestsController.php b/src/Controller/Api/Stations/RequestsController.php index 0dd2557b0..a8edc21a0 100644 --- a/src/Controller/Api/Stations/RequestsController.php +++ b/src/Controller/Api/Stations/RequestsController.php @@ -70,11 +70,13 @@ class RequestsController ->from(Entity\StationMedia::class, 'sm') ->leftJoin('sm.playlists', 'spm') ->leftJoin('spm.playlist', 'sp') - ->where('sm.station_id = :station_id') + ->where('sm.storage_location = :storageLocation') ->andWhere('sp.id IS NOT NULL') + ->andWhere('sp.station = :station') ->andWhere('sp.is_enabled = 1') ->andWhere('sp.include_in_requests = 1') - ->setParameter('station_id', $station->getId()); + ->setParameter('storageLocation', $station->getMediaStorageLocation()) + ->setParameter('station', $station); $params = $request->getQueryParams(); diff --git a/src/Controller/Api/Stations/Streamers/BroadcastsController.php b/src/Controller/Api/Stations/Streamers/BroadcastsController.php index 62297c09b..845f88587 100644 --- a/src/Controller/Api/Stations/Streamers/BroadcastsController.php +++ b/src/Controller/Api/Stations/Streamers/BroadcastsController.php @@ -5,7 +5,7 @@ namespace App\Controller\Api\Stations\Streamers; use App\Controller\Api\AbstractApiCrudController; use App\Entity; use App\File; -use App\Flysystem\Filesystem; +use App\Flysystem\FilesystemManager; use App\Http\Response; use App\Http\ServerRequest; use App\Paginator\QueryPaginator; @@ -19,14 +19,14 @@ class BroadcastsController extends AbstractApiCrudController /** * @param ServerRequest $request * @param Response $response - * @param Filesystem $filesystem + * @param FilesystemManager $filesystem * @param string|int $station_id * @param int $id */ public function listAction( ServerRequest $request, Response $response, - Filesystem $filesystem, + FilesystemManager $filesystem, $station_id, $id ): ResponseInterface { @@ -59,7 +59,7 @@ class BroadcastsController extends AbstractApiCrudController unset($return['recordingPath']); $recordingPath = $row->getRecordingPath(); - $recordingUri = Filesystem::PREFIX_RECORDINGS . '://' . $recordingPath; + $recordingUri = FilesystemManager::PREFIX_RECORDINGS . '://' . $recordingPath; if ($fs->has($recordingUri)) { $recordingMeta = $fs->getMetadata($recordingUri); @@ -99,7 +99,7 @@ class BroadcastsController extends AbstractApiCrudController /** * @param ServerRequest $request * @param Response $response - * @param Filesystem $filesystem + * @param FilesystemManager $filesystem * @param string|int $station_id * @param int $id * @param int $broadcast_id @@ -107,7 +107,7 @@ class BroadcastsController extends AbstractApiCrudController public function downloadAction( ServerRequest $request, Response $response, - Filesystem $filesystem, + FilesystemManager $filesystem, $station_id, $id, $broadcast_id @@ -130,10 +130,10 @@ class BroadcastsController extends AbstractApiCrudController $fs = $filesystem->getForStation($station); $filename = basename($recordingPath); - $recordingPath = Filesystem::PREFIX_RECORDINGS . '://' . $recordingPath; + $recordingPath = FilesystemManager::PREFIX_RECORDINGS . '://' . $recordingPath; - return $response->withFlysystemFile( - $fs, + return $fs->streamToResponse( + $response, $recordingPath, File::sanitizeFileName($broadcast->getStreamer()->getDisplayName()) . '_' . $filename ); @@ -142,7 +142,7 @@ class BroadcastsController extends AbstractApiCrudController public function deleteAction( ServerRequest $request, Response $response, - Filesystem $filesystem, + FilesystemManager $filesystem, $station_id, $id, $broadcast_id @@ -159,7 +159,7 @@ class BroadcastsController extends AbstractApiCrudController if (!empty($recordingPath)) { $fs = $filesystem->getForStation($station); - $recordingPath = Filesystem::PREFIX_RECORDINGS . '://' . $recordingPath; + $recordingPath = FilesystemManager::PREFIX_RECORDINGS . '://' . $recordingPath; $fs->delete($recordingPath); diff --git a/src/Controller/Api/Stations/Waveform/GetWaveformAction.php b/src/Controller/Api/Stations/Waveform/GetWaveformAction.php index df774b1f5..29d14bac3 100644 --- a/src/Controller/Api/Stations/Waveform/GetWaveformAction.php +++ b/src/Controller/Api/Stations/Waveform/GetWaveformAction.php @@ -5,7 +5,7 @@ namespace App\Controller\Api\Stations\Waveform; use App\Entity\Api\Error; use App\Entity\Repository\StationMediaRepository; use App\Entity\StationMedia; -use App\Flysystem\Filesystem; +use App\Flysystem\FilesystemManager; use App\Http\Response; use App\Http\ServerRequest; use Psr\Http\Message\ResponseInterface; @@ -15,7 +15,7 @@ class GetWaveformAction public function __invoke( ServerRequest $request, Response $response, - Filesystem $filesystem, + FilesystemManager $filesystem, StationMediaRepository $mediaRepo, $media_id ): ResponseInterface { @@ -28,9 +28,9 @@ class GetWaveformAction $media_id = explode('-', $media_id)[0]; if (StationMedia::UNIQUE_ID_LENGTH === strlen($media_id)) { - $waveformPath = Filesystem::PREFIX_WAVEFORMS . '://' . $media_id . '.json'; - if ($fs->has($waveformPath)) { - return $response->withFlysystemFile($fs, $waveformPath, null, 'inline'); + $waveformUri = StationMedia::getWaveformUri($media_id); + if ($fs->has($waveformUri)) { + return $fs->streamToResponse($response, $waveformUri, null, 'inline'); } } @@ -39,12 +39,11 @@ class GetWaveformAction return $response->withStatus(500)->withJson(new Error(500, 'Media not found.')); } - $waveformPath = $media->getWaveformPath(); - - if (!$fs->has($waveformPath)) { + $waveformUri = StationMedia::getWaveformUri($media->getUniqueId()); + if (!$fs->has($waveformUri)) { $mediaRepo->updateWaveform($media); } - return $response->withFlysystemFile($fs, $waveformPath, null, 'inline'); + return $fs->streamToResponse($response, $waveformUri, null, 'inline'); } } diff --git a/src/Controller/Stations/FilesController.php b/src/Controller/Stations/FilesController.php index ae4e20a59..a5063b025 100644 --- a/src/Controller/Stations/FilesController.php +++ b/src/Controller/Stations/FilesController.php @@ -27,8 +27,8 @@ class FilesController ->getArrayResult(); $files_count = $em->createQuery(/** @lang DQL */ 'SELECT COUNT(sm.id) FROM App\Entity\StationMedia sm - WHERE sm.station_id = :station_id') - ->setParameter('station_id', $station->getId()) + WHERE sm.storage_location = :storageLocation') + ->setParameter('storageLocation', $station->getMediaStorageLocation()) ->getSingleScalarResult(); // Get list of custom fields. @@ -45,13 +45,15 @@ class FilesController ]; } + $mediaStorage = $station->getMediaStorageLocation(); + return $request->getView()->renderToResponse($response, 'stations/files/index', [ - 'show_sftp' => SftpGo::isSupported(), + 'show_sftp' => SftpGo::isSupportedForStation($station), 'playlists' => $playlists, 'custom_fields' => $custom_fields, - 'space_used' => $station->getStorageUsed(), - 'space_total' => $station->getStorageAvailable(), - 'space_percent' => $station->getStorageUsePercentage(), + 'space_used' => $mediaStorage->getStorageUsed(), + 'space_total' => $mediaStorage->getStorageAvailable(), + 'space_percent' => $mediaStorage->getStorageUsePercentage(), 'files_count' => $files_count, ]); } diff --git a/src/Controller/Stations/ProfileController.php b/src/Controller/Stations/ProfileController.php index 76dc22f2f..efcfa31da 100644 --- a/src/Controller/Stations/ProfileController.php +++ b/src/Controller/Stations/ProfileController.php @@ -44,7 +44,7 @@ class ProfileController LEFT JOIN sm.playlists spm LEFT JOIN spm.playlist sp WHERE sp.id IS NOT NULL - AND sm.station_id = :station_id') + AND sp.station_id = :station_id') ->setParameter('station_id', $station->getId()) ->getSingleScalarResult(); diff --git a/src/Controller/Stations/Reports/DuplicatesController.php b/src/Controller/Stations/Reports/DuplicatesController.php index 2584ceefd..86490e167 100644 --- a/src/Controller/Stations/Reports/DuplicatesController.php +++ b/src/Controller/Stations/Reports/DuplicatesController.php @@ -3,7 +3,7 @@ namespace App\Controller\Stations\Reports; use App\Entity; -use App\Flysystem\Filesystem; +use App\Flysystem\FilesystemManager; use App\Http\Response; use App\Http\ServerRequest; use App\Session\Flash; @@ -16,12 +16,12 @@ class DuplicatesController protected Entity\Repository\StationMediaRepository $mediaRepo; - protected Filesystem $filesystem; + protected FilesystemManager $filesystem; public function __construct( EntityManagerInterface $em, Entity\Repository\StationMediaRepository $mediaRepo, - Filesystem $filesystem + FilesystemManager $filesystem ) { $this->em = $em; $this->mediaRepo = $mediaRepo; @@ -37,15 +37,17 @@ class DuplicatesController FROM App\Entity\StationMedia sm LEFT JOIN sm.playlists spm LEFT JOIN spm.playlist sp - WHERE sm.station = :station + WHERE sm.storage_location = :storageLocation + AND (sp.id IS NULL OR sp.station = :station) AND sm.song_id IN ( SELECT sm2.song_id FROM App\Entity\StationMedia sm2 - WHERE sm2.station = :station + WHERE sm2.storage_location = :storageLocation GROUP BY sm2.song_id HAVING COUNT(sm2.id) > 1 ) ORDER BY sm.song_id ASC, sm.mtime ASC') + ->setParameteR('storageLocation', $station->getMediaStorageLocation()) ->setParameter('station', $station) ->getArrayResult(); diff --git a/src/Controller/Stations/SftpUsersController.php b/src/Controller/Stations/SftpUsersController.php index c4d60d951..0989e73f1 100644 --- a/src/Controller/Stations/SftpUsersController.php +++ b/src/Controller/Stations/SftpUsersController.php @@ -25,12 +25,12 @@ class SftpUsersController extends AbstractStationCrudController public function indexAction(ServerRequest $request, Response $response): ResponseInterface { - if (!SftpGo::isSupported()) { - throw new StationUnsupportedException(__('This feature is not currently supported on this station.')); - } - $station = $request->getStation(); + if (!SftpGo::isSupportedForStation($station)) { + throw new StationUnsupportedException(__('This feature is not currently supported on this station.')); + } + $baseUrl = $request->getRouter()->getBaseUrl(false) ->withScheme('sftp') ->withPort(null); diff --git a/src/Doctrine/Repository.php b/src/Doctrine/Repository.php index 6ee4fdb72..0f9d7084c 100644 --- a/src/Doctrine/Repository.php +++ b/src/Doctrine/Repository.php @@ -91,7 +91,7 @@ class Repository // Specify custom text in the $add_blank parameter to override. if ($add_blank !== false) { - $select[''] = ($add_blank === true) ? 'Select...' : $add_blank; + $select[''] = ($add_blank === true) ? __('Select...') : $add_blank; } // Build query for records. diff --git a/src/Entity/Api/Admin/StorageLocation.php b/src/Entity/Api/Admin/StorageLocation.php new file mode 100644 index 000000000..a66083be4 --- /dev/null +++ b/src/Entity/Api/Admin/StorageLocation.php @@ -0,0 +1,33 @@ +title = (string)$song->getTitle(); if ($song instanceof Entity\StationMedia) { - $station = $song->getStation(); - $response->album = (string)$song->getAlbum(); $response->genre = (string)$song->getGenre(); $response->lyrics = (string)$song->getLyrics(); @@ -65,12 +63,12 @@ class SongApiGenerator protected function getAlbumArtUrl( - Entity\Station $station, + ?Entity\Station $station = null, string $mediaUniqueId, int $mediaUpdatedTimestamp, ?UriInterface $baseUri = null ): UriInterface { - if (0 === $mediaUpdatedTimestamp) { + if (null === $station || 0 === $mediaUpdatedTimestamp) { return $this->getDefaultAlbumArtUrl($station, $baseUri); } diff --git a/src/Entity/ApiGenerator/SongHistoryApiGenerator.php b/src/Entity/ApiGenerator/SongHistoryApiGenerator.php index 526f4896a..f1e372b8b 100644 --- a/src/Entity/ApiGenerator/SongHistoryApiGenerator.php +++ b/src/Entity/ApiGenerator/SongHistoryApiGenerator.php @@ -8,6 +8,7 @@ use Psr\Http\Message\UriInterface; class SongHistoryApiGenerator { protected SongApiGenerator $songApiGenerator; + public function __construct(SongApiGenerator $songApiGenerator) { $this->songApiGenerator = $songApiGenerator; @@ -35,7 +36,7 @@ class SongHistoryApiGenerator } if (null !== $record->getMedia()) { - $response->song = ($this->songApiGenerator)($record->getMedia(), null, $baseUri); + $response->song = ($this->songApiGenerator)($record->getMedia(), $record->getStation(), $baseUri); } else { $response->song = ($this->songApiGenerator)($record, $record->getStation(), $baseUri); } diff --git a/src/Entity/ApiGenerator/StationQueueApiGenerator.php b/src/Entity/ApiGenerator/StationQueueApiGenerator.php index 1d65b3d55..4a57d9b83 100644 --- a/src/Entity/ApiGenerator/StationQueueApiGenerator.php +++ b/src/Entity/ApiGenerator/StationQueueApiGenerator.php @@ -8,6 +8,7 @@ use Psr\Http\Message\UriInterface; class StationQueueApiGenerator { protected SongApiGenerator $songApiGenerator; + public function __construct(SongApiGenerator $songApiGenerator) { $this->songApiGenerator = $songApiGenerator; @@ -26,7 +27,7 @@ class StationQueueApiGenerator } if ($record->getMedia()) { - $response->song = ($this->songApiGenerator)($record->getMedia(), null, $baseUri); + $response->song = ($this->songApiGenerator)($record->getMedia(), $record->getStation(), $baseUri); } else { $response->song = ($this->songApiGenerator)($record, $record->getStation(), $baseUri); } diff --git a/src/Entity/Fixture/Station.php b/src/Entity/Fixture/Station.php index ff9f37276..2d87a100e 100644 --- a/src/Entity/Fixture/Station.php +++ b/src/Entity/Fixture/Station.php @@ -20,27 +20,21 @@ class Station extends AbstractFixture $station->setBackendType(Adapters::BACKEND_LIQUIDSOAP); $station->setRadioBaseDir('/var/azuracast/stations/azuratest_radio'); - // Ensure all directories exist. - $radio_dirs = [ - $station->getRadioBaseDir(), - $station->getRadioMediaDir(), - $station->getRadioAlbumArtDir(), - $station->getRadioPlaylistsDir(), - $station->getRadioConfigDir(), - $station->getRadioTempDir(), - ]; - foreach ($radio_dirs as $radio_dir) { - if (!file_exists($radio_dir) && !mkdir($radio_dir, 0777) && !is_dir($radio_dir)) { - throw new RuntimeException(sprintf('Directory "%s" was not created', $radio_dir)); - } - } + $station->ensureDirectoriesExist(); - $station_quota = getenv('INIT_STATION_QUOTA'); - if (!empty($station_quota)) { - $station->setStorageQuota($station_quota); + $mediaStorage = $station->getMediaStorageLocation(); + $recordingsStorage = $station->getRecordingsStorageLocation(); + + $stationQuota = getenv('INIT_STATION_QUOTA'); + if (!empty($stationQuota)) { + $mediaStorage->setStorageQuota($stationQuota); + $recordingsStorage->setStorageQuota($stationQuota); } $em->persist($station); + $em->persist($mediaStorage); + $em->persist($recordingsStorage); + $em->flush(); $this->addReference('station', $station); diff --git a/src/Entity/Fixture/StationMedia.php b/src/Entity/Fixture/StationMedia.php index 3958cc400..f846792a8 100644 --- a/src/Entity/Fixture/StationMedia.php +++ b/src/Entity/Fixture/StationMedia.php @@ -19,39 +19,40 @@ class StationMedia extends AbstractFixture implements DependentFixtureInterface public function load(ObjectManager $em): void { - $music_skeleton_dir = getenv('INIT_MUSIC_PATH'); + $musicSkeletonDir = getenv('INIT_MUSIC_PATH'); - if (empty($music_skeleton_dir) || !is_dir($music_skeleton_dir)) { + if (empty($musicSkeletonDir) || !is_dir($musicSkeletonDir)) { return; } /** @var Entity\Station $station */ $station = $this->getReference('station'); - $station_media_dir = $station->getRadioMediaDir(); + $mediaStorage = $station->getMediaStorageLocation(); + $fs = $mediaStorage->getFilesystem(); /** @var Entity\StationPlaylist $playlist */ $playlist = $this->getReference('station_playlist'); $finder = (new Finder()) ->files() - ->in($music_skeleton_dir) + ->in($musicSkeletonDir) ->name('/^.+\.(mp3|aac|ogg|flac)$/i'); foreach ($finder as $file) { - $file_path = $file->getPathname(); - $file_base_name = basename($file_path); + $filePath = $file->getPathname(); + $fileBaseName = basename($filePath); // Copy the file to the station media directory. - copy($file_path, $station_media_dir . '/' . $file_base_name); + $fs->copyFromLocal($filePath, '/' . $fileBaseName); - $media_row = $this->mediaRepo->getOrCreate($station, $file_base_name); - $em->persist($media_row); + $mediaRow = $this->mediaRepo->getOrCreate($mediaStorage, $fileBaseName); + $em->persist($mediaRow); // Add the file to the playlist. - $spm_row = new Entity\StationPlaylistMedia($playlist, $media_row); - $spm_row->setWeight(1); - $em->persist($spm_row); + $spmRow = new Entity\StationPlaylistMedia($playlist, $mediaRow); + $spmRow->setWeight(1); + $em->persist($spmRow); } $em->flush(); diff --git a/src/Entity/Migration/Version20201027130404.php b/src/Entity/Migration/Version20201027130404.php new file mode 100644 index 000000000..6c9ea089d --- /dev/null +++ b/src/Entity/Migration/Version20201027130404.php @@ -0,0 +1,184 @@ +addSql('CREATE TABLE storage_location (id INT AUTO_INCREMENT NOT NULL, type VARCHAR(50) NOT NULL, adapter VARCHAR(50) NOT NULL, path VARCHAR(255) DEFAULT NULL, s3_credential_key VARCHAR(255) DEFAULT NULL, s3_credential_secret VARCHAR(255) DEFAULT NULL, s3_region VARCHAR(150) DEFAULT NULL, s3_version VARCHAR(150) DEFAULT NULL, s3_bucket VARCHAR(255) DEFAULT NULL, s3_endpoint VARCHAR(255) DEFAULT NULL, storage_quota BIGINT DEFAULT NULL, storage_used BIGINT DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_general_ci` ENGINE = InnoDB'); + + $this->addSql('ALTER TABLE station ADD media_storage_location_id INT DEFAULT NULL, ADD recordings_storage_location_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE station ADD CONSTRAINT FK_9F39F8B1C896ABC5 FOREIGN KEY (media_storage_location_id) REFERENCES storage_location (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE station ADD CONSTRAINT FK_9F39F8B15C7361BE FOREIGN KEY (recordings_storage_location_id) REFERENCES storage_location (id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_9F39F8B1C896ABC5 ON station (media_storage_location_id)'); + $this->addSql('CREATE INDEX IDX_9F39F8B15C7361BE ON station (recordings_storage_location_id)'); + + $this->addSql('ALTER TABLE station_media DROP FOREIGN KEY FK_32AADE3A21BDB235'); + $this->addSql('DROP INDEX IDX_32AADE3A21BDB235 ON station_media'); + $this->addSql('DROP INDEX path_unique_idx ON station_media'); + $this->addSql('ALTER TABLE station_media ADD storage_location_id INT NOT NULL'); + } + + public function postUp(Schema $schema): void + { + // Create initial backup directory. + $this->connection->insert('storage_location', [ + 'type' => 'backup', + 'adapter' => 'local', + 'path' => '/var/azuracast/backup', + ]); + + $storageLocationId = $this->connection->lastInsertId('storage_location'); + $this->connection->update('settings', [ + 'setting_value' => $storageLocationId, + ], [ + 'setting_key' => 'backup_storage_location', + ]); + + // Migrate existing directories to new StorageLocation paradigm. + $stations = $this->connection->fetchAll('SELECT id, radio_base_dir, radio_media_dir, storage_quota FROM station ORDER BY id ASC'); + + $directories = []; + + foreach ($stations as $row) { + $stationId = $row['id']; + + $baseDir = $row['radio_base_dir']; + $mediaDir = $row['radio_media_dir']; + if (empty($mediaDir)) { + $mediaDir = $baseDir . '/media'; + } + + if (isset($directories[$mediaDir])) { + $directories[$mediaDir]['stations'][] = $stationId; + } else { + $directories[$mediaDir] = [ + 'stations' => [$stationId], + 'storageQuota' => $row['storage_quota'], + 'albumArtDir' => $baseDir . '/album_art', + 'waveformsDir' => $baseDir . '/waveforms', + ]; + } + + // Create recordings dir. + $this->connection->insert('storage_location', [ + 'type' => 'station_recordings', + 'adapter' => 'local', + 'path' => $baseDir . '/recordings', + 'storage_quota' => $row['storage_quota'], + ]); + + $recordingsStorageLocationId = $this->connection->lastInsertId('storage_location'); + + $this->connection->update('station', [ + 'recordings_storage_location_id' => $recordingsStorageLocationId, + ], [ + 'id' => $stationId, + ]); + } + + foreach ($directories as $path => $dirInfo) { + $newAlbumArtDir = $path . '/.albumart'; + rename($dirInfo['albumArtDir'], $newAlbumArtDir); + + $newWaveformsDir = $path . '/.waveforms'; + rename($dirInfo['waveformsDir'], $newWaveformsDir); + + $this->connection->insert('storage_location', [ + 'type' => 'station_media', + 'adapter' => 'local', + 'path' => $path, + 'storage_quota' => $dirInfo['storageQuota'], + ]); + + $mediaStorageLocationId = $this->connection->lastInsertId('storage_location'); + + foreach ($dirInfo['stations'] as $stationId) { + $this->connection->update('station', [ + 'media_storage_location_id' => $mediaStorageLocationId, + ], [ + 'id' => $stationId, + ]); + } + + $firstStationId = array_shift($dirInfo['stations']); + + $this->connection->executeQuery( + 'UPDATE station_media SET storage_location_id=? WHERE station_id = ?', + [ + $mediaStorageLocationId, + $firstStationId, + ], + [ + ParameterType::INTEGER, + ParameterType::INTEGER, + ] + ); + + foreach ($dirInfo['stations'] as $stationId) { + $media = $this->connection->fetchAllAssociative( + 'SELECT sm1.id AS old_id, sm2.id AS new_id FROM station_media AS sm1 INNER JOIN station_media AS sm2 ON sm1.path = sm2.path WHERE sm2.storage_location_id = ? AND sm1.station_id = ?', + [ + $mediaStorageLocationId, + $stationId, + ], + [ + ParameterType::INTEGER, + ParameterType::INTEGER, + ] + ); + + $tablesToUpdate = ['song_history', 'station_playlist_media', 'station_queue', 'station_requests']; + + foreach ($media as [$oldMediaId, $newMediaId]) { + foreach ($tablesToUpdate as $table) { + $this->connection->update($table, [ + 'media_id' => $newMediaId, + ], [ + 'media_id' => $oldMediaId, + ]); + } + } + + $this->connection->executeQuery('DELETE FROM station_media WHRE station_id = ?', [ + $stationId, + ], [ + ParameterType::INTEGER, + ]); + } + } + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE station DROP FOREIGN KEY FK_9F39F8B1C896ABC5'); + $this->addSql('ALTER TABLE station DROP FOREIGN KEY FK_9F39F8B15C7361BE'); + $this->addSql('DROP INDEX IDX_9F39F8B1C896ABC5 ON station'); + $this->addSql('DROP INDEX IDX_9F39F8B15C7361BE ON station'); + + $this->addSql('DROP TABLE storage_location'); + + $this->addSql('ALTER TABLE station DROP media_storage_location_id, DROP recordings_storage_location_id'); + $this->addSql('DROP INDEX IDX_32AADE3ACDDD8AF ON station_media'); + $this->addSql('DROP INDEX path_unique_idx ON station_media'); + $this->addSql('ALTER TABLE station_media DROP storage_location_id'); + $this->addSql('ALTER TABLE station_media CHANGE storage_location_id station_id INT NOT NULL'); + $this->addSql('ALTER TABLE station_media ADD CONSTRAINT FK_32AADE3A21BDB235 FOREIGN KEY (station_id) REFERENCES station (id) ON DELETE CASCADE'); + $this->addSql('CREATE INDEX IDX_32AADE3A21BDB235 ON station_media (station_id)'); + $this->addSql('CREATE UNIQUE INDEX path_unique_idx ON station_media (path, station_id)'); + } +} diff --git a/src/Entity/Migration/Version20201027130504.php b/src/Entity/Migration/Version20201027130504.php new file mode 100644 index 000000000..7d7f34623 --- /dev/null +++ b/src/Entity/Migration/Version20201027130504.php @@ -0,0 +1,38 @@ +addSql('ALTER TABLE station_media ADD CONSTRAINT FK_32AADE3ACDDD8AF FOREIGN KEY (storage_location_id) REFERENCES storage_location (id) ON DELETE CASCADE'); + $this->addSql('CREATE INDEX IDX_32AADE3ACDDD8AF ON station_media (storage_location_id)'); + $this->addSql('CREATE UNIQUE INDEX path_unique_idx ON station_media (path, storage_location_id)'); + + $this->addSql('ALTER TABLE station DROP radio_media_dir, DROP storage_quota, DROP storage_used'); + + $this->addSql('ALTER TABLE station_media DROP station_id'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE station ADD radio_media_dir VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_general_ci`, ADD storage_quota BIGINT DEFAULT NULL, ADD storage_used BIGINT DEFAULT NULL'); + + $this->addSql('ALTER TABLE station_media ADD station_id INT NOT NULL'); + + $this->addSql('ALTER TABLE station DROP FOREIGN KEY FK_32AADE3ACDDD8AF'); + $this->addSql('DROP INDEX IDX_32AADE3ACDDD8AF ON station_media'); + $this->addSql('DROP INDEX path_unique_idx ON station_media'); + } +} diff --git a/src/Entity/Repository/StationMediaRepository.php b/src/Entity/Repository/StationMediaRepository.php index 09bdd2e9e..962399b9e 100644 --- a/src/Entity/Repository/StationMediaRepository.php +++ b/src/Entity/Repository/StationMediaRepository.php @@ -6,14 +6,12 @@ use App\Doctrine\Repository; use App\Entity; use App\Entity\StationPlaylist; use App\Exception\MediaProcessingException; -use App\Flysystem\Filesystem; use App\Media\AlbumArt; use App\Media\MetadataManagerInterface; use App\Service\AudioWaveform; use App\Settings; use Doctrine\ORM\EntityManagerInterface; use Exception; -use getid3_exception; use InvalidArgumentException; use Psr\Log\LoggerInterface; use Symfony\Component\Serializer\Serializer; @@ -24,12 +22,12 @@ use const JSON_UNESCAPED_SLASHES; class StationMediaRepository extends Repository { - protected Filesystem $filesystem; - protected CustomFieldRepository $customFieldRepo; protected StationPlaylistMediaRepository $spmRepo; + protected StorageLocationRepository $storageLocationRepo; + protected MetadataManagerInterface $metadataManager; public function __construct( @@ -37,72 +35,92 @@ class StationMediaRepository extends Repository Serializer $serializer, Settings $settings, LoggerInterface $logger, - Filesystem $filesystem, MetadataManagerInterface $metadataManager, CustomFieldRepository $customFieldRepo, - StationPlaylistMediaRepository $spmRepo + StationPlaylistMediaRepository $spmRepo, + StorageLocationRepository $storageLocationRepo ) { parent::__construct($em, $serializer, $settings, $logger); - $this->filesystem = $filesystem; - $this->metadataManager = $metadataManager; - $this->customFieldRepo = $customFieldRepo; $this->spmRepo = $spmRepo; + $this->storageLocationRepo = $storageLocationRepo; + + $this->metadataManager = $metadataManager; } /** * @param mixed $id - * @param Entity\Station $station + * @param Entity\Station|Entity\StorageLocation $source */ - public function find($id, Entity\Station $station): ?Entity\StationMedia + public function find($id, $source): ?Entity\StationMedia { if (Entity\StationMedia::UNIQUE_ID_LENGTH === strlen($id)) { - $media = $this->findByUniqueId($id, $station); + $media = $this->findByUniqueId($id, $source); if ($media instanceof Entity\StationMedia) { return $media; } } + $storageLocation = $this->getStorageLocation($source); return $this->repository->findOneBy([ - 'station' => $station, + 'storage_location' => $storageLocation, 'id' => $id, ]); } /** * @param string $path - * @param Entity\Station $station + * @param Entity\Station|Entity\StorageLocation $source */ - public function findByPath(string $path, Entity\Station $station): ?Entity\StationMedia + public function findByPath(string $path, $source): ?Entity\StationMedia { + $storageLocation = $this->getStorageLocation($source); return $this->repository->findOneBy([ - 'station' => $station, + 'storage_location' => $storageLocation, 'path' => $path, ]); } /** * @param string $uniqueId - * @param Entity\Station $station + * @param Entity\Station|Entity\StorageLocation $source */ - public function findByUniqueId(string $uniqueId, Entity\Station $station): ?Entity\StationMedia + public function findByUniqueId(string $uniqueId, $source): ?Entity\StationMedia { + $storageLocation = $this->getStorageLocation($source); + return $this->repository->findOneBy([ - 'station' => $station, + 'storage_location' => $storageLocation, 'unique_id' => $uniqueId, ]); } /** - * @param Entity\Station $station + * @param Entity\Station|Entity\StorageLocation $source + * + */ + protected function getStorageLocation($source): Entity\StorageLocation + { + if ($source instanceof Entity\StorageLocation) { + return $source; + } + if ($source instanceof Entity\Station) { + return $source->getMediaStorageLocation(); + } + + throw new InvalidArgumentException('Parameter must be a station or storage location.'); + } + + /** + * @param Entity\Station|Entity\StorageLocation $source * @param string $path * @param string|null $uploadedFrom The original uploaded path (if this is a new upload). * * @throws Exception */ public function getOrCreate( - Entity\Station $station, + $source, string $path, ?string $uploadedFrom = null ): Entity\StationMedia { @@ -110,14 +128,13 @@ class StationMediaRepository extends Repository [, $path] = explode('://', $path, 2); } - $record = $this->repository->findOneBy([ - 'station_id' => $station->getId(), - 'path' => $path, - ]); + $record = $this->findByPath($path, $source); $created = false; if (!($record instanceof Entity\StationMedia)) { - $record = new Entity\StationMedia($station, $path); + $storageLocation = $this->getStorageLocation($source); + + $record = new Entity\StationMedia($storageLocation, $path); $created = true; } @@ -144,45 +161,34 @@ class StationMediaRepository extends Repository bool $force = false, ?string $uploadedPath = null ): bool { - $fs = $this->filesystem->getForStation($media->getStation(), false); - - $tmp_uri = null; - $media_uri = $media->getPathUri(); + $fs = $media->getStorageLocation()->getFilesystem(); + $mediaUri = $media->getPath(); if (null !== $uploadedPath) { - $tmp_path = $uploadedPath; + $this->loadFromFile($media, $uploadedPath); + $this->writeWaveform($media, $uploadedPath); - $media_mtime = time(); + $fs->putFromLocal($uploadedPath, $mediaUri); + $mediaMtime = time(); } else { - if (!$fs->has($media_uri)) { - throw new MediaProcessingException(sprintf('Media path "%s" not found.', $media_uri)); + if (!$fs->has($mediaUri)) { + throw new MediaProcessingException(sprintf('Media path "%s" not found.', $mediaUri)); } - $media_mtime = (int)$fs->getTimestamp($media_uri); + $mediaMtime = (int)$fs->getTimestamp($mediaUri); // No need to update if all of these conditions are true. - if (!$force && !$media->needsReprocessing($media_mtime)) { + if (!$force && !$media->needsReprocessing($mediaMtime)) { return false; } - try { - $tmp_path = $fs->getFullPath($media_uri); - } catch (InvalidArgumentException $e) { - $tmp_uri = $fs->copyToTemp($media_uri); - $tmp_path = $fs->getFullPath($tmp_uri); - } + $fs->withLocalFile($mediaUri, function ($path) use ($media): void { + $this->loadFromFile($media, $path); + $this->writeWaveform($media, $path); + }); } - $this->loadFromFile($media, $tmp_path); - $this->writeWaveform($media, $tmp_path); - - if (null !== $uploadedPath) { - $fs->upload($uploadedPath, $media_uri); - } elseif (null !== $tmp_uri) { - $fs->delete($tmp_uri); - } - - $media->setMtime($media_mtime); + $media->setMtime($mediaMtime); $this->em->persist($media); return true; @@ -250,35 +256,24 @@ class StationMediaRepository extends Repository $media->updateSongId(); } - /** - * Read the contents of the album art from storage (if it exists). - * - * @param Entity\StationMedia $media - */ public function readAlbumArt(Entity\StationMedia $media): ?string { - $album_art_path = $media->getArtPath(); - $fs = $this->filesystem->getForStation($media->getStation()); + $fs = $media->getStorageLocation()->getFilesystem(); + $albumArtPath = Entity\StationMedia::getArtPath($media->getUniqueId()); - if (!$fs->has($album_art_path)) { + if (!$fs->has($albumArtPath)) { return null; } - return $fs->read($album_art_path); + return $fs->read($albumArtPath); } - /** - * Crop album art and write the resulting image to storage. - * - * @param Entity\StationMedia $media - * @param string $rawArtString The raw image data, as would be retrieved from file_get_contents. - */ - public function writeAlbumArt(Entity\StationMedia $media, $rawArtString): bool + public function writeAlbumArt(Entity\StationMedia $media, string $rawArtString): bool { $albumArt = AlbumArt::resize($rawArtString); - $fs = $this->filesystem->getForStation($media->getStation()); - $albumArtPath = $media->getArtPath(); + $fs = $media->getStorageLocation()->getFilesystem(); + $albumArtPath = Entity\StationMedia::getArtPath($media->getUniqueId()); $media->setArtUpdatedAt(time()); $this->em->persist($media); @@ -288,9 +283,8 @@ class StationMediaRepository extends Repository public function removeAlbumArt(Entity\StationMedia $media): void { - // Remove the album art, if it exists. - $fs = $this->filesystem->getForStation($media->getStation()); - $currentAlbumArtPath = $media->getArtPath(); + $fs = $media->getStorageLocation()->getFilesystem(); + $currentAlbumArtPath = Entity\StationMedia::getArtPath($media->getUniqueId()); $fs->delete($currentAlbumArtPath); @@ -299,74 +293,42 @@ class StationMediaRepository extends Repository $this->em->flush(); } - /** - * Write modified metadata directly to the file as ID3 information. - * - * @param Entity\StationMedia $media - * - * @throws getid3_exception - */ public function writeToFile(Entity\StationMedia $media): bool { - $fs = $this->filesystem->getForStation($media->getStation()); - - $media_uri = $media->getPathUri(); - $tmp_uri = null; - - try { - $tmp_path = $fs->getFullPath($media_uri); - } catch (InvalidArgumentException $e) { - $tmp_uri = $fs->copyToTemp($media_uri); - $tmp_path = $fs->getFullPath($tmp_uri); - } + $fs = $media->getStorageLocation()->getFilesystem(); $metadata = $media->toMetadata(); - $art_path = $media->getArtPath(); + $art_path = Entity\StationMedia::getArtPath($media->getUniqueId()); if ($fs->has($art_path)) { $metadata->setArtwork($fs->read($art_path)); } - // write tags - if ($this->metadataManager->writeMetadata($metadata, $tmp_path)) { - $media->setMtime(time() + 5); - - if (null !== $tmp_uri) { - $fs->updateFromTemp($tmp_uri, $media_uri); + // Write tags to the Media file. + return $fs->withLocalFile($media->getPath(), function ($path) use ($media, $metadata) { + if ($this->metadataManager->writeMetadata($metadata, $path)) { + $media->setMtime(time() + 5); + return true; } - return true; - } - - return false; + return false; + }); } public function updateWaveform(Entity\StationMedia $media): void { - $fs = $this->filesystem->getForStation($media->getStation()); - - $mediaUri = $media->getPathUri(); - $tmpUri = null; - try { - $tmpPath = $fs->getFullPath($mediaUri); - } catch (InvalidArgumentException $e) { - $tmpUri = $fs->copyToTemp($mediaUri); - $tmpPath = $fs->getFullPath($tmpUri); - } - - $this->writeWaveform($media, $tmpPath); - - if (null !== $tmpUri) { - $fs->delete($tmpUri); - } + $fs = $media->getStorageLocation()->getFilesystem(); + $fs->withLocalFile($media->getPathUri(), function ($path) use ($media): void { + $this->writeWaveform($media, $path); + }); } public function writeWaveform(Entity\StationMedia $media, string $path): bool { $waveform = AudioWaveform::getWaveformFor($path); - $waveformPath = $media->getWaveformPath(); + $waveformPath = Entity\StationMedia::getWaveformPath($media->getUniqueId()); - $fs = $this->filesystem->getForStation($media->getStation()); + $fs = $media->getStorageLocation()->getFilesystem(); return $fs->put( $waveformPath, json_encode($waveform, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) @@ -380,8 +342,7 @@ class StationMediaRepository extends Repository */ public function getFullPath(Entity\StationMedia $media): string { - $fs = $this->filesystem->getForStation($media->getStation()); - + $fs = $media->getStorageLocation()->getFilesystem(); $uri = $media->getPathUri(); return $fs->getFullPath($uri); @@ -394,7 +355,7 @@ class StationMediaRepository extends Repository */ public function remove(Entity\StationMedia $media): array { - $fs = $this->filesystem->getForStation($media->getStation()); + $fs = $media->getStorageLocation()->getFilesystem(); // Clear related media. foreach ($media->getRelatedFilePaths() as $relatedFilePath) { diff --git a/src/Entity/Repository/StationRepository.php b/src/Entity/Repository/StationRepository.php index a6b2a8af3..afacbad8d 100644 --- a/src/Entity/Repository/StationRepository.php +++ b/src/Entity/Repository/StationRepository.php @@ -22,7 +22,7 @@ use Symfony\Component\Validator\Validator\ValidatorInterface; class StationRepository extends Repository { - protected Media $media_sync; + protected Media $mediaSync; protected Adapters $adapters; @@ -34,24 +34,28 @@ class StationRepository extends Repository protected SettingsRepository $settingsRepo; + protected StorageLocationRepository $storageLocationRepo; + public function __construct( EntityManagerInterface $em, Serializer $serializer, Settings $settings, SettingsRepository $settingsRepo, + StorageLocationRepository $storageLocationRepo, LoggerInterface $logger, - Media $media_sync, + Media $mediaSync, Adapters $adapters, Configuration $configuration, ValidatorInterface $validator, CacheInterface $cache ) { - $this->media_sync = $media_sync; + $this->mediaSync = $mediaSync; $this->adapters = $adapters; $this->configuration = $configuration; $this->validator = $validator; $this->cache = $cache; $this->settingsRepo = $settingsRepo; + $this->storageLocationRepo = $storageLocationRepo; parent::__construct($em, $serializer, $settings, $logger); } @@ -114,30 +118,49 @@ class StationRepository extends Repository } /** - * @param Entity\Station $record + * @param Entity\Station $station */ - public function edit(Entity\Station $record): Entity\Station + public function edit(Entity\Station $station): Entity\Station { - $original_record = $this->em->getUnitOfWork()->getOriginalEntityData($record); + // Create path for station. + $station->ensureDirectoriesExist(); + + $this->em->persist($station); + $this->em->persist($station->getMediaStorageLocation()); + $this->em->persist($station->getRecordingsStorageLocation()); + + $original_record = $this->em->getUnitOfWork()->getOriginalEntityData($station); + + // Generate station ID. + $this->em->flush(); + + // Delete media-related items if the media storage is changed. + /** @var Entity\StorageLocation|null $oldMediaStorage */ + $oldMediaStorage = $original_record['media_storage_location']; + $newMediaStorage = $station->getMediaStorageLocation(); + + if (null === $oldMediaStorage || $oldMediaStorage->getId() !== $newMediaStorage->getId()) { + $this->flushRelatedMedia($station); + } // Get the original values to check for changes. $old_frontend = $original_record['frontend_type']; $old_backend = $original_record['backend_type']; - $frontend_changed = ($old_frontend !== $record->getFrontendType()); - $backend_changed = ($old_backend !== $record->getBackendType()); + $frontend_changed = ($old_frontend !== $station->getFrontendType()); + $backend_changed = ($old_backend !== $station->getBackendType()); $adapter_changed = $frontend_changed || $backend_changed; if ($frontend_changed) { - $frontend = $this->adapters->getFrontendAdapter($record); - $this->resetMounts($record, $frontend); + $frontend = $this->adapters->getFrontendAdapter($station); + $this->resetMounts($station, $frontend); } - $this->configuration->writeConfiguration($record, $adapter_changed); + $this->configuration->writeConfiguration($station, $adapter_changed); $this->cache->delete('stations'); - return $record; + return $station; } /** @@ -169,6 +192,29 @@ class StationRepository extends Repository $this->em->refresh($station); } + protected function flushRelatedMedia(Entity\Station $station): void + { + $this->em->createQuery(/** @lang DQL */ 'UPDATE App\Entity\SongHistory sh SET sh.media = null + WHERE sh.station = :station') + ->setParameter('station', $station) + ->execute(); + + $this->em->createQuery(/** @lang DQL */ 'DELETE FROM App\Entity\StationPlaylistMedia spm + WHERE spm.playlist_id IN (SELECT sp.id FROM App\Entity\StationPlaylist sp WHERE sp.station = :station)') + ->setParameter('station', $station) + ->execute(); + + $this->em->createQuery(/** @lang DQL */ 'DELETE FROM App\Entity\StationQueue sq + WHERE sq.station = :station') + ->setParameter('station', $station) + ->execute(); + + $this->em->createQuery(/** @lang DQL */ 'DELETE FROM App\Entity\StationRequest sr + WHERE sr.station = :station') + ->setParameter('station', $station) + ->execute(); + } + /** * Handle tasks necessary to a station's creation. * @@ -177,21 +223,23 @@ class StationRepository extends Repository public function create(Entity\Station $station): Entity\Station { // Create path for station. - $station->setRadioBaseDir(null); + $station->ensureDirectoriesExist(); $this->em->persist($station); + $this->em->persist($station->getMediaStorageLocation()); + $this->em->persist($station->getRecordingsStorageLocation()); // Generate station ID. $this->em->flush(); // Scan directory for any existing files. set_time_limit(600); - $this->media_sync->importMusic($station); + $this->mediaSync->importMusic($station->getMediaStorageLocation()); /** @var Entity\Station $station */ $station = $this->em->find(Entity\Station::class, $station->getId()); - $this->media_sync->importPlaylists($station); + $this->mediaSync->importPlaylists($station); /** @var Entity\Station $station */ $station = $this->em->find(Entity\Station::class, $station->getId()); @@ -232,6 +280,19 @@ class StationRepository extends Repository // Save changes and continue to the last setup step. $this->em->flush(); + + $storageLocations = [ + $station->getMediaStorageLocation(), + $station->getRecordingsStorageLocation(), + ]; + + foreach ($storageLocations as $storageLocation) { + $stations = $this->storageLocationRepo->getStationsUsingLocation($storageLocation); + if (1 === count($stations)) { + $this->em->remove($storageLocation); + } + } + $this->em->remove($station); $this->em->flush(); diff --git a/src/Entity/Repository/StationRequestRepository.php b/src/Entity/Repository/StationRequestRepository.php index 7ce61a045..6f5fd7d0e 100644 --- a/src/Entity/Repository/StationRequestRepository.php +++ b/src/Entity/Repository/StationRequestRepository.php @@ -6,12 +6,30 @@ use App\Doctrine\Repository; use App\Entity; use App\Exception; use App\Radio\AutoDJ; +use App\Settings; use App\Utilities; use Carbon\CarbonImmutable; use Carbon\CarbonInterface; +use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Serializer\Serializer; class StationRequestRepository extends Repository { + protected StationMediaRepository $mediaRepo; + + public function __construct( + EntityManagerInterface $em, + Serializer $serializer, + Settings $settings, + LoggerInterface $logger, + StationMediaRepository $mediaRepo + ) { + parent::__construct($em, $serializer, $settings, $logger); + + $this->mediaRepo = $mediaRepo; + } + public function submit( Entity\Station $station, string $trackId, @@ -29,8 +47,7 @@ class StationRequestRepository extends Repository } // Verify that Track ID exists with station. - $media_repo = $this->em->getRepository(Entity\StationMedia::class); - $media_item = $media_repo->findOneBy(['unique_id' => $trackId, 'station_id' => $station->getId()]); + $media_item = $this->mediaRepo->findByUniqueId($trackId, $station); if (!($media_item instanceof Entity\StationMedia)) { throw new Exception(__('The song ID you specified could not be found in the station.')); diff --git a/src/Entity/Repository/StationStreamerBroadcastRepository.php b/src/Entity/Repository/StationStreamerBroadcastRepository.php index afda31ac7..48e4698ea 100644 --- a/src/Entity/Repository/StationStreamerBroadcastRepository.php +++ b/src/Entity/Repository/StationStreamerBroadcastRepository.php @@ -25,4 +25,28 @@ class StationStreamerBroadcastRepository extends Repository ->getSingleResult(); return $latestBroadcast; } + + public function endAllActiveBroadcasts(Entity\Station $station): void + { + $this->em->createQuery(/** @lang DQL */ 'UPDATE App\Entity\StationStreamerBroadcast ssb + SET ssb.timestampEnd = :time + WHERE ssb.station = :station + AND ssb.timestampEnd = 0') + ->setParameter('time', time()) + ->setParameter('station', $station) + ->execute(); + } + + /** + * @param Entity\Station $station + * + * @return Entity\StationStreamerBroadcast[] + */ + public function getActiveBroadcasts(Entity\Station $station): array + { + return $this->repository->findBy([ + 'station' => $station, + 'timestampEnd' => 0, + ]); + } } diff --git a/src/Entity/Repository/StationStreamerRepository.php b/src/Entity/Repository/StationStreamerRepository.php index b90fe3303..1622908a6 100644 --- a/src/Entity/Repository/StationStreamerRepository.php +++ b/src/Entity/Repository/StationStreamerRepository.php @@ -4,6 +4,7 @@ namespace App\Entity\Repository; use App\Doctrine\Repository; use App\Entity; +use App\Flysystem\FilesystemManager; use App\Radio\Adapters; use App\Radio\AutoDJ\Scheduler; use App\Settings; @@ -15,16 +16,24 @@ class StationStreamerRepository extends Repository { protected Scheduler $scheduler; + protected StationStreamerBroadcastRepository $broadcastRepo; + + protected FilesystemManager $filesystem; + public function __construct( EntityManagerInterface $em, Serializer $serializer, Settings $settings, LoggerInterface $logger, - Scheduler $scheduler + Scheduler $scheduler, + StationStreamerBroadcastRepository $broadcastRepo, + FilesystemManager $filesystem ) { parent::__construct($em, $serializer, $settings, $logger); $this->scheduler = $scheduler; + $this->broadcastRepo = $broadcastRepo; + $this->filesystem = $filesystem; } /** @@ -61,7 +70,7 @@ class StationStreamerRepository extends Repository public function onConnect(Entity\Station $station, string $username = '') { // End all current streamer sessions. - $this->clearBroadcastsForStation($station); + $this->broadcastRepo->endAllActiveBroadcasts($station); $streamer = $this->getStreamer($station, $username); if (!($streamer instanceof Entity\StationStreamer)) { @@ -82,11 +91,11 @@ class StationStreamerRepository extends Repository if ($recordStreams) { $format = $backendConfig->getRecordStreamsFormat() ?? Entity\StationMountInterface::FORMAT_MP3; $recordingPath = $record->generateRecordingPath($format); - $this->em->persist($record); $this->em->flush(); - return $station->getRadioRecordingsDir() . '/' . $recordingPath; + $fs = $this->filesystem->getForStation($station); + return $fs->getFullPath(FilesystemManager::PREFIX_TEMP . '://' . $recordingPath); } } @@ -96,26 +105,30 @@ class StationStreamerRepository extends Repository public function onDisconnect(Entity\Station $station): bool { + $fs = $this->filesystem->getForStation($station); + $broadcasts = $this->broadcastRepo->getActiveBroadcasts($station); + + foreach ($broadcasts as $broadcast) { + $broadcastPath = $broadcast->getRecordingPath(); + + $tempPath = FilesystemManager::PREFIX_TEMP . '://' . $broadcastPath; + $destPath = FilesystemManager::PREFIX_RECORDINGS . '://' . $broadcastPath; + + if ($fs->has($tempPath)) { + $fs->copy($tempPath, $destPath); + } + + $broadcast->setTimestampEnd(time()); + $this->em->persist($broadcast); + } + $station->setIsStreamerLive(false); $station->setCurrentStreamer(null); $this->em->persist($station); $this->em->flush(); - - $this->clearBroadcastsForStation($station); return true; } - protected function clearBroadcastsForStation(Entity\Station $station): void - { - $this->em->createQuery(/** @lang DQL */ 'UPDATE App\Entity\StationStreamerBroadcast ssb - SET ssb.timestampEnd = :time - WHERE ssb.station = :station - AND ssb.timestampEnd = 0') - ->setParameter('time', time()) - ->setParameter('station', $station) - ->execute(); - } - protected function getStreamer(Entity\Station $station, string $username = ''): ?Entity\StationStreamer { /** @var Entity\StationStreamer|null $streamer */ diff --git a/src/Entity/Repository/StorageLocationRepository.php b/src/Entity/Repository/StorageLocationRepository.php new file mode 100644 index 000000000..75f1b39ff --- /dev/null +++ b/src/Entity/Repository/StorageLocationRepository.php @@ -0,0 +1,85 @@ +repository->findOneBy([ + 'type' => $type, + 'id' => $id, + ]); + } + + /** + * @param string $type + * + * @return Entity\StorageLocation[] + */ + public function findAllByType(string $type): array + { + return $this->repository->findBy([ + 'type' => $type, + ]); + } + + /** + * @param string $type + * @param bool $addBlank + * @param string|null $emptyString + * + * @return string[] + */ + public function fetchSelectByType( + string $type, + bool $addBlank = false, + ?string $emptyString = null + ): array { + $select = []; + + if ($addBlank) { + $emptyString ??= __('None'); + $select[''] = $emptyString; + } + + foreach ($this->findAllByType($type) as $storageLocation) { + $select[$storageLocation->getId()] = (string)$storageLocation; + } + + return $select; + } + + /** + * @param Entity\StorageLocation $storageLocation + * + * @return Entity\Station[] + */ + public function getStationsUsingLocation(Entity\StorageLocation $storageLocation): array + { + $qb = $this->em->createQueryBuilder() + ->select('s') + ->from(Entity\Station::class, 's'); + + switch ($storageLocation->getType()) { + case Entity\StorageLocation::TYPE_STATION_MEDIA: + $qb->where('s.media_storage_location = :storageLocation') + ->setParameter('storageLocation', $storageLocation); + break; + + case Entity\StorageLocation::TYPE_STATION_RECORDINGS: + $qb->where('s.recordings_storage_location = :storageLocation') + ->setParameter('storageLocation', $storageLocation); + break; + + case Entity\StorageLocation::TYPE_BACKUP: + default: + return []; + } + + return $qb->getQuery()->execute(); + } +} diff --git a/src/Entity/Settings.php b/src/Entity/Settings.php index 3ed436276..e30a88dbb 100644 --- a/src/Entity/Settings.php +++ b/src/Entity/Settings.php @@ -40,6 +40,7 @@ class Settings public const BACKUP_TIME = 'backup_time'; public const BACKUP_EXCLUDE_MEDIA = 'backup_exclude_media'; public const BACKUP_KEEP_COPIES = 'backup_keep_copies'; + public const BACKUP_STORAGE_LOCATION = 'backup_storage_location'; // Internal settings public const SETUP_COMPLETE = 'setup_complete'; diff --git a/src/Entity/Station.php b/src/Entity/Station.php index b2082202d..f08efe235 100644 --- a/src/Entity/Station.php +++ b/src/Entity/Station.php @@ -4,17 +4,18 @@ namespace App\Entity; use App\Annotations\AuditLog; use App\File; +use App\Normalizer\Annotation\DeepNormalize; use App\Radio\Adapters; -use App\Radio\Quota; use App\Settings; use App\Validator\Constraints as AppAssert; -use Brick\Math\BigInteger; use DateTimeZone; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use League\Flysystem\Adapter\Local; +use League\Flysystem\AdapterInterface; use OpenApi\Annotations as OA; -use RuntimeException; +use Symfony\Component\Serializer\Annotation as Serializer; use Symfony\Component\Validator\Constraints as Assert; /** @@ -152,14 +153,6 @@ class Station */ protected $radio_base_dir; - /** - * @ORM\Column(name="radio_media_dir", type="string", length=255, nullable=true) - * - * @OA\Property(example="/var/azuracast/stations/azuratest_radio/media") - * @var string|null - */ - protected $radio_media_dir; - /** * @ORM\Column(name="nowplaying", type="array", nullable=true) * @@ -287,36 +280,6 @@ class Station */ protected $api_history_items = self::DEFAULT_API_HISTORY_ITEMS; - /** - * @ORM\Column(name="storage_quota", type="bigint", nullable=true) - * - * @OA\Property(example="50 GB") - * @var string|null - */ - protected $storage_quota; - - /** - * @OA\Property(example="50000000000") - * @var string|null - */ - protected $storage_quota_bytes; - - /** - * @ORM\Column(name="storage_used", type="bigint", nullable=true) - * - * @AuditLog\AuditIgnore() - * - * @OA\Property(example="1 GB") - * @var string|null - */ - protected $storage_used; - - /** - * @OA\Property(example="1000000000") - * @var string|null - */ - protected $storage_used_bytes; - /** * @ORM\Column(name="timezone", type="string", length=100, nullable=true) * @@ -341,10 +304,30 @@ class Station protected $history; /** - * @ORM\OneToMany(targetEntity="StationMedia", mappedBy="station") - * @var Collection + * @ORM\ManyToOne(targetEntity="StorageLocation") + * @ORM\JoinColumns({ + * @ORM\JoinColumn(name="media_storage_location_id", referencedColumnName="id", onDelete="SET NULL") + * }) + * + * @DeepNormalize(true) + * @Serializer\MaxDepth(1) + * + * @var StorageLocation */ - protected $media; + protected $media_storage_location; + + /** + * @ORM\ManyToOne(targetEntity="StorageLocation") + * @ORM\JoinColumns({ + * @ORM\JoinColumn(name="recordings_storage_location_id", referencedColumnName="id", onDelete="SET NULL") + * }) + * + * @DeepNormalize(true) + * @Serializer\MaxDepth(1) + * + * @var StorageLocation + */ + protected $recordings_storage_location; /** * @ORM\OneToMany(targetEntity="StationStreamer", mappedBy="station") @@ -354,7 +337,7 @@ class Station /** * @ORM\Column(name="current_streamer_id", type="integer", nullable=true) - * @var int + * @var int|null */ protected $current_streamer_id; @@ -407,7 +390,6 @@ class Station public function __construct() { $this->history = new ArrayCollection(); - $this->media = new ArrayCollection(); $this->playlists = new ArrayCollection(); $this->mounts = new ArrayCollection(); $this->remotes = new ArrayCollection(); @@ -657,54 +639,46 @@ class Station $this->radio_base_dir = $newDir; } - public function getRadioAlbumArtDir(): string + public function ensureDirectoriesExist(): void { - return $this->radio_base_dir . '/album_art'; - } - - public function getRadioWaveformsDir(): string - { - return $this->radio_base_dir . '/waveforms'; - } - - public function getRadioTempDir(): string - { - return $this->radio_base_dir . '/temp'; - } - - public function getRadioRecordingsDir(): string - { - return $this->radio_base_dir . '/recordings'; - } - - /** - * Given an absolute path, return a path relative to this station's media directory. - * - * @param string $full_path - */ - public function getRelativeMediaPath($full_path): string - { - return ltrim(str_replace($this->getRadioMediaDir(), '', $full_path), '/'); - } - - public function getRadioMediaDir(): string - { - return (!empty($this->radio_media_dir)) - ? $this->radio_media_dir - : $this->radio_base_dir . '/media'; - } - - public function setRadioMediaDir(?string $new_dir): void - { - $new_dir = $this->truncateString(trim($new_dir)); - - if ($new_dir && $new_dir !== $this->radio_media_dir) { - if (!empty($new_dir) && !file_exists($new_dir) && !mkdir($new_dir, 0777, true) && !is_dir($new_dir)) { - throw new RuntimeException(sprintf('Directory "%s" was not created', $new_dir)); - } - - $this->radio_media_dir = $new_dir; + if (null === $this->radio_base_dir) { + $this->setRadioBaseDir(null); } + + // Flysystem adapters will automatically create the main directory. + $this->getRadioBaseDirAdapter(); + $this->getRadioPlaylistsDirAdapter(); + $this->getRadioConfigDirAdapter(); + $this->getRadioTempDirAdapter(); + + if (null === $this->media_storage_location) { + $storageLocation = new StorageLocation( + StorageLocation::TYPE_STATION_MEDIA, + StorageLocation::ADAPTER_LOCAL + ); + $storageLocation->setPath($this->getRadioBaseDir() . '/media'); + + $this->media_storage_location = $storageLocation; + } + + if (null === $this->recordings_storage_location) { + $storageLocation = new StorageLocation( + StorageLocation::TYPE_STATION_RECORDINGS, + StorageLocation::ADAPTER_LOCAL + ); + $storageLocation->setPath($this->getRadioBaseDir() . '/recordings'); + + $this->recordings_storage_location = $storageLocation; + } + + $this->getRadioMediaDirAdapter(); + $this->getRadioRecordingsDirAdapter(); + } + + public function getRadioBaseDirAdapter(?string $suffix = null): AdapterInterface + { + $path = $this->radio_base_dir . $suffix; + return new Local($path); } public function getRadioPlaylistsDir(): string @@ -712,26 +686,39 @@ class Station return $this->radio_base_dir . '/playlists'; } + public function getRadioPlaylistsDirAdapter(): AdapterInterface + { + return new Local($this->getRadioPlaylistsDir()); + } + public function getRadioConfigDir(): string { return $this->radio_base_dir . '/config'; } - /** - * @return string[]|null[] - */ - public function getAllStationDirectories(): array + public function getRadioConfigDirAdapter(): AdapterInterface { - return [ - $this->getRadioBaseDir(), - $this->getRadioMediaDir(), - $this->getRadioAlbumArtDir(), - $this->getRadioWaveformsDir(), - $this->getRadioPlaylistsDir(), - $this->getRadioConfigDir(), - $this->getRadioTempDir(), - $this->getRadioRecordingsDir(), - ]; + return new Local($this->getRadioConfigDir()); + } + + public function getRadioTempDir(): string + { + return $this->radio_base_dir . '/temp'; + } + + public function getRadioTempDirAdapter(): AdapterInterface + { + return new Local($this->getRadioTempDir()); + } + + public function getRadioMediaDirAdapter(): AdapterInterface + { + return $this->getMediaStorageLocation()->getStorageAdapter(); + } + + public function getRadioRecordingsDirAdapter(): AdapterInterface + { + return $this->getRecordingsStorageLocation()->getStorageAdapter(); } public function getNowplaying(): ?Api\NowPlaying @@ -903,132 +890,6 @@ class Station $this->api_history_items = $api_history_items; } - public function getStorageQuota(): ?string - { - $raw_quota = $this->getStorageQuotaBytes(); - - return ($raw_quota instanceof BigInteger) - ? Quota::getReadableSize($raw_quota) - : ''; - } - - /** - * @param BigInteger|string|null $storage_quota - */ - public function setStorageQuota($storage_quota): void - { - $storage_quota = (string)Quota::convertFromReadableSize($storage_quota); - $this->storage_quota = !empty($storage_quota) ? $storage_quota : null; - } - - public function getStorageQuotaBytes(): ?BigInteger - { - $size = $this->storage_quota; - - return (null !== $size) - ? BigInteger::of($size) - : null; - } - - public function getStorageUsed(): ?string - { - $raw_size = $this->getStorageUsedBytes(); - return Quota::getReadableSize($raw_size); - } - - /** - * @param BigInteger|string|null $storage_used - */ - public function setStorageUsed($storage_used): void - { - $storage_used = (string)Quota::convertFromReadableSize($storage_used); - $this->storage_used = !empty($storage_used) ? $storage_used : null; - } - - public function getStorageUsedBytes(): BigInteger - { - $size = $this->storage_used; - - if (null === $size) { - return BigInteger::zero(); - } - - return BigInteger::of($size); - } - - /** - * Increment the current used storage total. - * - * @param BigInteger|string|int $new_storage_amount - */ - public function addStorageUsed($new_storage_amount): void - { - if (empty($new_storage_amount)) { - return; - } - - $current_storage_used = $this->getStorageUsedBytes(); - $this->storage_used = (string)$current_storage_used->plus($new_storage_amount); - } - - /** - * Decrement the current used storage total. - * - * @param BigInteger|string|int $amount_to_remove - */ - public function removeStorageUsed($amount_to_remove): void - { - if (empty($amount_to_remove)) { - return; - } - - $current_storage_used = $this->getStorageUsedBytes(); - $storage_used = $current_storage_used->minus($amount_to_remove); - if ($storage_used->isLessThan(0)) { - $storage_used = BigInteger::zero(); - } - - $this->storage_used = (string)$storage_used; - } - - public function getStorageAvailable(): string - { - $raw_size = $this->getRawStorageAvailable(); - - return ($raw_size instanceof BigInteger) - ? Quota::getReadableSize($raw_size) - : ''; - } - - public function getRawStorageAvailable(): ?BigInteger - { - $quota = $this->getStorageQuotaBytes(); - $total_space = disk_total_space($this->getRadioMediaDir()); - - if ($quota === null || $quota->compareTo($total_space) === 1) { - return BigInteger::of($total_space); - } - - return $quota; - } - - public function isStorageFull(): bool - { - $available = $this->getRawStorageAvailable(); - if ($available === null) { - return true; - } - - $used = $this->getStorageUsedBytes(); - - return ($used->compareTo($available) !== -1); - } - - public function getStorageUsePercentage(): int - { - return Quota::getPercentage($this->getStorageUsedBytes(), $this->getRawStorageAvailable()); - } - public function getTimezone(): string { if (!empty($this->timezone)) { @@ -1066,11 +927,6 @@ class Station return $this->history; } - public function getMedia(): Collection - { - return $this->media; - } - public function getStreamers(): Collection { return $this->streamers; @@ -1088,11 +944,47 @@ class Station } } + public function getMediaStorageLocation(): StorageLocation + { + return $this->media_storage_location; + } + + public function setMediaStorageLocation(StorageLocation $storageLocation): void + { + if (StorageLocation::TYPE_STATION_MEDIA !== $storageLocation->getType()) { + throw new \InvalidArgumentException('Storage location must be for station media.'); + } + + $this->media_storage_location = $storageLocation; + } + + public function getRecordingsStorageLocation(): StorageLocation + { + return $this->recordings_storage_location; + } + + public function setRecordingsStorageLocation(StorageLocation $storageLocation): void + { + if (StorageLocation::TYPE_STATION_RECORDINGS !== $storageLocation->getType()) { + throw new \InvalidArgumentException('Storage location must be for station live recordings.'); + } + + $this->recordings_storage_location = $storageLocation; + } + public function getPermissions(): Collection { return $this->permissions; } + /** + * @return StationMedia[]|Collection + */ + public function getMedia(): Collection + { + return $this->media_storage_location->getMedia(); + } + /** * @return StationPlaylist[]|Collection */ diff --git a/src/Entity/StationMedia.php b/src/Entity/StationMedia.php index 732c24bed..25c72f2ab 100644 --- a/src/Entity/StationMedia.php +++ b/src/Entity/StationMedia.php @@ -3,7 +3,7 @@ namespace App\Entity; use App\Annotations\AuditLog; -use App\Flysystem\Filesystem; +use App\Flysystem\FilesystemManager; use App\Media\Metadata; use App\Normalizer\Annotation\DeepNormalize; use Doctrine\Common\Collections\ArrayCollection; @@ -16,7 +16,7 @@ use Symfony\Component\Serializer\Annotation as Serializer; * @ORM\Table(name="station_media", indexes={ * @ORM\Index(name="search_idx", columns={"title", "artist", "album"}) * }, uniqueConstraints={ - * @ORM\UniqueConstraint(name="path_unique_idx", columns={"path", "station_id"}) + * @ORM\UniqueConstraint(name="path_unique_idx", columns={"path", "storage_location_id"}) * }) * @ORM\Entity() * @@ -30,6 +30,9 @@ class StationMedia implements SongInterface public const UNIQUE_ID_LENGTH = 24; + public const DIR_ALBUM_ART = '.albumart'; + public const DIR_WAVEFORMS = '.waveforms'; + /** * @ORM\Column(name="id", type="integer") * @ORM\Id @@ -42,19 +45,19 @@ class StationMedia implements SongInterface protected $id; /** - * @ORM\Column(name="station_id", type="integer") + * @ORM\Column(name="storage_location_id", type="integer") * @var int */ - protected $station_id; + protected $storage_location_id; /** - * @ORM\ManyToOne(targetEntity="Station", inversedBy="media") + * @ORM\ManyToOne(targetEntity="StorageLocation", inversedBy="media") * @ORM\JoinColumns({ - * @ORM\JoinColumn(name="station_id", referencedColumnName="id", onDelete="CASCADE") + * @ORM\JoinColumn(name="storage_location_id", referencedColumnName="id", onDelete="CASCADE") * }) - * @var Station + * @var StorageLocation */ - protected $station; + protected $storage_location; /** * @ORM\Column(name="album", type="string", length=200, nullable=true) @@ -216,9 +219,9 @@ class StationMedia implements SongInterface */ protected $custom_fields; - public function __construct(Station $station, string $path) + public function __construct(StorageLocation $storageLocation, string $path) { - $this->station = $station; + $this->storage_location = $storageLocation; $this->playlists = new ArrayCollection(); $this->custom_fields = new ArrayCollection(); @@ -232,9 +235,9 @@ class StationMedia implements SongInterface return $this->id; } - public function getStation(): Station + public function getStorageLocation(): StorageLocation { - return $this->station; + return $this->storage_location; } public function getAlbum(): ?string @@ -267,27 +270,14 @@ class StationMedia implements SongInterface $this->lyrics = $lyrics; } - /** - * Get the Flysystem URI for album artwork for this item. - */ - public function getArtPath(): string - { - return Filesystem::PREFIX_ALBUM_ART . '://' . $this->unique_id . '.jpg'; - } - - public function getWaveformPath(): string - { - return Filesystem::PREFIX_WAVEFORMS . '://' . $this->unique_id . '.json'; - } - /** * @return string[] */ public function getRelatedFilePaths(): array { return [ - $this->getArtPath(), - $this->getWaveformPath(), + self::getArtPath($this->getUniqueId()), + self::getWaveformPath($this->getUniqueId()), ]; } @@ -343,7 +333,7 @@ class StationMedia implements SongInterface */ public function getPathUri(): string { - return Filesystem::PREFIX_MEDIA . '://' . $this->path; + return FilesystemManager::PREFIX_MEDIA . '://' . $this->path; } public function getMtime(): ?int @@ -583,4 +573,24 @@ class StationMedia implements SongInterface { return 'StationMedia ' . $this->unique_id . ': ' . $this->artist . ' - ' . $this->title; } + + public static function getArtPath(string $uniqueId): string + { + return self::DIR_ALBUM_ART . '/' . $uniqueId . '.jpg'; + } + + public static function getArtUri(string $uniqueId): string + { + return FilesystemManager::PREFIX_MEDIA . '://' . ltrim(self::getArtPath($uniqueId), '/'); + } + + public static function getWaveformPath(string $uniqueId): string + { + return self::DIR_WAVEFORMS . '/' . $uniqueId . '.json'; + } + + public static function getWaveformUri(string $uniqueId): string + { + return FilesystemManager::PREFIX_MEDIA . '://' . ltrim(self::getWaveformPath($uniqueId), '/'); + } } diff --git a/src/Entity/StationPlaylist.php b/src/Entity/StationPlaylist.php index 85f765e45..7e4ffef90 100644 --- a/src/Entity/StationPlaylist.php +++ b/src/Entity/StationPlaylist.php @@ -664,7 +664,16 @@ class StationPlaylist $absolute_paths = false, $with_annotations = false ): string { - $media_path = ($absolute_paths) ? $this->station->getRadioMediaDir() . '/' : ''; + if ($absolute_paths) { + $mediaStorage = $this->station->getMediaStorageLocation(); + if (!$mediaStorage->isLocal()) { + throw new \RuntimeException('Media is not hosted locally on this system.'); + } + + $media_path = $mediaStorage->getPath() . '/'; + } else { + $media_path = ''; + } switch ($file_format) { case 'm3u': diff --git a/src/Entity/StorageLocation.php b/src/Entity/StorageLocation.php new file mode 100644 index 000000000..92dd75c98 --- /dev/null +++ b/src/Entity/StorageLocation.php @@ -0,0 +1,505 @@ +type = $type; + $this->adapter = $adapter; + + $this->media = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getType(): string + { + return $this->type; + } + + public function getAdapter(): string + { + return $this->adapter; + } + + public function getPath(): ?string + { + return $this->path; + } + + public function applyPath(?string $suffix = null): string + { + $suffix = (null !== $suffix) + ? '/' . ltrim($suffix, '/') + : ''; + + return $this->path . $suffix; + } + + public function setPath(?string $path): void + { + $this->path = $this->truncateString($path, 255); + } + + public function getS3CredentialKey(): ?string + { + return $this->s3CredentialKey; + } + + public function setS3CredentialKey(?string $s3CredentialKey): void + { + $this->s3CredentialKey = $this->truncateString($s3CredentialKey, 255); + } + + public function getS3CredentialSecret(): ?string + { + return $this->s3CredentialSecret; + } + + public function setS3CredentialSecret(?string $s3CredentialSecret): void + { + $this->s3CredentialSecret = $this->truncateString($s3CredentialSecret, 255); + } + + public function getS3Region(): ?string + { + return $this->s3Region; + } + + public function setS3Region(?string $s3Region): void + { + $this->s3Region = $s3Region; + } + + public function getS3Version(): ?string + { + return $this->s3Version; + } + + public function setS3Version(?string $s3Version): void + { + $this->s3Version = $s3Version; + } + + public function getS3Bucket(): ?string + { + return $this->s3Bucket; + } + + public function setS3Bucket(?string $s3Bucket): void + { + $this->s3Bucket = $s3Bucket; + } + + public function getS3Endpoint(): ?string + { + return $this->s3Endpoint; + } + + public function setS3Endpoint(?string $s3Endpoint): void + { + $this->s3Endpoint = $this->truncateString($s3Endpoint, 255); + } + + public function isLocal(): bool + { + return self::ADAPTER_LOCAL === $this->adapter; + } + + public function getStorageQuota(): ?string + { + $raw_quota = $this->getStorageQuotaBytes(); + + return ($raw_quota instanceof BigInteger) + ? Quota::getReadableSize($raw_quota) + : ''; + } + + /** + * @param BigInteger|string|null $storageQuota + */ + public function setStorageQuota($storageQuota): void + { + $storageQuota = (string)Quota::convertFromReadableSize($storageQuota); + $this->storageQuota = !empty($storageQuota) ? $storageQuota : null; + } + + public function getStorageQuotaBytes(): ?BigInteger + { + $size = $this->storageQuota; + + return (null !== $size) + ? BigInteger::of($size) + : null; + } + + public function getStorageUsed(): ?string + { + $raw_size = $this->getStorageUsedBytes(); + return Quota::getReadableSize($raw_size); + } + + /** + * @param BigInteger|string|null $storageUsed + */ + public function setStorageUsed($storageUsed): void + { + $storageUsed = (string)Quota::convertFromReadableSize($storageUsed); + $this->storageUsed = !empty($storageUsed) ? $storageUsed : null; + } + + public function getStorageUsedBytes(): BigInteger + { + $size = $this->storageUsed; + + if (null === $size) { + return BigInteger::zero(); + } + + return BigInteger::of($size); + } + + /** + * Increment the current used storage total. + * + * @param BigInteger|string|int $newStorageAmount + */ + public function addStorageUsed($newStorageAmount): void + { + if (empty($newStorageAmount)) { + return; + } + + $currentStorageUsed = $this->getStorageUsedBytes(); + $this->storageUsed = (string)$currentStorageUsed->plus($newStorageAmount); + } + + /** + * Decrement the current used storage total. + * + * @param BigInteger|string|int $amountToRemove + */ + public function removeStorageUsed($amountToRemove): void + { + if (empty($amountToRemove)) { + return; + } + + $currentStorageUsed = $this->getStorageUsedBytes(); + $storageUsed = $currentStorageUsed->minus($amountToRemove); + if ($storageUsed->isLessThan(0)) { + $storageUsed = BigInteger::zero(); + } + + $this->storageUsed = (string)$storageUsed; + } + + public function getStorageAvailable(): string + { + $raw_size = $this->getRawStorageAvailable(); + + return ($raw_size instanceof BigInteger) + ? Quota::getReadableSize($raw_size) + : ''; + } + + public function getRawStorageAvailable(): ?BigInteger + { + $quota = $this->getStorageQuotaBytes(); + + if ($this->isLocal()) { + $localPath = $this->getPath(); + $totalSpace = BigInteger::of(disk_total_space($localPath)); + + if (null === $quota || $quota->isGreaterThan($totalSpace)) { + return $totalSpace; + } + } elseif (null !== $quota) { + return $quota; + } + + return null; + } + + public function isStorageFull(): bool + { + $available = $this->getRawStorageAvailable(); + if ($available === null) { + return false; + } + + $used = $this->getStorageUsedBytes(); + + return ($used->compareTo($available) !== -1); + } + + public function getStorageUsePercentage(): int + { + $storageUsed = $this->getStorageUsedBytes(); + $storageAvailable = $this->getRawStorageAvailable(); + + if (null === $storageAvailable) { + return 0; + } + + return Quota::getPercentage($storageUsed, $storageAvailable); + } + + /** + * @return StationMedia[]|Collection + */ + public function getMedia() + { + return $this->media; + } + + public function getUri(?string $suffix = null): string + { + $path = $this->applyPath($suffix); + + switch ($this->adapter) { + case self::ADAPTER_S3: + try { + $client = $this->getS3Client(); + if (empty($path)) { + $objectUrl = $client->getObjectUrl($this->s3Bucket, '/'); + return rtrim($objectUrl, '/'); + } + + return $client->getObjectUrl($this->s3Bucket, ltrim($path, '/')); + } catch (\InvalidArgumentException $e) { + return 'Invalid URI (' . $e->getMessage() . ')'; + } + break; + + case self::ADAPTER_LOCAL: + default: + return $path; + } + } + + public function validate(): void + { + if (self::ADAPTER_S3 === $this->adapter) { + $client = $this->getS3Client(); + $client->listObjectsV2([ + 'Bucket' => $this->s3Bucket, + 'max-keys' => 1, + ]); + } + + $adapter = $this->getStorageAdapter(); + $adapter->has('/test'); + } + + public function getStorageAdapter(): AdapterInterface + { + switch ($this->adapter) { + case self::ADAPTER_S3: + $client = $this->getS3Client(); + return new AwsS3Adapter($client, $this->s3Bucket, $this->path); + + case self::ADAPTER_LOCAL: + default: + return new Local($this->path); + } + } + + protected function getS3Client(): S3Client + { + if (self::ADAPTER_S3 !== $this->adapter) { + throw new \InvalidArgumentException('This storage location is not using the S3 adapter.'); + } + + $s3Options = array_filter([ + 'credentials' => [ + 'key' => $this->s3CredentialKey, + 'secret' => $this->s3CredentialSecret, + ], + 'region' => $this->s3Region, + 'version' => $this->s3Version, + 'endpoint' => $this->s3Endpoint, + ]); + return new S3Client($s3Options); + } + + /** + * @param Config|array|null $config + * + */ + public function getFilesystem($config = null): Filesystem + { + return new Filesystem($this->getStorageAdapter(), $config); + } + + public function __toString(): string + { + $adapterNames = [ + self::ADAPTER_LOCAL => 'Local', + self::ADAPTER_S3 => 'S3', + ]; + return $adapterNames[$this->adapter] . ': ' . $this->getUri(); + } +} diff --git a/src/Flysystem/Filesystem.php b/src/Flysystem/Filesystem.php index f6d21b1b3..70285da2e 100644 --- a/src/Flysystem/Filesystem.php +++ b/src/Flysystem/Filesystem.php @@ -2,81 +2,208 @@ namespace App\Flysystem; -use App\Entity; -use Cache\Prefixed\PrefixedCachePool; +use App\Http\Response; +use InvalidArgumentException; +use Iterator; +use Jhofm\FlysystemIterator\FilesystemFilterIterator; +use Jhofm\FlysystemIterator\FilesystemIterator; +use Jhofm\FlysystemIterator\Options\Options; +use Jhofm\FlysystemIterator\RecursiveFilesystemIteratorIterator; use League\Flysystem\Adapter\Local; use League\Flysystem\Cached\CachedAdapter; -use League\Flysystem\Cached\Storage\Psr6Cache; +use League\Flysystem\Cached\Storage\AbstractCache; use League\Flysystem\Filesystem as LeagueFilesystem; -use Psr\Cache\CacheItemPoolInterface; +use Psr\Http\Message\ResponseInterface; -/** - * A wrapper and manager class for accessing assets on the filesystem. - */ -class Filesystem +class Filesystem extends LeagueFilesystem implements FilesystemInterface { - public const PREFIX_MEDIA = 'media'; - public const PREFIX_ALBUM_ART = 'albumart'; - public const PREFIX_WAVEFORMS = 'waveforms'; - public const PREFIX_PLAYLISTS = 'playlists'; - public const PREFIX_CONFIG = 'config'; - public const PREFIX_RECORDINGS = 'recordings'; - public const PREFIX_TEMP = 'temp'; - - protected CacheItemPoolInterface $cachePool; - - /** @var StationFilesystem[] All current interfaces managed by this instance. */ - protected array $interfaces = []; - - public function __construct(CacheItemPoolInterface $cachePool) + /** + * Call a callable function with a path that is guaranteed to be a local path, even if + * this filesystem is a remote one, by copying to a temporary directory first in the + * case of remote filesystems. + * + * @param string $path + * @param callable $function + * + * @return mixed + */ + public function withLocalFile(string $path, callable $function) { - $this->cachePool = new PrefixedCachePool($cachePool, 'fs|'); + try { + $localPath = $this->getFullPath($path); + return $function($localPath); + } catch (InvalidArgumentException $e) { + $tempPath = $this->copyToLocal($path); + $returnVal = $function($tempPath); + unlink($tempPath); + + return $returnVal; + } } - public function getForStation(Entity\Station $station, bool $cached = true): StationFilesystem + public function putFromLocal(string $localPath, string $to): bool { - $stationId = $station->getId(); - $interfaceKey = ($cached) - ? $stationId . '_cached' - : $stationId . '_uncached'; + $uploaded = $this->copyFromLocal($localPath, $to); - if (!isset($this->interfaces[$interfaceKey])) { - $aliases = [ - self::PREFIX_MEDIA => $station->getRadioMediaDir(), - self::PREFIX_ALBUM_ART => $station->getRadioAlbumArtDir(), - self::PREFIX_WAVEFORMS => $station->getRadioWaveformsDir(), - self::PREFIX_PLAYLISTS => $station->getRadioPlaylistsDir(), - self::PREFIX_CONFIG => $station->getRadioConfigDir(), - self::PREFIX_RECORDINGS => $station->getRadioRecordingsDir(), - self::PREFIX_TEMP => $station->getRadioTempDir(), - ]; + if ($uploaded) { + @unlink($localPath); + } - $filesystems = []; - foreach ($aliases as $alias => $localPath) { - $adapter = new Local($localPath); + return $uploaded; + } - if ($cached) { - $cachedClient = new Psr6Cache($this->cachePool, $this->normalizeCacheKey($localPath), 3600); - $adapter = new CachedAdapter($adapter, $cachedClient); - } + public function copyFromLocal(string $localPath, string $to): bool + { + if (!file_exists($localPath)) { + throw new \RuntimeException(sprintf('Source upload file not found at path: %s', $localPath)); + } - $filesystems[$alias] = new LeagueFilesystem($adapter); + $stream = fopen($localPath, 'rb'); + + $uploaded = $this->putStream($to, $stream); + + if (is_resource($stream)) { + fclose($stream); + } + + return $uploaded; + } + + public function copyToLocal(string $from, ?string $localPath = null): string + { + if (null === $localPath) { + $folderPrefix = substr(md5($from), 0, 10); + $localPath = sys_get_temp_dir() . '/' . $folderPrefix . '_' . basename($from); + } + + if (file_exists($localPath)) { + if (filemtime($localPath) >= $this->getTimestamp($from)) { + touch($localPath); + return $localPath; } - $this->interfaces[$interfaceKey] = new StationFilesystem($filesystems); + unlink($localPath); } - return $this->interfaces[$interfaceKey]; + $stream = $this->readStream($from); + + file_put_contents($localPath, $stream); + + if (is_resource($stream)) { + fclose($stream); + } + + return $localPath; } - protected function normalizeCacheKey(string $path): string + public function clearCache(bool $inMemoryOnly = false): void { - $path = ltrim($path, '/'); + $adapter = $this->getAdapter(); + if ($adapter instanceof CachedAdapter) { + $cache = $adapter->getCache(); - if (preg_match('|[\{\}\(\)/\\\@\:]|', $path)) { - return preg_replace('|[\{\}\(\)/\\\@\:]|', '_', $path); + if ($inMemoryOnly && $cache instanceof AbstractCache) { + $prev_autosave = $cache->getAutosave(); + $cache->setAutosave(false); + $cache->flush(); + $cache->setAutosave($prev_autosave); + } else { + $cache->flush(); + } + } + } + + public function getFullPath(string $path): string + { + $adapter = $this->getAdapter(); + if ($adapter instanceof CachedAdapter) { + $adapter = $adapter->getAdapter(); } - return $path; + if (!($adapter instanceof Local)) { + throw new InvalidArgumentException('Filesystem adapter is not a Local or cached Local adapter.'); + } + + return $adapter->applyPathPrefix($path); + } + + /** + * Create an iterator that loops through the entire contents of a given prefix. + * + * @param string $path + * @param array $iteratorOptions + * + */ + public function createIterator(string $path, array $iteratorOptions = []): Iterator + { + $iterator = new FilesystemIterator($this, $path, $iteratorOptions); + + $options = Options::fromArray($iteratorOptions); + if ($options->{Options::OPTION_IS_RECURSIVE}) { + $iterator = new RecursiveFilesystemIteratorIterator($iterator); + } + if ($options->{Options::OPTION_FILTER} !== null) { + $iterator = new FilesystemFilterIterator($iterator, $options->{Options::OPTION_FILTER}); + } + + return $iterator; + } + + /** @inheritDoc */ + public function streamToResponse( + Response $response, + string $path, + string $fileName = null, + string $disposition = 'attachment' + ): ResponseInterface { + $meta = $this->getMetadata($path); + + try { + $mime = $this->getMimetype($path); + } catch (\Exception $e) { + $mime = 'application/octet-stream'; + } + + $fileName ??= basename($path); + + if ('attachment' === $disposition) { + /* + * The regex used below is to ensure that the $fileName contains only + * characters ranging from ASCII 128-255 and ASCII 0-31 and 127 are replaced with an empty string + */ + $disposition .= '; filename="' . preg_replace('/[\x00-\x1F\x7F\"]/', ' ', $fileName) . '"'; + $disposition .= "; filename*=UTF-8''" . rawurlencode($fileName); + } + + $response = $response->withHeader('Content-Disposition', $disposition) + ->withHeader('Content-Length', $meta['size']) + ->withHeader('X-Accel-Buffering', 'no'); + + try { + $localPath = $this->getFullPath($path); + } catch (InvalidArgumentException $e) { + $localPath = $this->copyToLocal($path); + } + + // Special internal nginx routes to use X-Accel-Redirect for far more performant file serving. + $specialPaths = [ + '/var/azuracast/backups' => '/internal/backups', + '/var/azuracast/stations' => '/internal/stations', + ]; + + foreach ($specialPaths as $diskPath => $nginxPath) { + if (0 === strpos($localPath, $diskPath)) { + $accelPath = str_replace($diskPath, $nginxPath, $localPath); + + // Temporary work around, see SlimPHP/Slim#2924 + $response->getBody()->write(' '); + + return $response->withHeader('Content-Type', $mime) + ->withHeader('X-Accel-Redirect', $accelPath); + } + } + + + return $response->withFile($localPath, $mime); } } diff --git a/src/Flysystem/FilesystemGroup.php b/src/Flysystem/FilesystemGroup.php deleted file mode 100644 index 7f578f2c5..000000000 --- a/src/Flysystem/FilesystemGroup.php +++ /dev/null @@ -1,139 +0,0 @@ -putStream($to, $stream); - - if (is_resource($stream)) { - fclose($stream); - } - - if ($uploaded) { - @unlink($local_path); - return true; - } - - return false; - } - - /** - * If the adapter associated with the specified URI is a local one, get the full filesystem path. - * - * NOTE: This can only be assured for the temp:// and config:// prefixes. Other prefixes can (and will) - * use non-local adapters that will trigger an exception here. - * - * @param string $uri - */ - public function getFullPath($uri): string - { - [$prefix, $path] = $this->getPrefixAndPath($uri); - - $fs = $this->getFilesystem($prefix); - - if (!($fs instanceof Filesystem)) { - throw new InvalidArgumentException(sprintf( - 'Filesystem for "%s" is not an instance of Filesystem.', - $prefix - )); - } - - $adapter = $fs->getAdapter(); - - if ($adapter instanceof CachedAdapter) { - $adapter = $adapter->getAdapter(); - } - - if (!($adapter instanceof Local)) { - throw new InvalidArgumentException(sprintf( - 'Adapter for "%s" is not a Local or cached Local adapter.', - $prefix - )); - } - - $prefix = $adapter->getPathPrefix(); - return $prefix . $path; - } - - /** - * Flush the caches of all associated filesystems. - * - * @param bool $in_memory_only Set to TRUE to only flush the current PHP process's memory, not the Redis cache. - */ - public function flushAllCaches($in_memory_only = false): void - { - foreach ($this->filesystems as $prefix => $filesystem) { - if ($filesystem instanceof Filesystem) { - $adapter = $filesystem->getAdapter(); - if ($adapter instanceof CachedAdapter) { - $cache = $adapter->getCache(); - - if ($in_memory_only && $cache instanceof AbstractCache) { - $prev_autosave = $cache->getAutosave(); - $cache->setAutosave(false); - $cache->flush(); - $cache->setAutosave($prev_autosave); - } else { - $cache->flush(); - } - } - } - } - } - - /** - * Create an iterator that loops through the entire contents of a given prefix. - * - * @param string $uri - * @param array $iteratorOptions - */ - public function createIterator(string $uri, array $iteratorOptions = []): Iterator - { - [$prefix, $path] = $this->getPrefixAndPath($uri); - - $fs = $this->getFilesystem($prefix); - if (!($fs instanceof Filesystem)) { - throw new RuntimeException('Filesystem cannot be iterated.'); - } - - $iterator = new FilesystemIterator($fs, $path, $iteratorOptions); - - $options = Options::fromArray($iteratorOptions); - if ($options->{Options::OPTION_IS_RECURSIVE}) { - $iterator = new RecursiveFilesystemIteratorIterator($iterator); - } - if ($options->{Options::OPTION_FILTER} !== null) { - $iterator = new FilesystemFilterIterator($iterator, $options->{Options::OPTION_FILTER}); - } - return $iterator; - } -} diff --git a/src/Flysystem/FilesystemInterface.php b/src/Flysystem/FilesystemInterface.php new file mode 100644 index 000000000..8ac376763 --- /dev/null +++ b/src/Flysystem/FilesystemInterface.php @@ -0,0 +1,40 @@ +cachePool = new PrefixedCachePool($cachePool, 'fs|'); + } + + public function getForStation(Entity\Station $station, bool $cached = true): StationFilesystemGroup + { + /** @var AdapterInterface[] $aliases */ + $aliases = [ + self::PREFIX_MEDIA => $station->getRadioMediaDirAdapter(), + self::PREFIX_PLAYLISTS => $station->getRadioPlaylistsDirAdapter(), + self::PREFIX_CONFIG => $station->getRadioConfigDirAdapter(), + self::PREFIX_RECORDINGS => $station->getRadioRecordingsDirAdapter(), + self::PREFIX_TEMP => $station->getRadioTempDirAdapter(), + ]; + + $cachableFilesystems = [ + self::PREFIX_MEDIA, + self::PREFIX_RECORDINGS, + ]; + + $filesystems = []; + foreach ($aliases as $alias => $adapter) { + $cacheThisAdapter = (in_array($alias, $cachableFilesystems, true)) + ? $cached + : false; + + $filesystems[$alias] = $this->getFilesystemForAdapter($adapter, $cacheThisAdapter); + } + + return new StationFilesystemGroup($filesystems); + } + + public function getFilesystemForAdapter(AdapterInterface $adapter, bool $cached = false): Filesystem + { + if ($cached) { + $cachedClient = new Psr6Cache($this->cachePool, $this->getCacheKey($adapter), 3600); + $adapter = new CachedAdapter($adapter, $cachedClient); + } + + return new Filesystem($adapter); + } + + public function flushCacheForAdapter(AdapterInterface $adapter, bool $inMemoryOnly = false): void + { + $fs = $this->getFilesystemForAdapter($adapter, true); + $fs->clearCache($inMemoryOnly); + } + + protected function getCacheKey(AdapterInterface $adapter): string + { + if ($adapter instanceof CachedAdapter) { + $adapter = $adapter->getAdapter(); + } + + if ($adapter instanceof AwsS3Adapter) { + $s3Client = $adapter->getClient(); + $bucket = $adapter->getBucket(); + + $objectUrl = $s3Client->getObjectUrl($bucket, $adapter->applyPathPrefix('/cache')); + return $this->filterCacheKey($objectUrl); + } + if ($adapter instanceof AbstractAdapter) { + return $this->filterCacheKey(ltrim($adapter->getPathPrefix(), '/')); + } + + throw new \InvalidArgumentException('Adapter does not have a cache key.'); + } + + protected function filterCacheKey(string $cacheKey): string + { + if (preg_match('|[\{\}\(\)/\\\@\:]|', $cacheKey)) { + return preg_replace('|[\{\}\(\)/\\\@\:]|', '_', $cacheKey); + } + return $cacheKey; + } +} diff --git a/src/Flysystem/StationFilesystem.php b/src/Flysystem/StationFilesystem.php deleted file mode 100644 index aa123c015..000000000 --- a/src/Flysystem/StationFilesystem.php +++ /dev/null @@ -1,61 +0,0 @@ -getPrefixAndPath($from); - - if (null === $to) { - $random_prefix = substr(md5(random_bytes(8)), 0, 5); - $to = Filesystem::PREFIX_TEMP . '://' . $random_prefix . '_' . $path_from; - } - - if ($this->has($to)) { - $this->delete($to); - } - - $this->copy($from, $to); - - return $to; - } - - /** - * Update the value of a permanent file from a temporary directory. - * - * @param string $from The temporary path to update from - * @param string $to The permanent path to update to - * @param array $config - */ - public function updateFromTemp($from, $to, array $config = []): string - { - $buffer = $this->readStream($from); - if ($buffer === false) { - throw new Exception('Source file could not be read.'); - } - - $written = $this->putStream($to, $buffer, $config); - - if (is_resource($buffer)) { - fclose($buffer); - } - - if ($written) { - $this->delete($from); - } - - return $to; - } -} diff --git a/src/Flysystem/StationFilesystemGroup.php b/src/Flysystem/StationFilesystemGroup.php new file mode 100644 index 000000000..9da4f7bd7 --- /dev/null +++ b/src/Flysystem/StationFilesystemGroup.php @@ -0,0 +1,124 @@ +getPrefixAndPath($to); + + /** @var Filesystem $fs */ + $fs = $this->getFilesystem($prefix); + + return $fs->putFromLocal($localPath, $to); + } + + public function clearCache(bool $inMemoryOnly = false): void + { + foreach ($this->filesystems as $prefix => $filesystem) { + /** @var Filesystem $filesystem */ + $filesystem->clearCache($inMemoryOnly); + } + } + + public function getFullPath(string $uri): string + { + [$prefix, $path] = $this->getPrefixAndPath($uri); + + /** @var Filesystem $fs */ + $fs = $this->getFilesystem($prefix); + + return $fs->getFullPath($path); + } + + public function getLocalPath(string $uri): string + { + [$prefix, $path] = $this->getPrefixAndPath($uri); + + /** @var Filesystem $fs */ + $fs = $this->getFilesystem($prefix); + + try { + return $fs->getFullPath($path); + } catch (\InvalidArgumentException $e) { + $tempUri = $this->copyToTemp($uri); + return $this->getFullPath($tempUri); + } + } + + public function copyToTemp(string $from, ?string $to = null): string + { + [, $fromPath] = $this->getPrefixAndPath($from); + + if (null === $to) { + $folderPrefix = substr(md5($fromPath), 0, 10); + $to = FilesystemManager::PREFIX_TEMP . '://' . $folderPrefix . '_' . basename($fromPath); + } + + if ($this->has($to)) { + if ($this->getTimestamp($to) >= $this->getTimestamp($from)) { + $tempFullPath = $this->getLocalPath($to); + touch($tempFullPath); + + return $to; + } + + $this->delete($to); + } + + $this->copy($from, $to); + + return $to; + } + + public function putFromTemp(string $from, string $to, array $config = []): string + { + $buffer = $this->readStream($from); + if ($buffer === false) { + throw new Exception('Source file could not be read.'); + } + + $written = $this->putStream($to, $buffer, $config); + + if (is_resource($buffer)) { + fclose($buffer); + } + + if ($written) { + $this->delete($from); + } + + return $to; + } + + /** @inheritDoc */ + public function createIterator(string $path, array $iteratorOptions = []): Iterator + { + [$prefix, $path] = $this->getPrefixAndPath($path); + + /** @var FilesystemInterface $fs */ + $fs = $this->getFilesystem($prefix); + return $fs->createIterator($path, $iteratorOptions); + } + + /** @inheritDoc */ + public function streamToResponse( + Response $response, + string $path, + string $fileName = null, + string $disposition = 'attachment' + ): ResponseInterface { + [$prefix, $path] = $this->getPrefixAndPath($path); + + /** @var FilesystemInterface $fs */ + $fs = $this->getFilesystem($prefix); + return $fs->streamToResponse($response, $path, $fileName, $disposition); + } +} diff --git a/src/Form/BackupSettingsForm.php b/src/Form/BackupSettingsForm.php index c0b84699c..7e49d3ecc 100644 --- a/src/Form/BackupSettingsForm.php +++ b/src/Form/BackupSettingsForm.php @@ -12,11 +12,13 @@ class BackupSettingsForm extends AbstractSettingsForm public function __construct( EntityManagerInterface $em, Entity\Repository\SettingsRepository $settingsRepo, + Entity\Repository\StorageLocationRepository $storageLocationRepo, Settings $settings, Config $config ) { $formConfig = $config->get('forms/backup', [ 'settings' => $settings, + 'storageLocations' => $storageLocationRepo->fetchSelectByType(Entity\StorageLocation::TYPE_BACKUP, true), ]); parent::__construct( diff --git a/src/Form/StationCloneForm.php b/src/Form/StationCloneForm.php index c99880577..1e0a7681b 100644 --- a/src/Form/StationCloneForm.php +++ b/src/Form/StationCloneForm.php @@ -5,6 +5,7 @@ namespace App\Form; use App\Acl; use App\Config; use App\Entity; +use App\Flysystem\FilesystemManager; use App\Http\ServerRequest; use App\Radio\Configuration; use App\Settings; @@ -13,7 +14,6 @@ use DeepCopy; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\EntityManagerInterface; use InvalidArgumentException; -use RuntimeException; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Validator\Validator\ValidatorInterface; @@ -23,24 +23,38 @@ class StationCloneForm extends StationForm protected Media $media_sync; + protected FilesystemManager $filesystem; + public function __construct( EntityManagerInterface $em, Serializer $serializer, ValidatorInterface $validator, Entity\Repository\StationRepository $station_repo, + Entity\Repository\StorageLocationRepository $storageLocationRepo, Acl $acl, Configuration $configuration, Media $media_sync, Config $config, - Settings $settings + Settings $settings, + FilesystemManager $filesystem ) { - parent::__construct($em, $serializer, $validator, $station_repo, $acl, $config, $settings); + parent::__construct( + $em, + $serializer, + $validator, + $station_repo, + $storageLocationRepo, + $acl, + $config, + $settings + ); $form_config = $config->get('forms/station_clone'); $this->configure($form_config); $this->configuration = $configuration; $this->media_sync = $media_sync; + $this->filesystem = $filesystem; } /** @@ -179,37 +193,26 @@ class StationCloneForm extends StationForm $new_record->setHasStarted(false); if ('share' === $data['clone_media']) { - $new_record->setRadioMediaDir($record->getRadioMediaDir()); + $new_record->setMediaStorageLocation($record->getMediaStorageLocation()); } // Set new radio base directory $station_base_dir = Settings::getInstance()->getStationDirectory(); $new_record->setRadioBaseDir($station_base_dir . '/' . $new_record->getShortName()); + $new_record->ensureDirectoriesExist(); + // Persist all newly created records (and relations). $this->em->persist($new_record); - foreach ($new_record->getMedia() as $subrecord) { - /** @var Entity\StationMedia $subrecord */ - $this->em->persist($subrecord); - foreach ($subrecord->getCustomFields() as $subrecord_custom_field) { - $this->em->persist($subrecord_custom_field); - } - - foreach ($subrecord->getPlaylists() as $subrecord_playlist_items) { - /** @var Entity\StationPlaylistMedia $subrecord_playlist_items */ - $this->em->persist($subrecord_playlist_items); - - $playlist = $subrecord_playlist_items->getPlaylist(); - $this->em->persist($playlist); - } - } foreach ($new_record->getMounts() as $subrecord) { $this->em->persist($subrecord); } + foreach ($new_record->getPermissions() as $subrecord) { $this->em->persist($subrecord); } + foreach ($new_record->getPlaylists() as $subrecord) { /** @var Entity\StationPlaylist $subrecord */ $this->em->persist($subrecord); @@ -218,9 +221,11 @@ class StationCloneForm extends StationForm $this->em->persist($playlist_schedule_item); } } + foreach ($new_record->getRemotes() as $subrecord) { $this->em->persist($subrecord); } + foreach ($new_record->getStreamers() as $subrecord) { /** @var Entity\StationStreamer $subrecord */ $this->em->persist($subrecord); @@ -229,34 +234,14 @@ class StationCloneForm extends StationForm $this->em->persist($playlist_schedule_item); } } + $this->em->flush(); - // Copy album art. - if ('none' !== $data['clone_media']) { - $this->copy( - $record->getRadioAlbumArtDir(), - $new_record->getRadioAlbumArtDir() - ); - } - - // Copy media. - if ('copy' === $data['clone_media']) { - $this->copy( - $record->getRadioMediaDir(), - $new_record->getRadioMediaDir() - ); - } - // Clear the EntityManager for later functions. $new_record_id = $new_record->getId(); $this->em->clear(); $new_record = $this->em->find(Entity\Station::class, $new_record_id); - // Run normal post-creation steps. - $this->media_sync->importMusic($new_record); - - $new_record = $this->em->find(Entity\Station::class, $new_record_id); - $this->configuration->assignRadioPorts($new_record, true); $this->configuration->writeConfiguration($new_record); @@ -266,22 +251,4 @@ class StationCloneForm extends StationForm return false; } - - protected function copy($src, $dest): void - { - foreach (scandir($src) as $file) { - if (!is_readable($src . '/' . $file)) { - continue; - } - - if (is_dir($src . '/' . $file) && ($file !== '.') && ($file !== '..')) { - if (!mkdir($concurrentDirectory = $dest . '/' . $file) && !is_dir($concurrentDirectory)) { - throw new RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory)); - } - $this->copy($src . '/' . $file, $dest . '/' . $file); - } else { - copy($src . '/' . $file, $dest . '/' . $file); - } - } - } } diff --git a/src/Form/StationForm.php b/src/Form/StationForm.php index d634a22c9..745e75c11 100644 --- a/src/Form/StationForm.php +++ b/src/Form/StationForm.php @@ -17,6 +17,8 @@ class StationForm extends EntityForm { protected Entity\Repository\StationRepository $station_repo; + protected Entity\Repository\StorageLocationRepository $storageLocationRepo; + protected Acl $acl; protected Settings $settings; @@ -26,6 +28,7 @@ class StationForm extends EntityForm Serializer $serializer, ValidatorInterface $validator, Entity\Repository\StationRepository $station_repo, + Entity\Repository\StorageLocationRepository $storageLocationRepo, Acl $acl, Config $config, Settings $settings @@ -33,6 +36,7 @@ class StationForm extends EntityForm $this->acl = $acl; $this->entityClass = Entity\Station::class; $this->station_repo = $station_repo; + $this->storageLocationRepo = $storageLocationRepo; $this->settings = $settings; $form_config = $config->get('forms/station'); @@ -75,21 +79,84 @@ class StationForm extends EntityForm } if (!SHOUTcast::isInstalled()) { - $this->options['groups']['select_frontend_type']['elements']['frontend_type'][1]['description'] = __( + $frontendDesc = __( 'Want to use SHOUTcast 2? Install it here, then reload this page.', $request->getRouter()->named('admin:install_shoutcast:index') ); + + $this->getField('frontend_type')->setOption('description', $frontendDesc); } $create_mode = (null === $record); if (!$create_mode) { - $this->populate($this->normalizeRecord($record)); + $recordArray = $this->normalizeRecord($record); + $recordArray['media_storage_location_id'] = $recordArray['media_storage_location']['id'] ?? null; + $recordArray['recordings_storage_location_id'] = $recordArray['recordings_storage_location']['id'] ?? null; + + $this->populate($recordArray); + } + + if ($canSeeAdministration) { + $storageLocationsDesc = __( + 'Manage storage locations here.', + $request->getRouter()->named('admin:storage_locations:index') + ); + + $mediaStorageField = $this->getField('media_storage_location_id'); + $mediaStorageField->setOption('description', $storageLocationsDesc); + $mediaStorageField->setOption( + 'choices', + $this->storageLocationRepo->fetchSelectByType( + Entity\StorageLocation::TYPE_STATION_MEDIA, + $create_mode, + __('Create a new storage location based on the base directory.'), + ) + ); + + $recordingsStorageField = $this->getField('recordings_storage_location_id'); + $recordingsStorageField->setOption('description', $storageLocationsDesc); + $recordingsStorageField->setOption( + 'choices', + $this->storageLocationRepo->fetchSelectByType( + Entity\StorageLocation::TYPE_STATION_RECORDINGS, + $create_mode, + __('Create a new storage location based on the base directory.'), + ) + ); + + $this->options['groups']['admin']['elements']['recordings_storage_location_id'][1]['choices'] = + $this->storageLocationRepo->fetchSelectByType( + Entity\StorageLocation::TYPE_STATION_RECORDINGS, + $create_mode, + __('Create a new storage location based on the base directory.'), + ); } if ('POST' === $request->getMethod() && $this->isValid($request->getParsedBody())) { $data = $this->getValues(); + + /** @var Entity\Station $record */ $record = $this->denormalizeToRecord($data, $record); + if ($canSeeAdministration) { + if (!empty($data['media_storage_location_id'])) { + $record->setMediaStorageLocation( + $this->storageLocationRepo->findByType( + Entity\StorageLocation::TYPE_STATION_MEDIA, + $data['media_storage_location_id'] + ) + ); + } + if (!empty($data['recordings_storage_location_id'])) { + $record->setRecordingsStorageLocation( + $this->storageLocationRepo->findByType( + Entity\StorageLocation::TYPE_STATION_RECORDINGS, + $data['recordings_storage_location_id'] + ) + ); + } + } + $errors = $this->validator->validate($record); if (count($errors) > 0) { foreach ($errors as $error) { @@ -105,14 +172,9 @@ class StationForm extends EntityForm return false; } - $this->em->persist($record); - $this->em->flush(); - - if ($create_mode) { - return $this->station_repo->create($record); - } - - return $this->station_repo->edit($record); + return ($create_mode) + ? $this->station_repo->create($record) + : $this->station_repo->edit($record); } return false; diff --git a/src/Http/Response.php b/src/Http/Response.php index 602075060..a28276d5c 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -2,8 +2,6 @@ namespace App\Http; -use App\Flysystem\FilesystemGroup; -use Exception; use Psr\Http\Message\ResponseInterface; final class Response extends \Slim\Http\Response @@ -129,59 +127,4 @@ final class Response extends \Slim\Http\Response return new static($response, $this->streamFactory); } - - public function withFlysystemFile( - FilesystemGroup $fs, - string $path, - string $fileName = null, - string $disposition = 'attachment' - ): ResponseInterface { - $meta = $fs->getMetadata($path); - - try { - $mime = $fs->getMimetype($path); - } catch (Exception $e) { - $mime = 'application/octet-stream'; - } - - $fileName ??= basename($path); - - if ('attachment' === $disposition) { - /* - * The regex used below is to ensure that the $fileName contains only - * characters ranging from ASCII 128-255 and ASCII 0-31 and 127 are replaced with an empty string - */ - $disposition .= '; filename="' . preg_replace('/[\x00-\x1F\x7F\"]/', ' ', $fileName) . '"'; - $disposition .= "; filename*=UTF-8''" . rawurlencode($fileName); - } - - $response = $this->withHeader('Content-Disposition', $disposition) - ->withHeader('Content-Length', $meta['size']) - ->withHeader('X-Accel-Buffering', 'no'); - - try { - $localPath = $fs->getFullPath($path); - - // Special internal nginx routes to use X-Accel-Redirect for far more performant file serving. - $specialPaths = [ - '/var/azuracast/backups' => '/internal/backups', - '/var/azuracast/stations' => '/internal/stations', - ]; - - foreach ($specialPaths as $diskPath => $nginxPath) { - if (0 === strpos($localPath, $diskPath)) { - $accelPath = str_replace($diskPath, $nginxPath, $localPath); - - return $response->withHeader('Content-Type', $mime) - ->withHeader('X-Accel-Redirect', $accelPath) - ->write(' '); // Temporary work around, see SlimPHP/Slim#2924 - } - } - } catch (Exception $e) { - // Stream via PHP instead - } - - $fh = $fs->readStream($path); - return $response->withFile($fh, $mime); - } } diff --git a/src/Message/AddNewMediaMessage.php b/src/Message/AddNewMediaMessage.php index 64392b2cf..2bccd21ac 100644 --- a/src/Message/AddNewMediaMessage.php +++ b/src/Message/AddNewMediaMessage.php @@ -6,8 +6,8 @@ use App\MessageQueue\QueueManager; class AddNewMediaMessage extends AbstractUniqueMessage { - /** @var int The numeric identifier for the station. */ - public int $station_id; + /** @var int The numeric identifier for the StorageLocation entity. */ + public int $storage_location_id; /** @var string The relative path for the media file to be processed. */ public string $path; diff --git a/src/Message/BackupMessage.php b/src/Message/BackupMessage.php index 52f40f304..cc4553d72 100644 --- a/src/Message/BackupMessage.php +++ b/src/Message/BackupMessage.php @@ -6,8 +6,11 @@ use App\MessageQueue\QueueManager; class BackupMessage extends AbstractUniqueMessage { + /** @var int|null The storage location to back up to. */ + public ?int $storageLocationId = null; + /** @var string|null The absolute or relative path of the backup file. */ - public ?string $path; + public ?string $path = null; /** @var string|null The path to log output of the Backup command to. */ public ?string $outputPath = null; diff --git a/src/Middleware/Module/StationFiles.php b/src/Middleware/Module/StationFiles.php index 2946a0ad8..a09046778 100644 --- a/src/Middleware/Module/StationFiles.php +++ b/src/Middleware/Module/StationFiles.php @@ -3,7 +3,7 @@ namespace App\Middleware\Module; use App\Exception; -use App\Flysystem\Filesystem; +use App\Flysystem\FilesystemManager; use App\Http\ServerRequest; use Psr\Http\Message\ResponseInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -24,7 +24,7 @@ class StationFiles $params = $request->getParams(); $file = ltrim($params['file'] ?? '', '/'); - $filePath = Filesystem::PREFIX_MEDIA . '://' . $file; + $filePath = FilesystemManager::PREFIX_MEDIA . '://' . $file; $request = $request->withAttribute('file', $file) ->withAttribute('file_path', $filePath); diff --git a/src/Radio/AutoDJ/Annotations.php b/src/Radio/AutoDJ/Annotations.php index a2dda461e..e3ddae30f 100644 --- a/src/Radio/AutoDJ/Annotations.php +++ b/src/Radio/AutoDJ/Annotations.php @@ -4,7 +4,7 @@ namespace App\Radio\AutoDJ; use App\Entity; use App\Event\Radio\AnnotateNextSong; -use App\Flysystem\Filesystem; +use App\Flysystem\FilesystemManager; use App\Radio\Adapters; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -17,7 +17,7 @@ class Annotations implements EventSubscriberInterface protected Entity\Repository\StationStreamerRepository $streamerRepo; - protected Filesystem $filesystem; + protected FilesystemManager $filesystem; protected Adapters $adapters; @@ -25,7 +25,7 @@ class Annotations implements EventSubscriberInterface EntityManagerInterface $em, Entity\Repository\StationQueueRepository $queueRepo, Entity\Repository\StationStreamerRepository $streamerRepo, - Filesystem $filesystem, + FilesystemManager $filesystem, Adapters $adapters ) { $this->em = $em; @@ -55,9 +55,9 @@ class Annotations implements EventSubscriberInterface $media = $event->getMedia(); if ($media instanceof Entity\StationMedia) { $fs = $this->filesystem->getForStation($event->getStation()); - $media_path = $fs->getFullPath($media->getPathUri()); - $event->setSongPath($media_path); + $localMediaPath = $fs->getLocalPath($media->getPathUri()); + $event->setSongPath($localMediaPath); $backend = $this->adapters->getBackendAdapter($event->getStation()); $event->addAnnotations($backend->annotateMedia($media)); diff --git a/src/Radio/Backend/Liquidsoap/ConfigWriter.php b/src/Radio/Backend/Liquidsoap/ConfigWriter.php index e24a7e9f7..bf7aa8a0a 100644 --- a/src/Radio/Backend/Liquidsoap/ConfigWriter.php +++ b/src/Radio/Backend/Liquidsoap/ConfigWriter.php @@ -5,6 +5,7 @@ namespace App\Radio\Backend\Liquidsoap; use App\Entity; use App\Event\Radio\WriteLiquidsoapConfiguration; use App\Exception; +use App\Flysystem\FilesystemManager; use App\Logger; use App\Message; use App\Radio\Adapters; @@ -29,10 +30,16 @@ class ConfigWriter implements EventSubscriberInterface protected Liquidsoap $liquidsoap; - public function __construct(EntityManagerInterface $em, Liquidsoap $liquidsoap) - { + protected FilesystemManager $filesystem; + + public function __construct( + EntityManagerInterface $em, + Liquidsoap $liquidsoap, + FilesystemManager $filesystem + ) { $this->em = $em; $this->liquidsoap = $liquidsoap; + $this->filesystem = $filesystem; } /** @@ -118,11 +125,13 @@ class ConfigWriter implements EventSubscriberInterface $this->writeCustomConfigurationSection($event, self::CUSTOM_TOP); $station = $event->getStation(); - $config_path = $station->getRadioConfigDir(); + $fs = $this->filesystem->getForStation($station, false); + + $pidfile = $fs->getFullPath(FilesystemManager::PREFIX_CONFIG . '://liquidsoap.pid'); $event->appendLines([ 'set("init.daemon", false)', - 'set("init.daemon.pidfile.path","' . $config_path . '/liquidsoap.pid")', + 'set("init.daemon.pidfile.path","' . $pidfile . '")', 'set("log.stdout", true)', 'set("log.file", false)', 'set("server.telnet",true)', @@ -145,10 +154,12 @@ class ConfigWriter implements EventSubscriberInterface $this->writeCustomConfigurationSection($event, self::CUSTOM_PRE_PLAYLISTS); // Clear out existing playlists directory. - $playlistPath = $station->getRadioPlaylistsDir(); - $currentPlaylists = array_diff(scandir($playlistPath, SCANDIR_SORT_NONE), ['..', '.']); - foreach ($currentPlaylists as $list) { - @unlink($playlistPath . '/' . $list); + $fs = $this->filesystem->getForStation($station, false); + + foreach ($fs->listContents(FilesystemManager::PREFIX_PLAYLISTS . '://', true) as $file) { + if ('file' === $file['type']) { + $fs->delete($file['filesystem'] . '://' . $file['path']); + } } // Set up playlists using older format as a fallback. @@ -230,7 +241,6 @@ class ConfigWriter implements EventSubscriberInterface if (Entity\StationPlaylist::SOURCE_SONGS === $playlist->getSource()) { $playlistFilePath = $this->writePlaylistFile($playlist, false); - if (!$playlistFilePath) { continue; } @@ -266,7 +276,10 @@ class ConfigWriter implements EventSubscriberInterface $playlistParams[] = '"' . $playlistFilePath . '"'; - $playlistConfigLines[] = $playlistVarName . ' = ' . $playlistFuncName . '(' . implode(',', $playlistParams) . ')'; + $playlistConfigLines[] = $playlistVarName . ' = ' . $playlistFuncName . '(' . implode( + ',', + $playlistParams + ) . ')'; } else { switch ($playlist->getRemoteType()) { case Entity\StationPlaylist::REMOTE_TYPE_PLAYLIST: @@ -493,6 +506,11 @@ class ConfigWriter implements EventSubscriberInterface { $station = $playlist->getStation(); + $mediaStorage = $station->getMediaStorageLocation(); + if (!$mediaStorage->isLocal()) { + return null; + } + $playlistPath = $station->getRadioPlaylistsDir(); $playlistVarName = 'playlist_' . $playlist->getShortName(); @@ -502,7 +520,7 @@ class ConfigWriter implements EventSubscriberInterface 'playlist' => $playlist->getName(), ]); - $mediaBaseDir = $station->getRadioMediaDir() . '/'; + $mediaBaseDir = $mediaStorage->getPath() . '/'; $playlistFile = []; $mediaQuery = $this->em->createQuery(/** @lang DQL */ 'SELECT DISTINCT sm diff --git a/src/Radio/Configuration.php b/src/Radio/Configuration.php index f95b98e83..02f2584c5 100644 --- a/src/Radio/Configuration.php +++ b/src/Radio/Configuration.php @@ -8,7 +8,6 @@ use App\Settings; use Doctrine\ORM\EntityManagerInterface; use fXmlRpc\Exception\FaultException; use Monolog\Logger; -use RuntimeException; use Supervisor\Supervisor; class Configuration @@ -83,12 +82,7 @@ class Configuration } // Ensure all directories exist. - $radio_dirs = $station->getAllStationDirectories(); - foreach ($radio_dirs as $radio_dir) { - if (!file_exists($radio_dir) && !mkdir($radio_dir, 0777) && !is_dir($radio_dir)) { - throw new RuntimeException(sprintf('Directory "%s" was not created', $radio_dir)); - } - } + $station->ensureDirectoriesExist(); // Write config files for both backend and frontend. $frontend->write($station); @@ -136,8 +130,8 @@ class Configuration */ protected function getSupervisorConfigFile(Station $station): string { - $config_path = $station->getRadioConfigDir(); - return $config_path . '/supervisord.conf'; + $configDir = $station->getRadioConfigDir(); + return $configDir . '/supervisord.conf'; } /** @@ -363,15 +357,13 @@ class Configuration AbstractAdapter $adapter, $priority ): string { - $config_path = $station->getRadioConfigDir(); - [, $program_name] = explode(':', $adapter->getProgramName($station)); $config_lines = [ 'user' => 'azuracast', 'priority' => $priority, 'command' => $adapter->getCommand($station), - 'directory' => $config_path, + 'directory' => $station->getRadioConfigDir(), 'environment' => 'TZ="' . $station->getTimezone() . '"', 'stdout_logfile' => $adapter->getLogPath($station), 'stdout_logfile_maxbytes' => '5MB', diff --git a/src/Service/SftpGo.php b/src/Service/SftpGo.php index 91e974d65..fa0de038c 100644 --- a/src/Service/SftpGo.php +++ b/src/Service/SftpGo.php @@ -2,6 +2,7 @@ namespace App\Service; +use App\Entity\Station; use App\Settings; class SftpGo @@ -12,4 +13,10 @@ class SftpGo return !$settings->isTesting() && $settings->isDockerRevisionNewerThan(7); } + + public static function isSupportedForStation(Station $station): bool + { + $mediaStorage = $station->getMediaStorageLocation(); + return $mediaStorage->isLocal() && self::isSupported(); + } } diff --git a/src/Sync/Task/Backup.php b/src/Sync/Task/Backup.php index 883d2672a..307815117 100644 --- a/src/Sync/Task/Backup.php +++ b/src/Sync/Task/Backup.php @@ -45,7 +45,8 @@ class Backup extends AbstractTask [$result_code, $result_output] = $this->runBackup( $message->path, $message->excludeMedia, - $message->outputPath + $message->outputPath, + $message->storageLocationId ); $this->settingsRepo->setSetting(Entity\Settings::BACKUP_LAST_RESULT, $result_code); @@ -57,15 +58,23 @@ class Backup extends AbstractTask * @param string|null $path * @param bool $excludeMedia * @param string|null $outputPath + * @param int|null $storageLocationId * * @return mixed[] [int $result_code, string|false $result_output] */ - public function runBackup(?string $path = null, bool $excludeMedia = false, ?string $outputPath = null): array - { + public function runBackup( + ?string $path = null, + bool $excludeMedia = false, + ?string $outputPath = null, + ?int $storageLocationId = null + ): array { $input_params = []; if (null !== $path) { $input_params['path'] = $path; } + if (null !== $storageLocationId) { + $input_params['--storage-location-id'] = $storageLocationId; + } if ($excludeMedia) { $input_params['--exclude-media'] = true; } @@ -119,7 +128,16 @@ class Backup extends AbstractTask } // Trigger a new backup. + $storageLocationId = (int)$this->settingsRepo->getSetting( + Entity\Settings::BACKUP_STORAGE_LOCATION, + 0 + ); + if ($storageLocationId <= 0) { + $storageLocationId = null; + } + $message = new Message\BackupMessage(); + $message->storageLocationId = $storageLocationId; $message->path = 'automatic_backup.zip'; $message->excludeMedia = (bool)$this->settingsRepo->getSetting(Entity\Settings::BACKUP_EXCLUDE_MEDIA, 0); diff --git a/src/Sync/Task/FolderPlaylists.php b/src/Sync/Task/FolderPlaylists.php index e4ca2e184..9f5acb8ae 100644 --- a/src/Sync/Task/FolderPlaylists.php +++ b/src/Sync/Task/FolderPlaylists.php @@ -3,7 +3,7 @@ namespace App\Sync\Task; use App\Entity; -use App\Flysystem\Filesystem; +use App\Flysystem\FilesystemManager; use Doctrine\ORM\EntityManagerInterface; use DoctrineBatchUtils\BatchProcessing\SimpleBatchIteratorAggregate; use Psr\Log\LoggerInterface; @@ -14,7 +14,7 @@ class FolderPlaylists extends AbstractTask protected Entity\Repository\StationPlaylistMediaRepository $spmRepo; - protected Filesystem $filesystem; + protected FilesystemManager $filesystem; public function __construct( EntityManagerInterface $em, @@ -22,7 +22,7 @@ class FolderPlaylists extends AbstractTask LoggerInterface $logger, Entity\Repository\StationPlaylistMediaRepository $spmRepo, Entity\Repository\StationPlaylistFolderRepository $folderRepo, - Filesystem $filesystem + FilesystemManager $filesystem ) { parent::__construct($em, $settingsRepo, $logger); @@ -68,7 +68,7 @@ class FolderPlaylists extends AbstractTask /** @var Entity\StationPlaylistFolder $row */ $path = $row->getPath(); - if ($fs->has(Filesystem::PREFIX_MEDIA . '://' . $path)) { + if ($fs->has(FilesystemManager::PREFIX_MEDIA . '://' . $path)) { $folders[$path][] = $row->getPlaylist(); } else { $this->em->remove($row); diff --git a/src/Sync/Task/Media.php b/src/Sync/Task/Media.php index 680f77ee2..2530b4973 100644 --- a/src/Sync/Task/Media.php +++ b/src/Sync/Task/Media.php @@ -3,23 +3,26 @@ namespace App\Sync\Task; use App\Entity; -use App\Flysystem\Filesystem; +use App\Flysystem\FilesystemManager; use App\Message; use App\MessageQueue\QueueManager; use App\Radio\Quota; +use Aws\S3\Exception\S3Exception; use Brick\Math\BigInteger; use Doctrine\ORM\EntityManagerInterface; use DoctrineBatchUtils\BatchProcessing\SimpleBatchIteratorAggregate; use Jhofm\FlysystemIterator\Filter\FilterFactory; +use Jhofm\FlysystemIterator\Options\Options; use Psr\Log\LoggerInterface; -use Symfony\Component\Finder\Finder; use Symfony\Component\Messenger\MessageBus; class Media extends AbstractTask { + protected Entity\Repository\StorageLocationRepository $storageLocationRepo; + protected Entity\Repository\StationMediaRepository $mediaRepo; - protected Filesystem $filesystem; + protected FilesystemManager $filesystem; protected MessageBus $messageBus; @@ -30,12 +33,14 @@ class Media extends AbstractTask Entity\Repository\SettingsRepository $settingsRepo, LoggerInterface $logger, Entity\Repository\StationMediaRepository $mediaRepo, - Filesystem $filesystem, + Entity\Repository\StorageLocationRepository $storageLocationRepo, + FilesystemManager $filesystem, MessageBus $messageBus, QueueManager $queueManager ) { parent::__construct($em, $settingsRepo, $logger); + $this->storageLocationRepo = $storageLocationRepo; $this->mediaRepo = $mediaRepo; $this->filesystem = $filesystem; $this->messageBus = $messageBus; @@ -50,42 +55,46 @@ class Media extends AbstractTask public function __invoke(Message\AbstractMessage $message): void { if ($message instanceof Message\ReprocessMediaMessage) { - $media_row = $this->em->find(Entity\StationMedia::class, $message->media_id); + $mediaRow = $this->em->find(Entity\StationMedia::class, $message->media_id); - if ($media_row instanceof Entity\StationMedia) { - $this->mediaRepo->processMedia($media_row, $message->force); + if ($mediaRow instanceof Entity\StationMedia) { + $this->mediaRepo->processMedia($mediaRow, $message->force); $this->em->flush(); } } elseif ($message instanceof Message\AddNewMediaMessage) { - $station = $this->em->find(Entity\Station::class, $message->station_id); + $storageLocation = $this->em->find(Entity\StorageLocation::class, $message->storage_location_id); - if ($station instanceof Entity\Station) { - $this->mediaRepo->getOrCreate($station, $message->path); + if ($storageLocation instanceof Entity\StorageLocation) { + $this->mediaRepo->getOrCreate($storageLocation, $message->path); } } } public function run(bool $force = false): void { - $stations = SimpleBatchIteratorAggregate::fromQuery( - $this->em->createQuery(/** @lang DQL */ 'SELECT s FROM App\Entity\Station s'), - 1 - ); + $query = $this->em->createQuery(/** @lang DQL */ 'SELECT sl + FROM App\Entity\StorageLocation sl + WHERE sl.type = :type') + ->setParameter('type', Entity\StorageLocation::TYPE_STATION_MEDIA); - foreach ($stations as $station) { - /** @var Entity\Station $station */ - $this->logger->info('Processing media for station...', [ - 'station' => $station->getName(), - ]); + $storageLocations = SimpleBatchIteratorAggregate::fromQuery($query, 1); - $this->importMusic($station); + foreach ($storageLocations as $storageLocation) { + /** @var Entity\StorageLocation $storageLocation */ + $this->logger->info(sprintf( + 'Processing media for storage location %s...', + (string)$storageLocation + )); + + $this->importMusic($storageLocation); gc_collect_cycles(); } } - public function importMusic(Entity\Station $station): void + public function importMusic(Entity\StorageLocation $storageLocation): void { - $fs = $this->filesystem->getForStation($station, false); + $adapter = $storageLocation->getStorageAdapter(); + $fs = $this->filesystem->getFilesystemForAdapter($adapter, false); $stats = [ 'total_size' => '0', @@ -100,11 +109,30 @@ class Media extends AbstractTask $music_files = []; $total_size = BigInteger::zero(); - $fsIterator = $fs->createIterator(Filesystem::PREFIX_MEDIA . '://', [ - 'filter' => FilterFactory::isFile(), - ]); + try { + $fsIterator = $fs->createIterator('/', [ + Options::OPTION_IS_RECURSIVE => true, + Options::OPTION_FILTER => FilterFactory::isFile(), + ]); + } catch (S3Exception $e) { + $this->logger->error(sprintf('S3 Error for Storage Space %s', (string)$storageLocation), [ + 'exception' => $e, + ]); + return; + } + + $protectedPaths = [ + Entity\StationMedia::DIR_ALBUM_ART, + Entity\StationMedia::DIR_WAVEFORMS, + ]; foreach ($fsIterator as $file) { + foreach ($protectedPaths as $protectedPath) { + if (0 === strpos($file['path'], $protectedPath)) { + continue 2; + } + } + if (!empty($file['size'])) { $total_size = $total_size->plus($file['size']); } @@ -113,8 +141,8 @@ class Media extends AbstractTask $music_files[$path_hash] = $file; } - $station->setStorageUsed($total_size); - $this->em->persist($station); + $storageLocation->setStorageUsed($total_size); + $this->em->persist($storageLocation); $stats['total_size'] = $total_size . ' (' . Quota::getReadableSize($total_size) . ')'; $stats['total_files'] = count($music_files); @@ -123,11 +151,10 @@ class Media extends AbstractTask $this->queueManager->clearQueue(QueueManager::QUEUE_MEDIA); // Check queue for existing pending processing entries. - $existingMediaQuery = $this->em->createQuery(/** @lang DQL */ 'SELECT - sm + $existingMediaQuery = $this->em->createQuery(/** @lang DQL */ 'SELECT sm FROM App\Entity\StationMedia sm - WHERE sm.station_id = :station_id') - ->setParameter('station_id', $station->getId()); + WHERE sm.storage_location = :storageLocation') + ->setParameter('storageLocation', $storageLocation); $iterator = SimpleBatchIteratorAggregate::fromQuery($existingMediaQuery, 10); @@ -167,7 +194,7 @@ class Media extends AbstractTask // Create files that do not currently exist. foreach ($music_files as $path_hash => $new_music_file) { $message = new Message\AddNewMediaMessage(); - $message->station_id = $station->getId(); + $message->storage_location_id = $storageLocation->getId(); $message->path = $new_music_file['path']; $this->messageBus->dispatch($message); @@ -175,17 +202,12 @@ class Media extends AbstractTask $stats['created']++; } - $this->logger->debug(sprintf('Media processed for station "%s".', $station->getName()), $stats); + $this->logger->debug(sprintf('Media processed for "%s".', (string)$storageLocation), $stats); } public function importPlaylists(Entity\Station $station): void { - $fs = $this->filesystem->getForStation($station); - - $base_dir = $station->getRadioPlaylistsDir(); - if (empty($base_dir)) { - return; - } + $fs = $this->filesystem->getForStation($station, false); // Create a lookup cache of all valid imported media. $media_lookup = []; @@ -198,12 +220,21 @@ class Media extends AbstractTask } // Iterate through playlists. - $playlist_files_raw = $this->globDirectory($base_dir, '/^.+\.(m3u|pls)$/i'); + $playlist_files_raw = $fs->createIterator( + FilesystemManager::PREFIX_PLAYLISTS . '://', + [ + 'filter' => FilterFactory::pathMatchesRegex('/^.+\.(m3u|pls)$/i'), + ] + ); - foreach ($playlist_files_raw as $playlist_file_path) { + foreach ($playlist_files_raw as $playlist_file) { // Create new StationPlaylist record. $record = new Entity\StationPlaylist($station); + $playlist_file_path = $fs->getFullPath( + FilesystemManager::PREFIX_PLAYLISTS . '://' . $playlist_file['path'] + ); + $path_parts = pathinfo($playlist_file_path); $playlist_name = str_replace('playlist_', '', $path_parts['filename']); $record->setName($playlist_name); @@ -235,23 +266,4 @@ class Media extends AbstractTask $this->em->flush(); } - - /** - * @return string[] - */ - public function globDirectory($base_dir, $regex_pattern = null): array - { - $finder = new Finder(); - $finder = $finder->files()->in($base_dir); - - if ($regex_pattern !== null) { - $finder = $finder->name($regex_pattern); - } - - $files = []; - foreach ($finder as $file) { - $files[] = $file->getPathname(); - } - return $files; - } } diff --git a/src/Sync/Task/RadioAutomation.php b/src/Sync/Task/RadioAutomation.php index 3a2b06f06..fd6d28552 100644 --- a/src/Sync/Task/RadioAutomation.php +++ b/src/Sync/Task/RadioAutomation.php @@ -224,9 +224,9 @@ class RadioAutomation extends AbstractTask $mediaQuery = $this->em->createQuery(/** @lang DQL */ 'SELECT sm FROM App\Entity\StationMedia sm - WHERE sm.station = :station + WHERE sm.storage_location = :storageLocation ORDER BY sm.artist ASC, sm.title ASC') - ->setParameter('station', $station); + ->setParameter('storageLocation', $station->getMediaStorageLocation()); $iterator = SimpleBatchIteratorAggregate::fromQuery($mediaQuery, 100); $report = []; diff --git a/src/Sync/Task/RotateLogs.php b/src/Sync/Task/RotateLogs.php index 931f5bc7f..822e72783 100644 --- a/src/Sync/Task/RotateLogs.php +++ b/src/Sync/Task/RotateLogs.php @@ -13,21 +13,29 @@ use Symfony\Component\Finder\Finder; class RotateLogs extends AbstractTask { + protected Settings $appSettings; + protected Adapters $adapters; protected Supervisor $supervisor; + protected Entity\Repository\StorageLocationRepository $storageLocationRepo; + public function __construct( EntityManagerInterface $em, Entity\Repository\SettingsRepository $settingsRepo, LoggerInterface $logger, + Settings $appSettings, Adapters $adapters, - Supervisor $supervisor + Supervisor $supervisor, + Entity\Repository\StorageLocationRepository $storageLocationRepo ) { parent::__construct($em, $settingsRepo, $logger); + $this->appSettings = $appSettings; $this->adapters = $adapters; $this->supervisor = $supervisor; + $this->storageLocationRepo = $storageLocationRepo; } public function run(bool $force = false): void @@ -49,7 +57,7 @@ class RotateLogs extends AbstractTask } // Rotate the main AzuraCast log. - $rotate = new Rotate\Rotate(Settings::getInstance()->getTempDirectory() . '/app.log'); + $rotate = new Rotate\Rotate($this->appSettings->getTempDirectory() . '/app.log'); $rotate->keep(5); $rotate->size('5MB'); $rotate->run(); @@ -58,9 +66,26 @@ class RotateLogs extends AbstractTask $backups_to_keep = (int)$this->settingsRepo->getSetting(Entity\Settings::BACKUP_KEEP_COPIES, 0); if ($backups_to_keep > 0) { - $rotate = new Rotate\Rotate(Backup::BASE_DIR . '/automatic_backup.zip'); - $rotate->keep($backups_to_keep); - $rotate->run(); + $backupStorageId = (int)$this->settingsRepo->getSetting( + Entity\Settings::BACKUP_STORAGE_LOCATION, + null + ); + + if ($backupStorageId > 0) { + $storageLocation = $this->storageLocationRepo->findByType( + Entity\StorageLocation::TYPE_BACKUP, + $backupStorageId + ); + + if ($storageLocation instanceof Entity\StorageLocation && $storageLocation->isLocal()) { + $fs = $storageLocation->getFilesystem(); + $autoBackupPath = $fs->getFullPath('automatic_backup.zip'); + + $rotate = new Rotate\Rotate($autoBackupPath); + $rotate->keep($backups_to_keep); + $rotate->run(); + } + } } } diff --git a/src/Sync/Task/StorageCleanupTask.php b/src/Sync/Task/StorageCleanupTask.php index 7291abfba..2a67ddae3 100644 --- a/src/Sync/Task/StorageCleanupTask.php +++ b/src/Sync/Task/StorageCleanupTask.php @@ -3,20 +3,21 @@ namespace App\Sync\Task; use App\Entity; -use App\Flysystem\Filesystem; +use App\Flysystem\FilesystemManager; use Doctrine\ORM\EntityManagerInterface; use DoctrineBatchUtils\BatchProcessing\SimpleBatchIteratorAggregate; use Psr\Log\LoggerInterface; +use Symfony\Component\Finder\Finder; class StorageCleanupTask extends AbstractTask { - protected Filesystem $filesystem; + protected FilesystemManager $filesystem; public function __construct( EntityManagerInterface $em, Entity\Repository\SettingsRepository $settingsRepo, LoggerInterface $logger, - Filesystem $filesystem + FilesystemManager $filesystem ) { parent::__construct($em, $settingsRepo, $logger); @@ -25,28 +26,54 @@ class StorageCleanupTask extends AbstractTask public function run(bool $force = false): void { - // Check all stations for automation settings. - // Use this to avoid detached entity errors. - $stations = SimpleBatchIteratorAggregate::fromQuery( - $this->em->createQuery(/** @lang DQL */ 'SELECT s FROM App\Entity\Station s'), - 1 - ); + $stationsQuery = $this->em->createQuery(/** @lang DQL */ 'SELECT s + FROM App\Entity\Station s'); + $stations = SimpleBatchIteratorAggregate::fromQuery($stationsQuery, 1); foreach ($stations as $station) { /** @var Entity\Station $station */ - $this->runStation($station); + $this->cleanStationTempFiles($station); + } + + // Check all stations for automation settings. + // Use this to avoid detached entity errors. + $storageLocationsQuery = $this->em->createQuery(/** @lang DQL */ 'SELECT sl + FROM App\Entity\StorageLocation sl + WHERE sl.type = :type') + ->setParameter('type', Entity\StorageLocation::TYPE_STATION_MEDIA); + + $storageLocations = SimpleBatchIteratorAggregate::fromQuery($storageLocationsQuery, 1); + foreach ($storageLocations as $storageLocation) { + /** @var Entity\StorageLocation $storageLocation */ + $this->cleanMediaStorageLocation($storageLocation); } } - protected function runStation(Entity\Station $station): void + protected function cleanStationTempFiles(Entity\Station $station): void { - $fs = $this->filesystem->getForStation($station, false); + $tempDir = $station->getRadioTempDir(); + $finder = new Finder(); + + $finder + ->files() + ->in($tempDir) + ->date('before 2 days ago'); + + foreach ($finder as $file) { + $file_path = $file->getRealPath(); + @unlink($file_path); + } + } + + protected function cleanMediaStorageLocation(Entity\StorageLocation $storageLocation): void + { + $fs = $storageLocation->getFilesystem(); $allUniqueIdsRaw = $this->em - ->createQuery(/** @lang DQL */ - 'SELECT sm.unique_id FROM App\Entity\StationMedia sm WHERE sm.station = :station' - ) - ->setParameter('station', $station) + ->createQuery(/** @lang DQL */ 'SELECT sm.unique_id + FROM App\Entity\StationMedia sm + WHERE sm.storage_location = :storageLocation') + ->setParameter('storageLocation', $storageLocation) ->getArrayResult(); $allUniqueIds = []; @@ -60,12 +87,11 @@ class StorageCleanupTask extends AbstractTask ]; $cleanupDirs = [ - 'albumart' => Filesystem::PREFIX_ALBUM_ART, - 'waveform' => Filesystem::PREFIX_WAVEFORMS, + 'albumart' => Entity\StationMedia::DIR_ALBUM_ART, + 'waveform' => Entity\StationMedia::DIR_WAVEFORMS, ]; - foreach ($cleanupDirs as $key => $prefix) { - $dirBase = $prefix . '://'; + foreach ($cleanupDirs as $key => $dirBase) { $dirContents = $fs->listContents($dirBase, true); foreach ($dirContents as $row) { diff --git a/src/Validator/Constraints/StorageLocation.php b/src/Validator/Constraints/StorageLocation.php new file mode 100644 index 000000000..8da9be77c --- /dev/null +++ b/src/Validator/Constraints/StorageLocation.php @@ -0,0 +1,25 @@ +message = __('This storage location could not be validated: %s', '{{ error }}'); + + parent::__construct($options); + } + + public function getTargets(): string + { + return self::CLASS_CONSTRAINT; + } +} diff --git a/src/Validator/Constraints/StorageLocationValidator.php b/src/Validator/Constraints/StorageLocationValidator.php new file mode 100644 index 000000000..60502e195 --- /dev/null +++ b/src/Validator/Constraints/StorageLocationValidator.php @@ -0,0 +1,38 @@ +configuration = $configuration; + } + + public function validate($storageLocation, Constraint $constraint): void + { + if (!$constraint instanceof StorageLocation) { + throw new UnexpectedTypeException($constraint, StorageLocation::class); + } + + if (!($storageLocation instanceof Entity\StorageLocation)) { + throw new UnexpectedTypeException($storageLocation, Entity\StorageLocation::class); + } + + try { + $storageLocation->validate(); + } catch (\Exception $e) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ error }}', $e->getMessage()) + ->addViolation(); + } + } +} diff --git a/templates/admin/backups/index.phtml b/templates/admin/backups/index.phtml index cefa85b85..4bb56b87d 100644 --- a/templates/admin/backups/index.phtml +++ b/templates/admin/backups/index.phtml @@ -108,9 +108,9 @@ $assets
+ ['path' => $row['pathEncoded']])?>" download="e($row['basename'])?>"> "> diff --git a/templates/admin/storage_locations/index.js.phtml b/templates/admin/storage_locations/index.js.phtml new file mode 100644 index 000000000..913c8b22f --- /dev/null +++ b/templates/admin/storage_locations/index.js.phtml @@ -0,0 +1,17 @@ + $router->fromHere('api:admin:storage_locations'), +] +?> +var adminStorageLocations; + +$(function () { + adminStorageLocations = new Vue({ + el: '#admin-storage-locations', + render: function (createElement) { + return createElement(AdminStorageLocations.default, { + props: + }); + } + }); +}); diff --git a/templates/admin/storage_locations/index.phtml b/templates/admin/storage_locations/index.phtml new file mode 100644 index 000000000..7101bd13c --- /dev/null +++ b/templates/admin/storage_locations/index.phtml @@ -0,0 +1,12 @@ +layout('main', [ + 'title' => __('Storage Locations'), + 'manual' => true, +]); + +/** @var \App\Assets $assets */ +$assets->load('AdminStorageLocations') + ->addInlineJs($this->fetch('admin/storage_locations/index.js')); +?> + +
diff --git a/templates/stations/files/index.phtml b/templates/stations/files/index.phtml index d087a5eb8..aabc3d8d3 100644 --- a/templates/stations/files/index.phtml +++ b/templates/stations/files/index.phtml @@ -28,13 +28,17 @@ $assets

-
-
- % + +
+
+ % +
-
- + + + +
@@ -60,4 +64,4 @@ $assets
- \ No newline at end of file + diff --git a/tests/functional/C05_Station_AutomationCest.php b/tests/functional/C05_Station_AutomationCest.php index adb5f0410..5ef3ee407 100644 --- a/tests/functional/C05_Station_AutomationCest.php +++ b/tests/functional/C05_Station_AutomationCest.php @@ -15,24 +15,13 @@ class C05_Station_AutomationCest extends CestAbstract // Set up automation preconditions. $testStation = $this->getTestStation(); - $song_src = '/var/azuracast/www/resources/error.mp3'; - $song_dest = $testStation->getRadioMediaDir() . '/test.mp3'; - copy($song_src, $song_dest); - $playlist = new Entity\StationPlaylist($testStation); $playlist->setName('Test Playlist'); $playlist->setIncludeInAutomation(true); $this->em->persist($playlist); - /** @var Entity\Repository\StationMediaRepository $media_repo */ - $media_repo = $this->di->get(Entity\Repository\StationMediaRepository::class); - - $media = new Entity\StationMedia($testStation, 'test.mp3'); - $media_repo->loadFromFile($media, $song_dest); - - $this->em->persist($media); - + $media = $this->uploadTestSong(); $spm = new Entity\StationPlaylistMedia($playlist, $media); $this->em->persist($spm); diff --git a/tests/functional/CestAbstract.php b/tests/functional/CestAbstract.php index 8e67c083a..71d0e4411 100644 --- a/tests/functional/CestAbstract.php +++ b/tests/functional/CestAbstract.php @@ -107,6 +107,23 @@ abstract class CestAbstract throw new RuntimeException('Test station is not established.'); } + protected function uploadTestSong(): Entity\StationMedia + { + $testStation = $this->getTestStation(); + + $songSrc = '/var/azuracast/www/resources/error.mp3'; + + $storageLocation = $testStation->getMediaStorageLocation(); + + $storageFs = $storageLocation->getFilesystem(); + $storageFs->copyFromLocal($songSrc, 'test.mp3'); + + /** @var Entity\Repository\StationMediaRepository $mediaRepo */ + $mediaRepo = $this->di->get(Entity\Repository\StationMediaRepository::class); + + return $mediaRepo->getOrCreate($storageLocation, 'test.mp3'); + } + protected function _cleanTables(): void { $clean_tables = [ diff --git a/tests/functional/D02_Api_RequestsCest.php b/tests/functional/D02_Api_RequestsCest.php index 327bc32c8..146cc2bcd 100644 --- a/tests/functional/D02_Api_RequestsCest.php +++ b/tests/functional/D02_Api_RequestsCest.php @@ -20,23 +20,12 @@ class D02_Api_RequestsCest extends CestAbstract $this->em->flush(); // Upload a test song. - $song_src = '/var/azuracast/www/resources/error.mp3'; - $song_dest = $testStation->getRadioMediaDir() . '/test.mp3'; - copy($song_src, $song_dest); + $media = $this->uploadTestSong(); $playlist = new Entity\StationPlaylist($testStation); $playlist->setName('Test Playlist'); - $this->em->persist($playlist); - /** @var Entity\Repository\StationMediaRepository $media_repo */ - $media_repo = $this->di->get(Entity\Repository\StationMediaRepository::class); - - $media = new Entity\StationMedia($testStation, 'test.mp3'); - $media_repo->loadFromFile($media, $song_dest); - - $this->em->persist($media); - $spm = new Entity\StationPlaylistMedia($playlist, $media); $this->em->persist($spm); diff --git a/web/static/api/openapi.yml b/web/static/api/openapi.yml index 162092d36..529fce738 100644 --- a/web/static/api/openapi.yml +++ b/web/static/api/openapi.yml @@ -449,6 +449,127 @@ paths: security: - api_key: [] + /admin/storage_locations: + get: + tags: + - 'Administration: Storage Locations' + description: 'List all current storage locations in the system.' + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/StorageLocation' + '403': + description: 'Access denied' + security: + - + api_key: [] + post: + tags: + - 'Administration: Storage Locations' + description: 'Create a new storage location.' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/StorageLocation' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/StorageLocation' + '403': + description: 'Access denied' + security: + - + api_key: [] + '/admin/storage_location/{id}': + get: + tags: + - 'Administration: Storage Locations' + description: 'Retrieve details for a single storage location.' + parameters: + - + name: id + in: path + description: 'User ID' + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/StorageLocation' + '403': + description: 'Access denied' + security: + - + api_key: [] + put: + tags: + - 'Administration: Storage Locations' + description: 'Update details of a single storage location.' + parameters: + - + name: id + in: path + description: 'Storage Location ID' + required: true + schema: + type: integer + format: int64 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/StorageLocation' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Api_Status' + '403': + description: 'Access denied' + security: + - + api_key: [] + delete: + tags: + - 'Administration: Storage Locations' + description: 'Delete a single storage location.' + parameters: + - + name: id + in: path + description: 'Storage Location ID' + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Api_Status' + '403': + description: 'Access denied' + security: + - + api_key: [] /admin/users: get: tags: @@ -1169,7 +1290,7 @@ paths: schema: type: array items: - $ref: '#/components/schemas/Api_QueuedSong' + $ref: '#/components/schemas/Api_StationQueueDetailed' '404': description: 'Station not found' '403': @@ -1182,7 +1303,6 @@ paths: tags: - 'Stations: Queue' description: 'Retrieve details of a single queued item.' - operationId: 'App\Controller\Api\Stations\QueueController::viewRecord' parameters: - name: id @@ -1198,7 +1318,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Api_QueuedSong' + $ref: '#/components/schemas/Api_StationQueueDetailed' '404': description: 'Station or Queue ID not found' '403': @@ -1210,7 +1330,6 @@ paths: tags: - 'Stations: Queue' description: 'Delete a single queued item.' - operationId: 'App\Controller\Api\Stations\QueueController::viewRecord' parameters: - name: id @@ -1956,7 +2075,7 @@ components: type: string example: 127.0.0.1 user_agent: - description: 'The listener''s HTTP User-Agent' + description: "The listener's HTTP User-Agent\n\nphpcs:disable Generic.Files.LineLength" type: string example: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.86 Safari/537.36' is_mobile: @@ -1966,7 +2085,7 @@ components: connected_on: description: 'UNIX timestamp that the user first connected.' type: integer - example: 1602264526 + example: 1603995457 connected_time: description: 'Number of seconds that the user has been connected.' type: integer @@ -2065,27 +2184,6 @@ components: example: '1591548318' nullable: true type: object - Api_QueuedSong: - allOf: - - - $ref: '#/components/schemas/Api_SongHistory' - - - properties: - cued_at: - description: 'UNIX timestamp when the item was cued for playback.' - type: integer - example: 1602264526 - autodj_custom_uri: - description: 'Custom AutoDJ playback URI, if it exists.' - type: string - example: '' - nullable: true - links: - type: array - items: - type: string - example: 'http://localhost/api/stations/1/queue/1' - type: object Api_Song: properties: id: @@ -2108,6 +2206,10 @@ components: description: 'The song album.' type: string example: 'Moving Castle' + genre: + description: 'The song genre.' + type: string + example: Rock lyrics: description: 'Lyrics to the song.' type: string @@ -2129,7 +2231,7 @@ components: played_at: description: 'UNIX timestamp when playback started.' type: integer - example: 1602264526 + example: 1603995457 duration: description: 'Duration of the song in seconds' type: integer @@ -2227,7 +2329,7 @@ components: cued_at: description: 'UNIX timestamp when playback is expected to start.' type: integer - example: 1602264526 + example: 1603995457 duration: description: 'Duration of the song in seconds' type: integer @@ -2243,6 +2345,23 @@ components: song: $ref: '#/components/schemas/Api_Song' type: object + Api_StationQueueDetailed: + allOf: + - + $ref: '#/components/schemas/Api_StationQueue' + - + properties: + autodj_custom_uri: + description: 'Custom AutoDJ playback URI, if it exists.' + type: string + example: '' + nullable: true + links: + type: array + items: + type: string + example: 'http://localhost/api/stations/1/queue/1' + type: object Api_StationRemote: properties: id: @@ -2302,7 +2421,7 @@ components: start_timestamp: description: 'The start time of the schedule entry, in UNIX format.' type: integer - example: 1602264526 + example: 1603995457 start: description: 'The start time of the schedule entry, in ISO 8601 format.' type: string @@ -2310,7 +2429,7 @@ components: end_timestamp: description: 'The end time of the schedule entry, in UNIX format.' type: integer - example: 1602264526 + example: 1603995457 end: description: 'The start time of the schedule entry, in ISO 8601 format.' type: string @@ -2350,7 +2469,7 @@ components: timestamp: description: 'The current UNIX timestamp' type: integer - example: 1602264526 + example: 1603995457 type: object Api_Time: properties: @@ -2412,10 +2531,10 @@ components: example: true created_at: type: integer - example: 1602264526 + example: 1603995457 updated_at: type: integer - example: 1602264526 + example: 1603995457 type: object Role: properties: @@ -2484,10 +2603,6 @@ components: type: string example: /var/azuracast/stations/azuratest_radio nullable: true - radio_media_dir: - type: string - example: /var/azuracast/stations/azuratest_radio/media - nullable: true automation_settings: type: array items: { } @@ -2524,26 +2639,10 @@ components: type: boolean example: true api_history_items: - description: 'The number of "last played" history items to show for a given station in the Now Playing API responses.' + description: 'The number of "last played" history items to show for a station in the Now Playing API responses.' type: integer example: 5 nullable: true - storage_quota: - type: string - example: '50 GB' - nullable: true - storage_quota_bytes: - type: string - example: '50000000000' - nullable: true - storage_used: - type: string - example: '1 GB' - nullable: true - storage_used_bytes: - type: string - example: '1000000000' - nullable: true timezone: description: 'The time zone that station operations should take place in.' type: string @@ -2565,6 +2664,11 @@ components: type: string example: 'Test Album' nullable: true + genre: + description: 'The genre of the media file.' + type: string + example: Rock + nullable: true lyrics: description: 'Full lyrics of the track, if available.' type: string @@ -2593,7 +2697,7 @@ components: mtime: description: 'The UNIX timestamp when the database was last modified.' type: integer - example: 1602264526 + example: 1603995457 nullable: true amplify: description: 'The amount of amplification (in dB) to be applied to the radio source;' @@ -2634,7 +2738,7 @@ components: art_updated_at: description: 'The latest time (UNIX timestamp) when album art was updated.' type: integer - example: 1602264526 + example: 1603995457 playlists: items: { } type: object @@ -2850,6 +2954,7 @@ components: type: integer example: 2200 days: + description: 'Array of ISO-8601 days (1 for Monday, 7 for Sunday)' type: string example: '0,1,2,3' type: object @@ -2880,7 +2985,7 @@ components: example: false reactivate_at: type: integer - example: 1602264526 + example: 1603995457 nullable: true schedule_items: items: { } @@ -2922,6 +3027,68 @@ components: type: array items: { } type: object + StorageLocation: + properties: + type: + description: 'The type of storage location.' + type: string + example: station_media + adapter: + description: 'The storage adapter to use for this location.' + type: string + example: local + path: + description: 'The local path, if the local adapter is used, or path prefix for S3/remote adapters.' + type: string + example: /var/azuracast/stations/azuratest_radio/media + nullable: true + s3CredentialKey: + description: 'The credential key for S3 adapters.' + type: string + example: your-key-here + nullable: true + s3CredentialSecret: + description: 'The credential secret for S3 adapters.' + type: string + example: your-secret-here + nullable: true + s3Region: + description: 'The region for S3 adapters.' + type: string + example: your-region + nullable: true + s3Version: + description: 'The API version for S3 adapters.' + type: string + example: latest + nullable: true + s3Bucket: + description: 'The S3 bucket name for S3 adapters.' + type: string + example: your-bucket-name + nullable: true + s3Endpoint: + description: 'The optional custom S3 endpoint S3 adapters.' + type: string + example: 'https://your-region.digitaloceanspaces.com' + nullable: true + storageQuota: + type: string + example: '50 GB' + nullable: true + storageQuotaBytes: + type: string + example: '50000000000' + nullable: true + storageUsed: + type: string + example: '1 GB' + nullable: true + storageUsedBytes: + type: string + example: '1000000000' + nullable: true + type: object Trait_UniqueId: properties: unique_id: @@ -2960,10 +3127,10 @@ components: nullable: true created_at: type: integer - example: 1602264526 + example: 1603995457 updated_at: type: integer - example: 1602264526 + example: 1603995457 roles: items: { } type: object