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.
This commit is contained in:
parent
073c669666
commit
6de636f475
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
],
|
||||
];
|
||||
|
|
|
@ -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).'));
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<?php
|
||||
|
||||
use App\Entity;
|
||||
|
||||
return [
|
||||
|
@ -18,7 +19,7 @@ return [
|
|||
'deselected_text' => __('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',
|
||||
]
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
|
|
@ -1,26 +1,33 @@
|
|||
<?php
|
||||
use App\Entity;
|
||||
|
||||
return [
|
||||
'elements' => [
|
||||
|
||||
'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',
|
||||
]
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
|
|
@ -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',
|
||||
],
|
||||
],
|
||||
|
|
|
@ -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',
|
||||
]
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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' => [
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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]) {
|
||||
|
|
|
@ -0,0 +1,147 @@
|
|||
<template>
|
||||
<div>
|
||||
<b-card no-body>
|
||||
<b-card-header header-bg-variant="primary-dark">
|
||||
<h2 class="card-title" key="lang_storage_locations" v-translate>Storage Locations</h2>
|
||||
</b-card-header>
|
||||
<b-tabs pills card lazy>
|
||||
<b-tab :active="activeType === 'station_media'" @click="setType('station_media')" :title="langStationMediaTab" no-body></b-tab>
|
||||
<b-tab :active="activeType === 'station_recordings'" @click="setType('station_recordings')" :title="langStationRecordingsTab" no-body></b-tab>
|
||||
<b-tab :active="activeType === 'backup'" @click="setType('backup')" :title="langBackupsTab" no-body></b-tab>
|
||||
</b-tabs>
|
||||
|
||||
<b-card-body body-class="card-padding-sm">
|
||||
<b-button variant="outline-primary" @click.prevent="doCreate">
|
||||
<i class="material-icons" aria-hidden="true">add</i>
|
||||
<translate key="lang_add_playlist">Add Storage Location</translate>
|
||||
</b-button>
|
||||
</b-card-body>
|
||||
|
||||
<data-table ref="datatable" id="admin_storage_locations" :show-toolbar="false" :fields="fields" :responsive="false"
|
||||
:api-url="listUrlForType">
|
||||
<template v-slot:cell(actions)="row">
|
||||
<b-button-group size="sm">
|
||||
<b-button size="sm" variant="primary" @click.prevent="doEdit(row.item.links.self)">
|
||||
<translate key="lang_btn_edit">Edit</translate>
|
||||
</b-button>
|
||||
<b-button size="sm" variant="danger" @click.prevent="doDelete(row.item.links.self)">
|
||||
<translate key="lang_btn_delete">Delete</translate>
|
||||
</b-button>
|
||||
</b-button-group>
|
||||
</template>
|
||||
<template v-slot:cell(adapter)="row">
|
||||
<h5 class="m-0">{{ getAdapterName(row.item.adapter) }}</h5>
|
||||
<p class="card-text">{{ row.item.uri }}</p>
|
||||
</template>
|
||||
<template v-slot:cell(stations)="row">
|
||||
{{ row.item.stations.join(', ') }}
|
||||
</template>
|
||||
</data-table>
|
||||
</b-card>
|
||||
|
||||
<edit-modal ref="editModal" :create-url="listUrl" :type="activeType" @relist="relist"></edit-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DataTable from './components/DataTable';
|
||||
import axios from 'axios';
|
||||
import EditModal from './admin_storage_locations/StorageLocationEditModal';
|
||||
|
||||
export default {
|
||||
name: 'AdminStorageLocations',
|
||||
components: { EditModal, DataTable },
|
||||
props: {
|
||||
listUrl: String
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
activeType: 'station_media',
|
||||
fields: [
|
||||
{ key: 'actions', label: this.$gettext('Actions'), sortable: false },
|
||||
{ key: 'adapter', label: this.$gettext('Adapter'), sortable: false },
|
||||
{ key: 'stations', label: this.$gettext('Station(s)'), sortable: false }
|
||||
]
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
langStationMediaTab () {
|
||||
return this.$gettext('Station Media');
|
||||
},
|
||||
langStationRecordingsTab () {
|
||||
return this.$gettext('Station Recordings');
|
||||
},
|
||||
langBackupsTab () {
|
||||
return this.$gettext('Backups');
|
||||
},
|
||||
listUrlForType () {
|
||||
return this.listUrl + '?type=' + this.activeType;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setType (type) {
|
||||
this.activeType = type;
|
||||
this.relist();
|
||||
},
|
||||
getAdapterName (adapter) {
|
||||
switch (adapter) {
|
||||
case 'local':
|
||||
return this.$gettext('Local');
|
||||
|
||||
case 's3':
|
||||
return this.$gettext('Remote S3');
|
||||
}
|
||||
},
|
||||
relist () {
|
||||
this.$refs.datatable.refresh();
|
||||
},
|
||||
doCreate () {
|
||||
this.$refs.editModal.create();
|
||||
},
|
||||
doEdit (url) {
|
||||
this.$refs.editModal.edit(url);
|
||||
},
|
||||
doModify (url) {
|
||||
notify('<b>' + this.$gettext('Applying changes...') + '</b>', 'warning', {
|
||||
delay: 3000
|
||||
});
|
||||
|
||||
axios.put(url).then((resp) => {
|
||||
notify('<b>' + resp.data.message + '</b>', 'success');
|
||||
|
||||
this.relist();
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
if (err.response.message) {
|
||||
notify('<b>' + err.response.message + '</b>', 'danger');
|
||||
}
|
||||
});
|
||||
},
|
||||
doDelete (url) {
|
||||
let buttonText = this.$gettext('Delete');
|
||||
let buttonConfirmText = this.$gettext('Delete storage location?');
|
||||
|
||||
Swal.fire({
|
||||
title: buttonConfirmText,
|
||||
confirmButtonText: buttonText,
|
||||
confirmButtonColor: '#e64942',
|
||||
showCancelButton: true,
|
||||
focusCancel: true
|
||||
}).then((result) => {
|
||||
if (result.value) {
|
||||
axios.delete(url).then((resp) => {
|
||||
notify('<b>' + resp.data.message + '</b>', 'success');
|
||||
|
||||
this.relist();
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
if (err.response.message) {
|
||||
notify('<b>' + err.response.message + '</b>', 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,208 @@
|
|||
<template>
|
||||
<b-modal size="lg" id="edit_modal" ref="modal" :title="langTitle" :busy="loading">
|
||||
<b-overlay variant="card" :show="loading">
|
||||
<b-alert variant="danger" :show="error != null">{{ error }}</b-alert>
|
||||
|
||||
<b-form class="form" @submit.prevent="doSubmit">
|
||||
<storage-location-form :form="$v.form"></storage-location-form>
|
||||
<invisible-submit-button/>
|
||||
</b-form>
|
||||
</b-overlay>
|
||||
<template v-slot:modal-footer>
|
||||
<b-button variant="default" type="button" @click="close">
|
||||
<translate key="lang_btn_close">Close</translate>
|
||||
</b-button>
|
||||
<b-button variant="primary" type="submit" @click="doSubmit" :disabled="$v.form.$invalid">
|
||||
<translate key="lang_btn_save_changes">Save Changes</translate>
|
||||
</b-button>
|
||||
</template>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import { validationMixin } from 'vuelidate';
|
||||
import required from 'vuelidate/src/validators/required';
|
||||
import InvisibleSubmitButton from '../components/InvisibleSubmitButton';
|
||||
import StorageLocationForm from './form/StorageLocationForm';
|
||||
|
||||
export default {
|
||||
name: 'EditModal',
|
||||
components: { StorageLocationForm, InvisibleSubmitButton },
|
||||
mixins: [validationMixin],
|
||||
props: {
|
||||
createUrl: String,
|
||||
type: String
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
loading: true,
|
||||
editUrl: null,
|
||||
error: null,
|
||||
form: {}
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
langTitle () {
|
||||
return this.isEditMode
|
||||
? this.$gettext('Edit Storage Location')
|
||||
: this.$gettext('Add Storage Location');
|
||||
},
|
||||
isEditMode () {
|
||||
return this.editUrl !== null;
|
||||
}
|
||||
},
|
||||
validations () {
|
||||
let validations = {
|
||||
form: {
|
||||
'adapter': { required },
|
||||
'storageQuota': {}
|
||||
}
|
||||
};
|
||||
|
||||
switch (this.form.adapter) {
|
||||
case 'local':
|
||||
validations.form.path = { required };
|
||||
validations.form.s3CredentialKey = {};
|
||||
validations.form.s3CredentialSecret = {};
|
||||
validations.form.s3Region = {};
|
||||
validations.form.s3Version = {};
|
||||
validations.form.s3Bucket = {};
|
||||
validations.form.s3Endpoint = {};
|
||||
break;
|
||||
|
||||
case 's3':
|
||||
validations.form.path = {};
|
||||
validations.form.s3CredentialKey = { required };
|
||||
validations.form.s3CredentialSecret = { required };
|
||||
validations.form.s3Region = { required };
|
||||
validations.form.s3Version = { required };
|
||||
validations.form.s3Bucket = { required };
|
||||
validations.form.s3Endpoint = { required };
|
||||
break;
|
||||
}
|
||||
|
||||
return validations;
|
||||
},
|
||||
methods: {
|
||||
resetForm () {
|
||||
this.form = {
|
||||
'adapter': 'local',
|
||||
'path': '',
|
||||
's3CredentialKey': null,
|
||||
's3CredentialSecret': null,
|
||||
's3Region': null,
|
||||
's3Version': 'latest',
|
||||
's3Bucket': null,
|
||||
's3Endpoint': null,
|
||||
'storageQuota': ''
|
||||
};
|
||||
},
|
||||
create () {
|
||||
this.resetForm();
|
||||
this.loading = false;
|
||||
this.editUrl = null;
|
||||
this.error = null;
|
||||
|
||||
this.$refs.modal.show();
|
||||
},
|
||||
edit (recordUrl) {
|
||||
this.resetForm();
|
||||
this.loading = true;
|
||||
this.editUrl = recordUrl;
|
||||
this.error = null;
|
||||
|
||||
this.$refs.modal.show();
|
||||
|
||||
axios.get(this.editUrl).then((resp) => {
|
||||
let d = resp.data;
|
||||
|
||||
this.form = {
|
||||
'adapter': d.adapter,
|
||||
'path': d.path,
|
||||
's3CredentialKey': d.s3CredentialKey,
|
||||
's3CredentialSecret': d.s3CredentialSecret,
|
||||
's3Region': d.s3Region,
|
||||
's3Version': d.s3Version,
|
||||
's3Bucket': d.s3Bucket,
|
||||
's3Endpoint': d.s3Endpoint,
|
||||
'storageQuota': d.storageQuota
|
||||
};
|
||||
|
||||
this.loading = false;
|
||||
}).catch((err) => {
|
||||
let notifyMessage = this.$gettext('An error occurred and your request could not be completed.');
|
||||
|
||||
if (error.response) {
|
||||
// Request made and server responded
|
||||
notifyMessage = error.response.data.message;
|
||||
console.log(notifyMessage);
|
||||
} else if (error.request) {
|
||||
// The request was made but no response was received
|
||||
console.log(error.request);
|
||||
} else {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
console.log('Error', error.message);
|
||||
}
|
||||
|
||||
notify('<b>' + notifyMessage + '</b>', 'danger', false);
|
||||
|
||||
this.$emit('relist');
|
||||
this.close();
|
||||
});
|
||||
},
|
||||
doSubmit () {
|
||||
this.$v.form.$touch();
|
||||
if (this.$v.form.$anyError) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.error = null;
|
||||
|
||||
let data = this.form;
|
||||
data.type = this.type;
|
||||
|
||||
axios({
|
||||
method: (this.isEditMode)
|
||||
? 'PUT'
|
||||
: 'POST',
|
||||
url: (this.isEditMode)
|
||||
? this.editUrl
|
||||
: this.createUrl,
|
||||
data: data
|
||||
}).then((resp) => {
|
||||
let notifyMessage = this.$gettext('Changes saved.');
|
||||
notify('<b>' + notifyMessage + '</b>', 'success', false);
|
||||
|
||||
this.$emit('relist');
|
||||
this.close();
|
||||
}).catch((error) => {
|
||||
let notifyMessage = this.$gettext('An error occurred and your request could not be completed.');
|
||||
|
||||
if (error.response) {
|
||||
// Request made and server responded
|
||||
notifyMessage = error.response.data.message;
|
||||
console.log(notifyMessage);
|
||||
} else if (error.request) {
|
||||
// The request was made but no response was received
|
||||
console.log(error.request);
|
||||
} else {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
console.log('Error', error.message);
|
||||
}
|
||||
|
||||
this.error = notifyMessage;
|
||||
});
|
||||
},
|
||||
close () {
|
||||
this.loading = false;
|
||||
this.editUrl = null;
|
||||
this.error = null;
|
||||
this.resetForm();
|
||||
|
||||
this.$v.form.$reset();
|
||||
this.$refs.modal.hide();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,146 @@
|
|||
<template>
|
||||
<div>
|
||||
<b-form-group>
|
||||
<b-row>
|
||||
<b-form-group class="col-md-12" label-for="form_edit_adapter">
|
||||
<template v-slot:label>
|
||||
<translate key="lang_form_edit_adapter">Storage Adapter</translate>
|
||||
</template>
|
||||
|
||||
<b-form-radio-group stacked id="edit_form_adapter" v-model="form.adapter.$model">
|
||||
<b-form-radio value="local">
|
||||
<translate key="lang_form_adapter_local">Local Filesystem</translate>
|
||||
</b-form-radio>
|
||||
<b-form-radio value="s3">
|
||||
<translate key="lang_form_adapter_s3">Remote: S3 Compatible</translate>
|
||||
</b-form-radio>
|
||||
</b-form-radio-group>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group class="col-md-6" label-for="form_edit_path">
|
||||
<template v-slot:label>
|
||||
<translate key="lang_form_edit_path">Path/Suffix</translate>
|
||||
</template>
|
||||
<template v-slot:description>
|
||||
<translate key="lang_form_edit_path_desc">For local filesystems, this is the base path of the directory. For remote filesystems, this is the folder prefix.</translate>
|
||||
</template>
|
||||
|
||||
<b-form-input id="form_edit_path" type="text" v-model="form.path.$model"
|
||||
:state="form.path.$dirty ? !form.path.$error : null"></b-form-input>
|
||||
<b-form-invalid-feedback>
|
||||
<translate key="lang_error_required">This field is required.</translate>
|
||||
</b-form-invalid-feedback>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group class="col-md-6" label-for="form_edit_storageQuota">
|
||||
<template v-slot:label>
|
||||
<translate key="lang_form_edit_storageQuota">Storage Quota</translate>
|
||||
</template>
|
||||
<template v-slot:description>
|
||||
<translate key="lang_form_edit_storageQuota_desc">Set a maximum disk space that this storage location 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.</translate>
|
||||
</template>
|
||||
|
||||
<b-form-input id="form_edit_storageQuota" type="text" v-model="form.storageQuota.$model"
|
||||
:state="form.storageQuota.$dirty ? !form.storageQuota.$error : null"></b-form-input>
|
||||
<b-form-invalid-feedback>
|
||||
<translate key="lang_error_required">This field is required.</translate>
|
||||
</b-form-invalid-feedback>
|
||||
</b-form-group>
|
||||
</b-row>
|
||||
</b-form-group>
|
||||
|
||||
<b-card v-show="form.adapter.$model === 's3'" class="mb-3" no-body>
|
||||
<div class="card-header bg-primary-dark">
|
||||
<h2 class="card-title">
|
||||
<translate key="lang_form_adapter_s3">Remote: S3 Compatible</translate>
|
||||
</h2>
|
||||
</div>
|
||||
<b-card-body>
|
||||
<b-form-group>
|
||||
<b-row>
|
||||
<b-form-group class="col-md-6" label-for="form_edit_s3CredentialKey">
|
||||
<template v-slot:label>
|
||||
<translate key="lang_form_edit_s3CredentialKey">Access Key ID</translate>
|
||||
</template>
|
||||
|
||||
<b-form-input id="form_edit_s3CredentialKey" type="text" v-model="form.s3CredentialKey.$model"
|
||||
:state="form.s3CredentialKey.$dirty ? !form.s3CredentialKey.$error : null"></b-form-input>
|
||||
<b-form-invalid-feedback>
|
||||
<translate key="lang_error_required">This field is required.</translate>
|
||||
</b-form-invalid-feedback>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group class="col-md-6" label-for="form_edit_s3CredentialSecret">
|
||||
<template v-slot:label>
|
||||
<translate key="lang_form_edit_s3CredentialSecret">Secret Key</translate>
|
||||
</template>
|
||||
|
||||
<b-form-input id="form_edit_s3CredentialSecret" type="text" v-model="form.s3CredentialSecret.$model"
|
||||
:state="form.s3CredentialSecret.$dirty ? !form.s3CredentialSecret.$error : null"></b-form-input>
|
||||
<b-form-invalid-feedback>
|
||||
<translate key="lang_error_required">This field is required.</translate>
|
||||
</b-form-invalid-feedback>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group class="col-md-6" label-for="form_edit_s3Endpoint">
|
||||
<template v-slot:label>
|
||||
<translate key="lang_form_edit_s3Endpoint">Endpoint</translate>
|
||||
</template>
|
||||
|
||||
<b-form-input id="form_edit_s3Endpoint" type="text" v-model="form.s3Endpoint.$model"
|
||||
:state="form.s3Endpoint.$dirty ? !form.s3Endpoint.$error : null"></b-form-input>
|
||||
<b-form-invalid-feedback>
|
||||
<translate key="lang_error_required">This field is required.</translate>
|
||||
</b-form-invalid-feedback>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group class="col-md-6" label-for="form_edit_s3Bucket">
|
||||
<template v-slot:label>
|
||||
<translate key="lang_form_edit_s3Bucket">Bucket Name</translate>
|
||||
</template>
|
||||
|
||||
<b-form-input id="form_edit_s3Bucket" type="text" v-model="form.s3Bucket.$model"
|
||||
:state="form.s3Bucket.$dirty ? !form.s3Bucket.$error : null"></b-form-input>
|
||||
<b-form-invalid-feedback>
|
||||
<translate key="lang_error_required">This field is required.</translate>
|
||||
</b-form-invalid-feedback>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group class="col-md-6" label-for="form_edit_s3Region">
|
||||
<template v-slot:label>
|
||||
<translate key="lang_form_edit_s3Region">Region</translate>
|
||||
</template>
|
||||
|
||||
<b-form-input id="form_edit_s3Region" type="text" v-model="form.s3Region.$model"
|
||||
:state="form.s3Region.$dirty ? !form.s3Region.$error : null"></b-form-input>
|
||||
<b-form-invalid-feedback>
|
||||
<translate key="lang_error_required">This field is required.</translate>
|
||||
</b-form-invalid-feedback>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group class="col-md-6" label-for="form_edit_s3Version">
|
||||
<template v-slot:label>
|
||||
<translate key="lang_form_edit_s3Version">API Version</translate>
|
||||
</template>
|
||||
|
||||
<b-form-input id="form_edit_s3Version" type="text" v-model="form.s3Version.$model"
|
||||
:state="form.s3Version.$dirty ? !form.s3Version.$error : null"></b-form-input>
|
||||
<b-form-invalid-feedback>
|
||||
<translate key="lang_error_required">This field is required.</translate>
|
||||
</b-form-invalid-feedback>
|
||||
</b-form-group>
|
||||
</b-row>
|
||||
</b-form-group>
|
||||
</b-card-body>
|
||||
</b-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'StorageLocationForm',
|
||||
props: {
|
||||
form: Object
|
||||
}
|
||||
};
|
||||
</script>
|
|
@ -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: {
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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('<b>' . __('Backup deleted.') . '</b>', 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];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
class StorageLocationsController
|
||||
{
|
||||
public function __invoke(ServerRequest $request, Response $response): ResponseInterface
|
||||
{
|
||||
return $request->getView()->renderToResponse($response, 'admin/storage_locations/index');
|
||||
}
|
||||
}
|
|
@ -65,6 +65,9 @@ abstract class AbstractApiCrudController
|
|||
}
|
||||
|
||||
/**
|
||||
* @param object $record
|
||||
* @param ServerRequest $request
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
protected function viewRecord($record, ServerRequest $request)
|
||||
|
|
|
@ -0,0 +1,170 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller\Api\Admin;
|
||||
|
||||
use App\Entity;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use OpenApi\Annotations as OA;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Serializer\Serializer;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
class StorageLocationsController extends AbstractAdminApiCrudController
|
||||
{
|
||||
protected string $entityClass = Entity\StorageLocation::class;
|
||||
protected string $resourceRouteName = 'api:admin:storage_location';
|
||||
|
||||
protected Entity\Repository\StorageLocationRepository $storageLocationRepo;
|
||||
|
||||
public function __construct(
|
||||
EntityManagerInterface $em,
|
||||
Serializer $serializer,
|
||||
ValidatorInterface $validator,
|
||||
Entity\Repository\StorageLocationRepository $storageLocationRepo
|
||||
) {
|
||||
parent::__construct($em, $serializer, $validator);
|
||||
|
||||
$this->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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = [];
|
||||
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Entity\Api\Admin;
|
||||
|
||||
use App\Entity;
|
||||
use OpenApi\Annotations as OA;
|
||||
|
||||
/**
|
||||
* @OA\Schema(type="object", schema="Api_Admin_StorageLocation")
|
||||
*/
|
||||
class StorageLocation extends Entity\StorageLocation
|
||||
{
|
||||
use Entity\Api\Traits\HasLinks;
|
||||
|
||||
/**
|
||||
* The URI associated with the storage location.
|
||||
*
|
||||
* @OA\Property(example="/var/azuracast/www")
|
||||
* @var string
|
||||
*/
|
||||
public string $uri;
|
||||
|
||||
/**
|
||||
* @OA\Property(
|
||||
* @OA\Items(
|
||||
* type="string",
|
||||
* example="AzuraTest Radio"
|
||||
* )
|
||||
* )
|
||||
* @var array|null The stations using this storage location, if any.
|
||||
*/
|
||||
public ?array $stations = [];
|
||||
}
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Entity\Api;
|
||||
|
||||
use App\Entity\Api\Traits\HasLinks;
|
||||
use App\Traits\LoadFromParentObject;
|
||||
use OpenApi\Annotations as OA;
|
||||
|
||||
|
@ -11,6 +12,7 @@ use OpenApi\Annotations as OA;
|
|||
class StationQueueDetailed extends StationQueue
|
||||
{
|
||||
use LoadFromParentObject;
|
||||
use HasLinks;
|
||||
|
||||
/**
|
||||
* Custom AutoDJ playback URI, if it exists.
|
||||
|
@ -19,15 +21,4 @@ class StationQueueDetailed extends StationQueue
|
|||
* @var string|null
|
||||
*/
|
||||
public ?string $autodj_custom_uri = null;
|
||||
|
||||
/**
|
||||
* @OA\Property(
|
||||
* @OA\Items(
|
||||
* type="string",
|
||||
* example="http://localhost/api/stations/1/queue/1"
|
||||
* )
|
||||
* )
|
||||
* @var array
|
||||
*/
|
||||
public array $links = [];
|
||||
}
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Entity\Api\Traits;
|
||||
|
||||
use OpenApi\Annotations as OA;
|
||||
|
||||
trait HasLinks
|
||||
{
|
||||
/**
|
||||
* @OA\Property(
|
||||
* @OA\Items(
|
||||
* type="string",
|
||||
* example="http://localhost/api/stations/1/queue/1"
|
||||
* )
|
||||
* )
|
||||
* @var array
|
||||
*/
|
||||
public array $links = [];
|
||||
}
|
|
@ -42,8 +42,6 @@ class SongApiGenerator
|
|||
$response->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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -0,0 +1,184 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Migration;
|
||||
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20201027130404 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Song storage consolidation, part 1.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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)');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Migration;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20201027130504 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Song storage consolidation, part 2.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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.'));
|
||||
|
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
namespace App\Entity\Repository;
|
||||
|
||||
use App\Doctrine\Repository;
|
||||
use App\Entity;
|
||||
|
||||
class StorageLocationRepository extends Repository
|
||||
{
|
||||
public function findByType(string $type, int $id): ?Entity\StorageLocation
|
||||
{
|
||||
return $this->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();
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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), '/');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -0,0 +1,505 @@
|
|||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Annotations\AuditLog;
|
||||
use App\Flysystem\Filesystem;
|
||||
use App\Radio\Quota;
|
||||
use App\Validator\Constraints as AppAssert;
|
||||
use Aws\S3\S3Client;
|
||||
use Brick\Math\BigInteger;
|
||||
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 League\Flysystem\AwsS3v3\AwsS3Adapter;
|
||||
use League\Flysystem\Config;
|
||||
use OpenApi\Annotations as OA;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* @ORM\Table(name="storage_location")
|
||||
* @ORM\Entity()
|
||||
*
|
||||
* @OA\Schema(type="object", schema="StorageLocation")
|
||||
*
|
||||
* @AuditLog\Auditable
|
||||
* @AppAssert\StorageLocation()
|
||||
*/
|
||||
class StorageLocation
|
||||
{
|
||||
use Traits\TruncateStrings;
|
||||
|
||||
public const TYPE_BACKUP = 'backup';
|
||||
public const TYPE_STATION_MEDIA = 'station_media';
|
||||
public const TYPE_STATION_RECORDINGS = 'station_recordings';
|
||||
|
||||
public const ADAPTER_LOCAL = 'local';
|
||||
public const ADAPTER_S3 = 's3';
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="id", type="integer")
|
||||
* @ORM\Id
|
||||
* @ORM\GeneratedValue(strategy="IDENTITY")
|
||||
*
|
||||
* @var int|null
|
||||
*/
|
||||
protected $id;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="type", type="string", length=50)
|
||||
*
|
||||
* @Assert\Choice(choices={
|
||||
* StorageLocation::TYPE_BACKUP,
|
||||
* StorageLocation::TYPE_STATION_MEDIA,
|
||||
* StorageLocation::TYPE_STATION_RECORDINGS
|
||||
* })
|
||||
* @OA\Property(example="station_media")
|
||||
* @var string The type of storage location.
|
||||
*/
|
||||
protected $type;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="adapter", type="string", length=50)
|
||||
*
|
||||
* @Assert\Choice(choices={StorageLocation::ADAPTER_LOCAL, StorageLocation::ADAPTER_S3})
|
||||
* @OA\Property(example="local")
|
||||
* @var string The storage adapter to use for this location.
|
||||
*/
|
||||
protected $adapter = self::ADAPTER_LOCAL;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="path", type="string", length=255, nullable=true)
|
||||
*
|
||||
* @OA\Property(example="/var/azuracast/stations/azuratest_radio/media")
|
||||
* @var string|null The local path, if the local adapter is used, or path prefix for S3/remote adapters.
|
||||
*/
|
||||
protected $path;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="s3_credential_key", type="string", length=255, nullable=true)
|
||||
*
|
||||
* @OA\Property(example="your-key-here")
|
||||
* @var string|null The credential key for S3 adapters.
|
||||
*/
|
||||
protected $s3CredentialKey;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="s3_credential_secret", type="string", length=255, nullable=true)
|
||||
*
|
||||
* @OA\Property(example="your-secret-here")
|
||||
* @var string|null The credential secret for S3 adapters.
|
||||
*/
|
||||
protected $s3CredentialSecret;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="s3_region", type="string", length=150, nullable=true)
|
||||
*
|
||||
* @OA\Property(example="your-region")
|
||||
* @var string|null The region for S3 adapters.
|
||||
*/
|
||||
protected $s3Region;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="s3_version", type="string", length=150, nullable=true)
|
||||
*
|
||||
* @OA\Property(example="latest")
|
||||
* @var string|null The API version for S3 adapters.
|
||||
*/
|
||||
protected $s3Version = 'latest';
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="s3_bucket", type="string", length=255, nullable=true)
|
||||
*
|
||||
* @OA\Property(example="your-bucket-name")
|
||||
* @var string|null The S3 bucket name for S3 adapters.
|
||||
*/
|
||||
protected $s3Bucket = null;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="s3_endpoint", type="string", length=255, nullable=true)
|
||||
*
|
||||
* @OA\Property(example="https://your-region.digitaloceanspaces.com")
|
||||
* @var string|null The optional custom S3 endpoint S3 adapters.
|
||||
*/
|
||||
protected $s3Endpoint = null;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="storage_quota", type="bigint", nullable=true)
|
||||
*
|
||||
* @OA\Property(example="50 GB")
|
||||
* @var string|null
|
||||
*/
|
||||
protected $storageQuota;
|
||||
|
||||
/**
|
||||
* @OA\Property(example="50000000000")
|
||||
* @var string|null
|
||||
*/
|
||||
protected $storageQuotaBytes;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="storage_used", type="bigint", nullable=true)
|
||||
*
|
||||
* @AuditLog\AuditIgnore()
|
||||
*
|
||||
* @OA\Property(example="1 GB")
|
||||
* @var string|null
|
||||
*/
|
||||
protected $storageUsed;
|
||||
|
||||
/**
|
||||
* @OA\Property(example="1000000000")
|
||||
* @var string|null
|
||||
*/
|
||||
protected $storageUsedBytes;
|
||||
|
||||
/**
|
||||
* @ORM\OneToMany(targetEntity="StationMedia", mappedBy="storage_location")
|
||||
* @var Collection|StationMedia[]
|
||||
*/
|
||||
protected $media;
|
||||
|
||||
public function __construct(string $type, string $adapter)
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,139 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Flysystem;
|
||||
|
||||
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\AbstractCache;
|
||||
use League\Flysystem\Filesystem;
|
||||
use League\Flysystem\MountManager;
|
||||
use RuntimeException;
|
||||
|
||||
class FilesystemGroup extends MountManager
|
||||
{
|
||||
/**
|
||||
* "Upload" a local path into the Flysystem abstract filesystem.
|
||||
*
|
||||
* @param string $local_path
|
||||
* @param string $to
|
||||
* @param array $config
|
||||
*/
|
||||
public function upload($local_path, $to, array $config = []): bool
|
||||
{
|
||||
if (!file_exists($local_path)) {
|
||||
throw new RuntimeException(sprintf('Source upload file not found at path: %s', $local_path));
|
||||
}
|
||||
|
||||
$stream = fopen($local_path, 'rb+');
|
||||
|
||||
$uploaded = $this->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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Flysystem;
|
||||
|
||||
use App\Http\Response;
|
||||
use Iterator;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
interface FilesystemInterface extends \League\Flysystem\FilesystemInterface
|
||||
{
|
||||
public function clearCache(bool $inMemoryOnly = false): void;
|
||||
|
||||
public function getFullPath(string $uri): string;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Read a stream from the filesystem and directly write it to a PSR-7-compatible response object.
|
||||
*
|
||||
* @param Response $response The original PSR-7 response.
|
||||
* @param string $path The path on the filesystem to stream.
|
||||
* @param string|null $fileName
|
||||
* @param string $disposition
|
||||
*
|
||||
* @return ResponseInterface The modified PSR-7 response.
|
||||
*/
|
||||
public function streamToResponse(
|
||||
Response $response,
|
||||
string $path,
|
||||
string $fileName = null,
|
||||
string $disposition = 'attachment'
|
||||
): ResponseInterface;
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
namespace App\Flysystem;
|
||||
|
||||
use App\Entity;
|
||||
use Cache\Prefixed\PrefixedCachePool;
|
||||
use League\Flysystem\Adapter\AbstractAdapter;
|
||||
use League\Flysystem\AdapterInterface;
|
||||
use League\Flysystem\AwsS3v3\AwsS3Adapter;
|
||||
use League\Flysystem\Cached\CachedAdapter;
|
||||
use League\Flysystem\Cached\Storage\Psr6Cache;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
|
||||
/**
|
||||
* A wrapper and manager class for accessing assets on the filesystem.
|
||||
*/
|
||||
class FilesystemManager
|
||||
{
|
||||
public const PREFIX_MEDIA = 'media';
|
||||
public const PREFIX_PLAYLISTS = 'playlists';
|
||||
public const PREFIX_CONFIG = 'config';
|
||||
public const PREFIX_RECORDINGS = 'recordings';
|
||||
public const PREFIX_TEMP = 'temp';
|
||||
|
||||
protected CacheItemPoolInterface $cachePool;
|
||||
|
||||
public function __construct(CacheItemPoolInterface $cachePool)
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
|
@ -1,61 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Flysystem;
|
||||
|
||||
use App\Exception;
|
||||
|
||||
class StationFilesystem extends FilesystemGroup
|
||||
{
|
||||
/**
|
||||
* Copy a file from the specified path to the temp directory
|
||||
*
|
||||
* @param string $from The permanent path to copy from
|
||||
* @param string|null $to The temporary path to copy to (temp://original if not specified)
|
||||
*
|
||||
* @return string The temporary path
|
||||
*/
|
||||
public function copyToTemp($from, $to = null): string
|
||||
{
|
||||
[, $path_from] = $this->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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
namespace App\Flysystem;
|
||||
|
||||
use App\Exception;
|
||||
use App\Http\Response;
|
||||
use Iterator;
|
||||
use League\Flysystem\MountManager;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
class StationFilesystemGroup extends MountManager implements FilesystemInterface
|
||||
{
|
||||
public function upload(string $localPath, string $to): bool
|
||||
{
|
||||
[$prefix, $path] = $this->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);
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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? <a href="%s" target="_blank">Install it here</a>, 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 = __(
|
||||
'<a href="%s" target="_blank">Manage storage locations here</a>.',
|
||||
$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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = [];
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
/**
|
||||
* @Annotation
|
||||
*/
|
||||
class StorageLocation extends Constraint
|
||||
{
|
||||
public $message;
|
||||
|
||||
public function __construct($options = null)
|
||||
{
|
||||
$this->message = __('This storage location could not be validated: %s', '{{ error }}');
|
||||
|
||||
parent::__construct($options);
|
||||
}
|
||||
|
||||
public function getTargets(): string
|
||||
{
|
||||
return self::CLASS_CONSTRAINT;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use App\Entity;
|
||||
use App\Radio\Configuration;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
class StorageLocationValidator extends ConstraintValidator
|
||||
{
|
||||
protected Configuration $configuration;
|
||||
|
||||
public function __construct(Configuration $configuration)
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -108,9 +108,9 @@ $assets
|
|||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a class="btn btn-sm btn-primary" href="<?=$router->fromHere('admin:backups:download',
|
||||
['path' => base64_encode($row['path'])])?>" download="<?=$this->e($row['basename'])?>"><?=__('Download')?></a>
|
||||
['path' => $row['pathEncoded']])?>" download="<?=$this->e($row['basename'])?>"><?=__('Download')?></a>
|
||||
<a class="btn btn-sm btn-danger" href="<?=$router->fromHere('admin:backups:delete', [
|
||||
'path' => base64_encode($row['path']),
|
||||
'path' => $row['pathEncoded'],
|
||||
'csrf' => $csrf,
|
||||
])?>" data-confirm-title="<?=$this->e(__('Delete backup "%s"?',
|
||||
$row['filename']))?>"><?=__('Delete')?></a>
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
$props = [
|
||||
'listUrl' => $router->fromHere('api:admin:storage_locations'),
|
||||
]
|
||||
?>
|
||||
var adminStorageLocations;
|
||||
|
||||
$(function () {
|
||||
adminStorageLocations = new Vue({
|
||||
el: '#admin-storage-locations',
|
||||
render: function (createElement) {
|
||||
return createElement(AdminStorageLocations.default, {
|
||||
props: <?=json_encode($props) ?>
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
$this->layout('main', [
|
||||
'title' => __('Storage Locations'),
|
||||
'manual' => true,
|
||||
]);
|
||||
|
||||
/** @var \App\Assets $assets */
|
||||
$assets->load('AdminStorageLocations')
|
||||
->addInlineJs($this->fetch('admin/storage_locations/index.js'));
|
||||
?>
|
||||
|
||||
<div id="admin-storage-locations"></div>
|
|
@ -28,13 +28,17 @@ $assets
|
|||
<h2 class="card-title"><?=__('Music Files')?></h2>
|
||||
</div>
|
||||
<div class="col-md-5 text-right text-white-50">
|
||||
<div class="progress">
|
||||
<div class="progress-bar" role="progressbar" aria-valuenow="<?=$space_percent?>"
|
||||
aria-valuemin="0" aria-valuemax="100" style="width: <?=$space_percent?>%;">
|
||||
<span class="sr-only"><?=$space_percent?>%</span>
|
||||
<?php if ($space_total): ?>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" role="progressbar" aria-valuenow="<?=$space_percent?>"
|
||||
aria-valuemin="0" aria-valuemax="100" style="width: <?=$space_percent?>%;">
|
||||
<span class="sr-only"><?=$space_percent?>%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?=__('%s of %s Used (%d Files)', $space_used, $space_total, $files_count)?>
|
||||
<?=__('%s of %s Used (%d Files)', $space_used, $space_total, $files_count)?>
|
||||
<?php else: ?>
|
||||
<?=__('%s Used (%d Files)', $space_used, $files_count)?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -60,4 +64,4 @@ $assets
|
|||
<div id="media-manager"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue