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:
Buster "Silver Eagle" Neece 2020-11-09 21:06:48 -06:00 committed by GitHub
parent 073c669666
commit 6de636f475
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
100 changed files with 3598 additions and 1385 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -65,6 +65,9 @@ abstract class AbstractApiCrudController
}
/**
* @param object $record
* @param ServerRequest $request
*
* @return mixed
*/
protected function viewRecord($record, ServerRequest $request)

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = [];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = [];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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