Add back ability to import playlist file from existing M3U/PLS file.

This commit is contained in:
Buster "Silver Eagle" Neece 2020-04-12 02:23:45 -05:00
parent 7c19adc03d
commit 51f82d1ceb
No known key found for this signature in database
GPG Key ID: 6D9E12FF03411F4E
8 changed files with 245 additions and 187 deletions

View File

@ -227,14 +227,17 @@ return function (App $app) {
$group->put('/toggle', Controller\Api\Stations\PlaylistsController::class . ':toggleAction')
->setName('api:stations:playlist:toggle');
$group->get('/order', Controller\Api\Stations\PlaylistsController::class . ':getOrderAction')
->setName('api:stations:playlist:order');
$group->put('/reshuffle', Controller\Api\Stations\PlaylistsController::class . ':reshuffleAction')
->setName('api:stations:playlist:reshuffle');
$group->get('/order', Controller\Api\Stations\PlaylistsController::class . ':getOrderAction')
->setName('api:stations:playlist:order');
$group->put('/order', Controller\Api\Stations\PlaylistsController::class . ':putOrderAction');
$group->post('/import', Controller\Api\Stations\PlaylistsController::class . ':importAction')
->setName('api:stations:playlist:import');
$group->get('/export[/{format}]',
Controller\Api\Stations\PlaylistsController::class . ':exportAction')
->setName('api:stations:playlist:export');

View File

@ -36,6 +36,10 @@
<b-dropdown-item @click.prevent="doModify(row.item.links.toggle)">
{{ langToggleButton(row.item) }}
</b-dropdown-item>
<b-dropdown-item @click.prevent="doImport(row.item.links.import)"
v-if="row.item.source === 'songs'">
{{ langImportButton }}
</b-dropdown-item>
<b-dropdown-item @click.prevent="doReorder(row.item.links.order)"
v-if="row.item.source === 'songs' && row.item.order === 'sequential'">
{{ langReorderButton }}
@ -102,6 +106,7 @@
<edit-modal ref="editModal" :create-url="listUrl" :station-time-zone="stationTimeZone"
@relist="relist"></edit-modal>
<reorder-modal ref="reorderModal"></reorder-modal>
<import-modal ref="importModal" @relist="relist"></import-modal>
</div>
</template>
@ -110,11 +115,12 @@
import Schedule from './components/ScheduleView';
import EditModal from './station_playlists/PlaylistEditModal';
import ReorderModal from './station_playlists/PlaylistReorderModal';
import ImportModal from './station_playlists/PlaylistImportModal';
import axios from 'axios';
export default {
name: 'StationPlaylists',
components: { ReorderModal, EditModal, Schedule, DataTable },
components: { ImportModal, ReorderModal, EditModal, Schedule, DataTable },
props: {
listUrl: String,
scheduleUrl: String,
@ -147,6 +153,9 @@
},
langReshuffleButton () {
return this.$gettext('Reshuffle');
},
langImportButton () {
return this.$gettext('Import from PLS/M3U');
}
},
mounted () {
@ -212,6 +221,9 @@
doReorder (url) {
this.$refs.reorderModal.open(url);
},
doImport (url) {
this.$refs.importModal.open(url);
},
doModify (url) {
notify('<b>' + this.$gettext('Applying changes...') + '</b>', 'warning', {
delay: 3000

View File

@ -0,0 +1,80 @@
<template>
<b-modal id="import_modal" ref="modal" :title="langTitle">
<b-form class="form" @submit.prevent="doSubmit">
<b-form-group label-for="import_modal_playlist_file">
<template v-slot:label>
<translate>Select PLS/M3U File to Import</translate>
</template>
<template v-slot:description>
<translate>AzuraCast will scan the uploaded file for matches in this station's music library. Media should already be uploaded before running this step. You can re-run this tool as many times as needed.</translate>
</template>
<b-form-file id="import_modal_playlist_file" v-model="playlistFile"></b-form-file>
</b-form-group>
<invisible-submit-button/>
</b-form>
<template v-slot:modal-footer>
<b-button variant="default" type="button" @click="close">
<translate>Close</translate>
</b-button>
<b-button variant="primary" type="submit" @click="doSubmit">
<translate>Import from PLS/M3U</translate>
</b-button>
</template>
</b-modal>
</template>
<script>
import axios from 'axios';
import InvisibleSubmitButton from '../components/InvisibleSubmitButton';
export default {
name: 'PlaylistImportModal',
components: { InvisibleSubmitButton },
data () {
return {
importPlaylistUrl: null,
playlistFile: null
};
},
computed: {
langTitle () {
return this.$gettext('Import from PLS/M3U');
}
},
methods: {
open (importPlaylistUrl) {
this.playlistFile = null;
this.importPlaylistUrl = importPlaylistUrl;
this.$refs.modal.show();
},
doSubmit () {
let formData = new FormData();
formData.append('playlist_file', this.playlistFile);
axios.post(this.importPlaylistUrl, formData).then((resp) => {
if (resp.data.success) {
notify('<b>' + resp.data.message + '</b>', 'success', false);
} else {
notify('<b>' + resp.data.message + '</b>', 'danger', false);
}
this.$emit('relist');
this.close();
}).catch((err) => {
console.error(err);
let notifyMessage = this.$gettext('An error occurred and your request could not be completed.');
notify('<b>' + notifyMessage + '</b>', 'danger', false);
this.$emit('relist');
this.close();
});
},
close () {
this.$refs.modal.hide();
}
}
};
</script>

View File

@ -5,7 +5,7 @@ msgstr ""
"Generated-By: easygettext\n"
"Project-Id-Version: \n"
#: ./vue/StationPlaylists.vue:131
#: ./vue/StationPlaylists.vue:137
msgid "# Songs"
msgstr ""
@ -26,7 +26,7 @@ msgid "Account List"
msgstr ""
#: ./vue/StationMedia.vue:189
#: ./vue/StationPlaylists.vue:128
#: ./vue/StationPlaylists.vue:134
#: ./vue/StationStreamers.vue:83
#: ./vue/station_playlists/PlaylistReorderModal.vue:11
#: ./vue/station_streamers/StreamerBroadcastsModal.vue:71
@ -76,7 +76,7 @@ msgstr ""
msgid "Album Art"
msgstr ""
#: ./vue/StationPlaylists.vue:137
#: ./vue/StationPlaylists.vue:143
msgid "All Playlists"
msgstr ""
@ -92,11 +92,12 @@ msgstr ""
#: ./vue/station_media/MediaMoveFilesModal.vue:92
#: ./vue/station_media/MediaNewDirectoryModal.vue:76
#: ./vue/station_playlists/PlaylistEditModal.vue:179
#: ./vue/station_playlists/PlaylistImportModal.vue:68
#: ./vue/station_streamers/StreamerEditModal.vue:152
msgid "An error occurred and your request could not be completed."
msgstr ""
#: ./vue/StationPlaylists.vue:216
#: ./vue/StationPlaylists.vue:228
#: ./vue/station_media/MediaToolbar.vue:194
msgid "Applying changes..."
msgstr ""
@ -107,10 +108,14 @@ msgstr ""
msgid "Artist"
msgstr ""
#: ./vue/StationPlaylists.vue:75
#: ./vue/StationPlaylists.vue:79
msgid "Auto-Assigned"
msgstr ""
#: ./vue/station_playlists/PlaylistImportModal.vue:8
msgid "AzuraCast will scan the uploaded file for matches in this station's music library. Media should already be uploaded before running this step. You can re-run this tool as many times as needed."
msgstr ""
#: ./vue/station_media/MediaMoveFilesModal.vue:6
msgid "Back"
msgstr ""
@ -143,6 +148,7 @@ msgstr ""
#: ./vue/station_media/MediaNewDirectoryModal.vue:15
#: ./vue/station_media/MediaRenameModal.vue:16
#: ./vue/station_playlists/PlaylistEditModal.vue:18
#: ./vue/station_playlists/PlaylistImportModal.vue:17
#: ./vue/station_streamers/StreamerBroadcastsModal.vue:20
#: ./vue/station_streamers/StreamerEditModal.vue:15
msgid "Close"
@ -170,7 +176,7 @@ msgstr ""
msgid "Cue"
msgstr ""
#: ./vue/StationPlaylists.vue:192
#: ./vue/StationPlaylists.vue:201
msgid "Custom"
msgstr ""
@ -203,7 +209,7 @@ msgid "Default"
msgstr ""
#: ./vue/StationPlaylists.vue:31
#: ./vue/StationPlaylists.vue:232
#: ./vue/StationPlaylists.vue:244
#: ./vue/StationStreamers.vue:43
#: ./vue/StationStreamers.vue:121
#: ./vue/station_media/MediaToolbar.vue:53
@ -225,7 +231,7 @@ msgstr ""
msgid "Delete broadcast?"
msgstr ""
#: ./vue/StationPlaylists.vue:233
#: ./vue/StationPlaylists.vue:245
msgid "Delete playlist?"
msgstr ""
@ -242,12 +248,12 @@ msgstr ""
msgid "Directory Name"
msgstr ""
#: ./vue/StationPlaylists.vue:161
#: ./vue/StationPlaylists.vue:170
msgid "Disable"
msgstr ""
#: ./vue/StationPlaylists.vue:78
#: ./vue/StationPlaylists.vue:172
#: ./vue/StationPlaylists.vue:82
#: ./vue/StationPlaylists.vue:181
#: ./vue/StationStreamers.vue:30
msgid "Disabled"
msgstr ""
@ -290,7 +296,7 @@ msgstr ""
msgid "Edit Streamer"
msgstr ""
#: ./vue/StationPlaylists.vue:162
#: ./vue/StationPlaylists.vue:171
msgid "Enable"
msgstr ""
@ -321,7 +327,7 @@ msgstr ""
msgid "Enforce Schedule Times"
msgstr ""
#: ./vue/StationPlaylists.vue:48
#: ./vue/StationPlaylists.vue:52
msgid "Export %{format}"
msgstr ""
@ -351,7 +357,7 @@ msgstr ""
msgid "Full Volume"
msgstr ""
#: ./vue/StationPlaylists.vue:177
#: ./vue/StationPlaylists.vue:186
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:45
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:87
msgid "General Rotation"
@ -401,6 +407,12 @@ msgstr ""
msgid "If the end time is before the start time, the schedule entry will continue overnight."
msgstr ""
#: ./vue/StationPlaylists.vue:158
#: ./vue/station_playlists/PlaylistImportModal.vue:20
#: ./vue/station_playlists/PlaylistImportModal.vue:42
msgid "Import from PLS/M3U"
msgstr ""
#: ./vue/station_playlists/form/PlaylistFormBasicInfo.vue:96
msgid "Include in Automated Assignment"
msgstr ""
@ -425,7 +437,7 @@ msgstr ""
msgid "ISRC"
msgstr ""
#: ./vue/StationPlaylists.vue:68
#: ./vue/StationPlaylists.vue:72
msgid "Jingle Mode"
msgstr ""
@ -492,7 +504,7 @@ msgstr ""
msgid "Monday"
msgstr ""
#: ./vue/StationPlaylists.vue:143
#: ./vue/StationPlaylists.vue:149
msgid "More"
msgstr ""
@ -566,11 +578,11 @@ msgstr ""
msgid "Number of Songs Between Plays"
msgstr ""
#: ./vue/StationPlaylists.vue:184
#: ./vue/StationPlaylists.vue:193
msgid "Once per %{minutes} Minutes"
msgstr ""
#: ./vue/StationPlaylists.vue:180
#: ./vue/StationPlaylists.vue:189
msgid "Once per %{songs} Songs"
msgstr ""
@ -579,7 +591,7 @@ msgstr ""
msgid "Once per Hour"
msgstr ""
#: ./vue/StationPlaylists.vue:188
#: ./vue/StationPlaylists.vue:197
msgid "Once per Hour (at %{minute})"
msgstr ""
@ -630,7 +642,7 @@ msgstr ""
msgid "Play/Pause"
msgstr ""
#: ./vue/StationPlaylists.vue:129
#: ./vue/StationPlaylists.vue:135
msgid "Playlist"
msgstr ""
@ -692,7 +704,7 @@ msgstr ""
msgid "Remote Playback Buffer (Seconds)"
msgstr ""
#: ./vue/StationPlaylists.vue:63
#: ./vue/StationPlaylists.vue:67
#: ./vue/station_playlists/form/PlaylistFormSource.vue:80
msgid "Remote URL"
msgstr ""
@ -720,7 +732,7 @@ msgstr ""
msgid "Rename File/Directory"
msgstr ""
#: ./vue/StationPlaylists.vue:146
#: ./vue/StationPlaylists.vue:152
msgid "Reorder"
msgstr ""
@ -736,7 +748,7 @@ msgstr ""
msgid "Replace Album Cover Art"
msgstr ""
#: ./vue/StationPlaylists.vue:149
#: ./vue/StationPlaylists.vue:155
msgid "Reshuffle"
msgstr ""
@ -769,7 +781,7 @@ msgstr ""
msgid "Schedule"
msgstr ""
#: ./vue/StationPlaylists.vue:140
#: ./vue/StationPlaylists.vue:146
#: ./vue/StationStreamers.vue:95
msgid "Schedule View"
msgstr ""
@ -784,7 +796,7 @@ msgstr ""
msgid "Scheduled Time #%{num}"
msgstr ""
#: ./vue/StationPlaylists.vue:130
#: ./vue/StationPlaylists.vue:136
msgid "Scheduling"
msgstr ""
@ -816,11 +828,15 @@ msgstr ""
msgid "Select File"
msgstr ""
#: ./vue/station_playlists/PlaylistImportModal.vue:5
msgid "Select PLS/M3U File to Import"
msgstr ""
#: ./vue/components/DataTable.vue:181
msgid "Select this row"
msgstr ""
#: ./vue/StationPlaylists.vue:72
#: ./vue/StationPlaylists.vue:76
msgid "Sequential"
msgstr ""
@ -881,7 +897,7 @@ msgstr ""
msgid "Song Title"
msgstr ""
#: ./vue/StationPlaylists.vue:60
#: ./vue/StationPlaylists.vue:64
msgid "Song-based"
msgstr ""
@ -1116,6 +1132,6 @@ msgstr ""
msgid "Wednesday"
msgstr ""
#: ./vue/StationPlaylists.vue:177
#: ./vue/StationPlaylists.vue:186
msgid "Weight"
msgstr ""

View File

@ -6,10 +6,12 @@ use App\Exception;
use App\Exception\NotFoundException;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Radio\PlaylistParser;
use Cake\Chronos\Chronos;
use InvalidArgumentException;
use OpenApi\Annotations as OA;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UploadedFileInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
class PlaylistsController extends AbstractScheduledEntityController
@ -285,6 +287,103 @@ class PlaylistsController extends AbstractScheduledEntityController
));
}
public function importAction(
ServerRequest $request,
Response $response,
Entity\Repository\StationPlaylistMediaRepository $playlistMediaRepo,
$id
): ResponseInterface {
/** @var Entity\StationPlaylist $playlist */
$playlist = $this->getRecord($request->getStation(), $id);
$files = $request->getUploadedFiles();
if (empty($files['playlist_file'])) {
return $response->withStatus(500)
->withJson(new Entity\Api\Error(500, 'No "playlist_file" provided.'));
}
/** @var UploadedFileInterface $file */
$file = $files['playlist_file'];
if (UPLOAD_ERR_OK !== $file->getError()) {
return $response->withStatus(500)
->withJson(new Entity\Api\Error(500, $file->getError()));
}
$playlistFile = $file->getStream()->getContents();
$paths = PlaylistParser::getSongs($playlistFile);
$totalPaths = count($paths);
$foundPaths = 0;
if (!empty($paths)) {
$station = $request->getStation();
// Assemble list of station media to match against.
$media_lookup = [];
$media_info_raw = $this->em->createQuery(/** @lang DQL */ 'SELECT sm.id, sm.path
FROM App\Entity\StationMedia sm
WHERE sm.station = :station')
->setParameter('station', $station)
->getArrayResult();
foreach ($media_info_raw as $row) {
$path_hash = md5($row['path']);
$media_lookup[$path_hash] = $row['id'];
}
// Run all paths against the lookup list of hashes.
$matches = [];
foreach ($paths as $path_raw) {
// De-Windows paths (if applicable)
$path_raw = str_replace('\\', '/', $path_raw);
// Work backwards from the basename to try to find matches.
$path_parts = explode('/', $path_raw);
for ($i = 1, $iMax = count($path_parts); $i <= $iMax; $i++) {
$path_attempt = implode('/', array_slice($path_parts, 0 - $i));
$path_hash = md5($path_attempt);
if (isset($media_lookup[$path_hash])) {
$matches[] = $media_lookup[$path_hash];
}
}
}
// Assign all matched media to the playlist.
if (!empty($matches)) {
$matched_media = $this->em->createQuery(/** @lang DQL */ 'SELECT sm
FROM App\Entity\StationMedia sm
WHERE sm.station = :station AND sm.id IN (:matched_ids)')
->setParameter('station', $station)
->setParameter('matched_ids', $matches)
->execute();
$weight = $playlistMediaRepo->getHighestSongWeight($playlist);
foreach ($matched_media as $media) {
$weight++;
/** @var Entity\StationMedia $media */
$playlistMediaRepo->addMediaToPlaylist($media, $playlist, $weight);
$foundPaths++;
}
}
$this->em->flush();
}
return $response->withJson(new Entity\Api\Status(
true,
__('Playlist successfully imported; %d of %d files were successfully matched.', $foundPaths, $totalPaths)
));
}
protected function viewRecord($record, \App\Http\ServerRequest $request)
{
if (!($record instanceof $this->entityClass)) {
@ -312,6 +411,7 @@ class PlaylistsController extends AbstractScheduledEntityController
'order' => $router->fromHere('api:stations:playlist:order', ['id' => $record->getId()], [], !$isInternal),
'reshuffle' => $router->fromHere('api:stations:playlist:reshuffle', ['id' => $record->getId()], [],
!$isInternal),
'import' => $router->fromHere('api:stations:playlist:import', ['id' => $record->getId()], [], !$isInternal),
'self' => $router->fromHere($this->resourceRouteName, ['id' => $record->getId()], [], !$isInternal),
];

View File

@ -1,153 +0,0 @@
<?php
namespace App\Form;
use App\Customization;
use App\Entity;
use App\Http\ServerRequest;
use App\Radio\PlaylistParser;
use App\Config;
use App\Session\Flash;
use AzuraForms\Field\Markup;
use Cake\Chronos\Chronos;
use DateTimeZone;
use Doctrine\ORM\EntityManager;
use Psr\Http\Message\UploadedFileInterface;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class StationPlaylistForm extends EntityForm
{
protected Entity\Repository\StationPlaylistMediaRepository $playlistMediaRepo;
public function __construct(
EntityManager $em,
Entity\Repository\StationPlaylistMediaRepository $playlistMediaRepo,
Serializer $serializer,
ValidatorInterface $validator,
Config $config,
Customization $customization
) {
$form_config = $config->get('forms/playlist', [
'customization' => $customization,
]);
parent::__construct($em, $serializer, $validator, $form_config);
$this->entityClass = Entity\StationPlaylist::class;
$this->playlistMediaRepo = $playlistMediaRepo;
}
public function process(ServerRequest $request, $record = null)
{
// Set the "Station Time Zone" field.
$station = $request->getStation();
$station_tz = $station->getTimezone();
$now_station = Chronos::now(new DateTimeZone($station_tz))->toIso8601String();
$tz_string = __('This station\'s time zone is currently %s.', '<b>' . $station_tz . '</b>')
. '<br>'
. __('The current time in the station\'s time zone is %s.',
'<b><time data-content="' . $now_station . '"></time></b>');
/** @var Markup $tz_field */
$tz_field = $this->fields['station_time_zone'];
$tz_field->setAttribute('markup', $tz_string);
// Resume regular record processing.
$record = parent::process($request, $record);
if ($record instanceof Entity\StationPlaylist) {
$files = $request->getUploadedFiles();
/** @var UploadedFileInterface $import_file */
$import_file = $files['import'];
if (UPLOAD_ERR_OK === $import_file->getError()) {
$matches = $this->_importPlaylist($record, $import_file);
if (is_int($matches)) {
$request->getFlash()->addMessage('<b>' . __('Existing playlist imported.') . '</b><br>' . __('%d song(s) were imported into the playlist.',
$matches), Flash::INFO);
}
}
$record->setQueue(null);
$this->em->persist($record);
$this->em->flush($record);
}
return $record;
}
protected function _importPlaylist(Entity\StationPlaylist $playlist, UploadedFileInterface $playlist_file)
{
$station_id = $this->station->getId();
$playlist_raw = (string)$playlist_file->getStream();
if (empty($playlist_raw)) {
return false;
}
$paths = PlaylistParser::getSongs($playlist_raw);
if (empty($paths)) {
return false;
}
// Assemble list of station media to match against.
$media_lookup = [];
$media_info_raw = $this->em->createQuery(/** @lang DQL */ 'SELECT sm.id, sm.path
FROM App\Entity\StationMedia sm
WHERE sm.station_id = :station_id')
->setParameter('station_id', $station_id)
->getArrayResult();
foreach ($media_info_raw as $row) {
$path_hash = md5($row['path']);
$media_lookup[$path_hash] = $row['id'];
}
// Run all paths against the lookup list of hashes.
$matches = [];
foreach ($paths as $path_raw) {
// De-Windows paths (if applicable)
$path_raw = str_replace('\\', '/', $path_raw);
// Work backwards from the basename to try to find matches.
$path_parts = explode('/', $path_raw);
for ($i = 1, $iMax = count($path_parts); $i <= $iMax; $i++) {
$path_attempt = implode('/', array_slice($path_parts, 0 - $i));
$path_hash = md5($path_attempt);
if (isset($media_lookup[$path_hash])) {
$matches[] = $media_lookup[$path_hash];
}
}
}
// Assign all matched media to the playlist.
if (!empty($matches)) {
$matched_media = $this->em->createQuery(/** @lang DQL */ 'SELECT sm
FROM App\Entity\StationMedia sm
WHERE sm.station_id = :station_id AND sm.id IN (:matched_ids)')
->setParameter('station_id', $station_id)
->setParameter('matched_ids', $matches)
->execute();
$weight = $this->playlistMediaRepo->getHighestSongWeight($playlist);
foreach ($matched_media as $media) {
$weight++;
/** @var Entity\StationMedia $media */
$this->playlistMediaRepo->addMediaToPlaylist($media, $playlist, $weight);
}
$this->em->flush();
}
return count($matches);
}
}

View File

@ -51,7 +51,7 @@
"dist/material.js": "dist/material-df68dbf23f.js",
"dist/radio_player.js": "dist/radio_player-f88abea4e8.js",
"dist/station_media.js": "dist/station_media-539908ba2c.js",
"dist/station_playlists.js": "dist/station_playlists-a5de781fdd.js",
"dist/station_playlists.js": "dist/station_playlists-f552dce7d5.js",
"dist/station_streamers.js": "dist/station_streamers-2e160bdd93.js",
"dist/vue_gettext.js": "dist/vue_gettext-5797c82c60.js",
"dist/webcaster.js": "dist/webcaster-23f4c0f3cf.js",

File diff suppressed because one or more lines are too long