Remove the `Song` entity and restructure dependent tables accordingly (#3231)
* Song database and entity overhaul, part 1. * Remove Songs table from a number of qeries and reports. * Fix references to Songs table; rewrite StationMedia processing. * Remove song reference in queue page. * Allow custom log level via environment variable.
This commit is contained in:
parent
c4b065b044
commit
c81ff62b5c
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
APPLICATION_ENV=development
|
||||
LOG_LEVEL=debug
|
||||
ENABLE_ADVANCED_FEATURES=true
|
||||
COMPOSER_PLUGIN_MODE=false
|
||||
|
||||
|
|
|
@ -6,6 +6,13 @@
|
|||
# Valid options: production, development, testing
|
||||
APPLICATION_ENV=production
|
||||
|
||||
# Manually modify the logging level.
|
||||
# This allows you to log debug-level errors temporarily (for problem-solving) or reduce
|
||||
# the volume of logs that are produced by your installation, without needing to modify
|
||||
# whether your installation is a production or development instance.
|
||||
# Valid options: debug, info, notice, warning, error, critical, alert, emergency
|
||||
# LOG_LEVEL=notice
|
||||
|
||||
# Enable certain advanced features inside the web interface, including
|
||||
# advanced playlist coniguration, station port assignment, changing base
|
||||
# media directory, and other functionality that should only be used by
|
||||
|
|
|
@ -136,14 +136,7 @@ return [
|
|||
Doctrine\ORM\EntityManagerInterface::class => DI\Get(App\Doctrine\DecoratedEntityManager::class),
|
||||
|
||||
// Cache
|
||||
Psr\Cache\CacheItemPoolInterface::class => function (App\Settings $settings, Psr\Container\ContainerInterface $di) {
|
||||
// Never use the Redis cache for CLI commands, as the CLI commands are where
|
||||
// the Redis cache gets flushed, so this will lead to a race condition that can't
|
||||
// be solved within the application.
|
||||
return $settings->enableRedis() && !$settings->isCli()
|
||||
? new Cache\Adapter\Redis\RedisCachePool($di->get(Redis::class))
|
||||
: new Cache\Adapter\PHPArray\ArrayCachePool;
|
||||
},
|
||||
Psr\Cache\CacheItemPoolInterface::class => DI\autowire(Cache\Adapter\Redis\RedisCachePool::class),
|
||||
Psr\SimpleCache\CacheInterface::class => DI\get(Psr\Cache\CacheItemPoolInterface::class),
|
||||
|
||||
// Doctrine cache
|
||||
|
@ -209,15 +202,35 @@ return [
|
|||
// Monolog Logger
|
||||
Monolog\Logger::class => function (App\Settings $settings) {
|
||||
$logger = new Monolog\Logger($settings[App\Settings::APP_NAME] ?? 'app');
|
||||
$logging_level = $settings->isProduction() ? Psr\Log\LogLevel::NOTICE : Psr\Log\LogLevel::DEBUG;
|
||||
|
||||
$loggingLevel = null;
|
||||
if (!empty($_ENV['LOG_LEVEL'])) {
|
||||
$allowedLogLevels = [
|
||||
Psr\Log\LogLevel::DEBUG,
|
||||
Psr\Log\LogLevel::INFO,
|
||||
Psr\Log\LogLevel::NOTICE,
|
||||
Psr\Log\LogLevel::WARNING,
|
||||
Psr\Log\LogLevel::ERROR,
|
||||
Psr\Log\LogLevel::CRITICAL,
|
||||
Psr\Log\LogLevel::ALERT,
|
||||
Psr\Log\LogLevel::EMERGENCY,
|
||||
];
|
||||
|
||||
$loggingLevel = strtolower($_ENV['LOG_LEVEL']);
|
||||
if (!in_array($loggingLevel, $allowedLogLevels, true)) {
|
||||
$loggingLevel = null;
|
||||
}
|
||||
}
|
||||
|
||||
$loggingLevel ??= $settings->isProduction() ? Psr\Log\LogLevel::NOTICE : Psr\Log\LogLevel::DEBUG;
|
||||
|
||||
if ($settings[App\Settings::IS_DOCKER] || $settings[App\Settings::IS_CLI]) {
|
||||
$log_stderr = new Monolog\Handler\StreamHandler('php://stderr', $logging_level, true);
|
||||
$log_stderr = new Monolog\Handler\StreamHandler('php://stderr', $loggingLevel, true);
|
||||
$logger->pushHandler($log_stderr);
|
||||
}
|
||||
|
||||
$log_file = new Monolog\Handler\StreamHandler($settings[App\Settings::TEMP_DIR] . '/app.log',
|
||||
$logging_level, true);
|
||||
$loggingLevel, true);
|
||||
$logger->pushHandler($log_file);
|
||||
|
||||
return $logger;
|
||||
|
@ -275,7 +288,9 @@ return [
|
|||
Psr\Log\LoggerInterface $logger
|
||||
) {
|
||||
$redisStore = new Symfony\Component\Lock\Store\RedisStore($redis);
|
||||
$retryStore = new Symfony\Component\Lock\Store\RetryTillSaveStore($redisStore, 1000, 60);
|
||||
|
||||
$retryStore = new Symfony\Component\Lock\Store\RetryTillSaveStore($redisStore, 1000, 30);
|
||||
$retryStore->setLogger($logger);
|
||||
|
||||
$lockFactory = new Symfony\Component\Lock\LockFactory($retryStore);
|
||||
$lockFactory->setLogger($logger);
|
||||
|
|
|
@ -5,6 +5,7 @@ use App\Entity;
|
|||
use App\Flysystem\Filesystem;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\UploadedFileInterface;
|
||||
|
||||
|
@ -15,6 +16,7 @@ class PostArtAction
|
|||
Response $response,
|
||||
Filesystem $filesystem,
|
||||
Entity\Repository\StationMediaRepository $mediaRepo,
|
||||
EntityManagerInterface $em,
|
||||
$media_id
|
||||
): ResponseInterface {
|
||||
$station = $request->getStation();
|
||||
|
@ -32,6 +34,7 @@ class PostArtAction
|
|||
/** @var UploadedFileInterface $file */
|
||||
if ($file->getError() === UPLOAD_ERR_OK) {
|
||||
$mediaRepo->writeAlbumArt($media, $file->getStream()->getContents());
|
||||
$em->flush();
|
||||
} elseif ($file->getError() !== UPLOAD_ERR_NO_FILE) {
|
||||
return $response->withStatus(500)
|
||||
->withJson(new Entity\Api\Error(500, $file->getError()));
|
||||
|
|
|
@ -6,8 +6,6 @@ use App\Http\Response;
|
|||
use App\Http\ServerRequest;
|
||||
use App\Service\Flow;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Error;
|
||||
use Exception;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
class FlowUploadAction
|
||||
|
@ -27,51 +25,46 @@ class FlowUploadAction
|
|||
->withJson(new Entity\Api\Error(500, __('This station is out of available storage space.')));
|
||||
}
|
||||
|
||||
try {
|
||||
$flowResponse = Flow::process($request, $response, $station->getRadioTempDir());
|
||||
if ($flowResponse instanceof ResponseInterface) {
|
||||
return $flowResponse;
|
||||
}
|
||||
$flowResponse = Flow::process($request, $response, $station->getRadioTempDir());
|
||||
if ($flowResponse instanceof ResponseInterface) {
|
||||
return $flowResponse;
|
||||
}
|
||||
|
||||
if (is_array($flowResponse)) {
|
||||
$file = $request->getAttribute('file');
|
||||
$filePath = $request->getAttribute('file_path');
|
||||
if (is_array($flowResponse)) {
|
||||
$file = $request->getAttribute('file');
|
||||
$filePath = $request->getAttribute('file_path');
|
||||
|
||||
$sanitizedName = $flowResponse['filename'];
|
||||
$sanitizedName = $flowResponse['filename'];
|
||||
|
||||
$finalPath = empty($file)
|
||||
? $filePath . $sanitizedName
|
||||
: $filePath . '/' . $sanitizedName;
|
||||
$finalPath = empty($file)
|
||||
? $filePath . $sanitizedName
|
||||
: $filePath . '/' . $sanitizedName;
|
||||
|
||||
$station_media = $mediaRepo->uploadFile($station, $flowResponse['path'], $finalPath);
|
||||
$station_media = $mediaRepo->getOrCreate($station, $finalPath, $flowResponse['path']);
|
||||
|
||||
// If the user is looking at a playlist's contents, add uploaded media to that playlist.
|
||||
if (!empty($params['searchPhrase'])) {
|
||||
$search_phrase = $params['searchPhrase'];
|
||||
// If the user is looking at a playlist's contents, add uploaded media to that playlist.
|
||||
if (!empty($params['searchPhrase'])) {
|
||||
$search_phrase = $params['searchPhrase'];
|
||||
|
||||
if (0 === strpos($search_phrase, 'playlist:')) {
|
||||
$playlist_name = substr($search_phrase, 9);
|
||||
if (0 === strpos($search_phrase, 'playlist:')) {
|
||||
$playlist_name = substr($search_phrase, 9);
|
||||
|
||||
$playlist = $em->getRepository(Entity\StationPlaylist::class)->findOneBy([
|
||||
'station_id' => $station->getId(),
|
||||
'name' => $playlist_name,
|
||||
]);
|
||||
$playlist = $em->getRepository(Entity\StationPlaylist::class)->findOneBy([
|
||||
'station_id' => $station->getId(),
|
||||
'name' => $playlist_name,
|
||||
]);
|
||||
|
||||
if ($playlist instanceof Entity\StationPlaylist) {
|
||||
$spmRepo->addMediaToPlaylist($station_media, $playlist);
|
||||
$em->flush();
|
||||
}
|
||||
if ($playlist instanceof Entity\StationPlaylist) {
|
||||
$spmRepo->addMediaToPlaylist($station_media, $playlist);
|
||||
$em->flush();
|
||||
}
|
||||
}
|
||||
|
||||
$station->addStorageUsed($flowResponse['size']);
|
||||
$em->flush();
|
||||
|
||||
return $response->withJson(new Entity\Api\Status);
|
||||
}
|
||||
} catch (Exception | Error $e) {
|
||||
return $response->withStatus(500)
|
||||
->withJson(new Entity\Api\Error(500, $e->getMessage()));
|
||||
|
||||
$station->addStorageUsed($flowResponse['size']);
|
||||
$em->flush();
|
||||
|
||||
return $response->withJson(new Entity\Api\Status);
|
||||
}
|
||||
|
||||
return $response->withJson(['success' => false]);
|
||||
|
|
|
@ -31,8 +31,6 @@ class FilesController extends AbstractStationApiCrudController
|
|||
|
||||
protected Entity\Repository\CustomFieldRepository $custom_fields_repo;
|
||||
|
||||
protected Entity\Repository\SongRepository $song_repo;
|
||||
|
||||
protected Entity\Repository\StationMediaRepository $media_repo;
|
||||
|
||||
protected Entity\Repository\StationPlaylistMediaRepository $playlist_media_repo;
|
||||
|
@ -45,7 +43,6 @@ class FilesController extends AbstractStationApiCrudController
|
|||
Adapters $adapters,
|
||||
MessageBus $messageBus,
|
||||
Entity\Repository\CustomFieldRepository $custom_fields_repo,
|
||||
Entity\Repository\SongRepository $song_repo,
|
||||
Entity\Repository\StationMediaRepository $media_repo,
|
||||
Entity\Repository\StationPlaylistMediaRepository $playlist_media_repo
|
||||
) {
|
||||
|
@ -57,7 +54,6 @@ class FilesController extends AbstractStationApiCrudController
|
|||
|
||||
$this->custom_fields_repo = $custom_fields_repo;
|
||||
$this->media_repo = $media_repo;
|
||||
$this->song_repo = $song_repo;
|
||||
$this->playlist_media_repo = $playlist_media_repo;
|
||||
}
|
||||
|
||||
|
@ -124,7 +120,7 @@ class FilesController extends AbstractStationApiCrudController
|
|||
$sanitized_path = Filesystem::PREFIX_MEDIA . '://' . $api_record->getSanitizedPath();
|
||||
|
||||
// Process temp path as regular media record.
|
||||
$record = $this->media_repo->uploadFile($station, $temp_path, $sanitized_path);
|
||||
$record = $this->media_repo->getOrCreate($station, $sanitized_path, $temp_path);
|
||||
|
||||
$return = $this->viewRecord($record, $request);
|
||||
|
||||
|
@ -257,16 +253,7 @@ class FilesController extends AbstractStationApiCrudController
|
|||
$this->em->flush();
|
||||
|
||||
if ($this->media_repo->writeToFile($record)) {
|
||||
$song_info = [
|
||||
'title' => $record->getTitle(),
|
||||
'artist' => $record->getArtist(),
|
||||
];
|
||||
|
||||
$song = $this->song_repo->getOrCreate($song_info);
|
||||
$song->update($song_info);
|
||||
$this->em->persist($song);
|
||||
|
||||
$record->setSong($song);
|
||||
$record->updateSongId();
|
||||
}
|
||||
|
||||
if (null !== $custom_fields) {
|
||||
|
|
|
@ -78,12 +78,11 @@ class HistoryController
|
|||
|
||||
$qb = $this->em->createQueryBuilder();
|
||||
|
||||
$qb->select('sh, sr, sp, ss, s')
|
||||
$qb->select('sh, sr, sp, ss')
|
||||
->from(Entity\SongHistory::class, 'sh')
|
||||
->leftJoin('sh.request', 'sr')
|
||||
->leftJoin('sh.playlist', 'sp')
|
||||
->leftJoin('sh.streamer', 'ss')
|
||||
->leftJoin('sh.song', 's')
|
||||
->where('sh.station_id = :station_id')
|
||||
->andWhere('sh.timestamp_start >= :start AND sh.timestamp_start <= :end')
|
||||
->andWhere('sh.listeners_start IS NOT NULL')
|
||||
|
@ -113,8 +112,8 @@ class HistoryController
|
|||
$datetime->format('g:ia'),
|
||||
$song_row['listeners_start'],
|
||||
$song_row['delta_total'],
|
||||
$song_row['song']['title'] ?: $song_row['song']['text'],
|
||||
$song_row['song']['artist'],
|
||||
$song_row['title'] ?: $song_row['text'],
|
||||
$song_row['artist'],
|
||||
$song_row['playlist']['name'] ?? '',
|
||||
$song_row['streamer']['display_name'] ?? $song_row['streamer']['streamer_username'] ?? '',
|
||||
];
|
||||
|
@ -130,7 +129,7 @@ class HistoryController
|
|||
|
||||
$search_phrase = trim($params['searchPhrase']);
|
||||
if (!empty($search_phrase)) {
|
||||
$qb->andWhere('(s.title LIKE :query OR s.artist LIKE :query)')
|
||||
$qb->andWhere('(sh.title LIKE :query OR sh.artist LIKE :query)')
|
||||
->setParameter('query', '%' . $search_phrase . '%');
|
||||
}
|
||||
|
||||
|
|
|
@ -51,9 +51,8 @@ class QueueController extends AbstractStationApiCrudController
|
|||
{
|
||||
$station = $request->getStation();
|
||||
|
||||
$query = $this->em->createQuery(/** @lang DQL */ 'SELECT sq, sp, s, sm
|
||||
$query = $this->em->createQuery(/** @lang DQL */ 'SELECT sq, sp, sm
|
||||
FROM App\Entity\StationQueue sq
|
||||
LEFT JOIN sq.song s
|
||||
LEFT JOIN sq.media sm
|
||||
LEFT JOIN sq.playlist sp
|
||||
WHERE sq.station = :station
|
||||
|
|
|
@ -67,9 +67,8 @@ class RequestsController
|
|||
|
||||
$qb = $this->em->createQueryBuilder();
|
||||
|
||||
$qb->select('sm, s, spm, sp')
|
||||
$qb->select('sm, spm, sp')
|
||||
->from(Entity\StationMedia::class, 'sm')
|
||||
->join('sm.song', 's')
|
||||
->leftJoin('sm.playlists', 'spm')
|
||||
->leftJoin('spm.playlist', 'sp')
|
||||
->where('sm.station_id = :station_id')
|
||||
|
|
|
@ -32,9 +32,8 @@ class DuplicatesController
|
|||
$station = $request->getStation();
|
||||
|
||||
$dupesRaw = $this->em->createQuery(/** @lang DQL */ 'SELECT
|
||||
sm, s, spm, sp
|
||||
sm, spm, sp
|
||||
FROM App\Entity\StationMedia sm
|
||||
JOIN sm.song s
|
||||
LEFT JOIN sm.playlists spm
|
||||
LEFT JOIN spm.playlist sp
|
||||
WHERE sm.station = :station
|
||||
|
|
|
@ -176,7 +176,7 @@ class OverviewController
|
|||
|
||||
$song_totals_raw = [];
|
||||
$song_totals_raw['played'] = $this->em->createQuery(/** @lang DQL */ 'SELECT
|
||||
sh.song_id, COUNT(sh.id) AS records
|
||||
sh.song_id, sh.text, sh.artist, sh.title, COUNT(sh.id) AS records
|
||||
FROM App\Entity\SongHistory sh
|
||||
WHERE sh.station_id = :station_id AND sh.timestamp_start >= :timestamp
|
||||
GROUP BY sh.song_id
|
||||
|
@ -189,17 +189,8 @@ class OverviewController
|
|||
// Compile the above data.
|
||||
$song_totals = [];
|
||||
|
||||
$get_song_q = $this->em->createQuery(/** @lang DQL */ 'SELECT s
|
||||
FROM App\Entity\Song s
|
||||
WHERE s.id = :song_id');
|
||||
|
||||
foreach ($song_totals_raw as $total_type => $total_records) {
|
||||
foreach ($total_records as $total_record) {
|
||||
$song = $get_song_q->setParameter('song_id', $total_record['song_id'])
|
||||
->getArrayResult();
|
||||
|
||||
$total_record['song'] = $song[0];
|
||||
|
||||
$song_totals[$total_type][] = $total_record;
|
||||
}
|
||||
|
||||
|
@ -210,9 +201,8 @@ class OverviewController
|
|||
$songPerformanceThreshold = CarbonImmutable::parse('-2 days', $station_tz)->getTimestamp();
|
||||
|
||||
// Get all songs played in timeline.
|
||||
$songs_played_raw = $this->em->createQuery(/** @lang DQL */ 'SELECT sh, s
|
||||
$songs_played_raw = $this->em->createQuery(/** @lang DQL */ 'SELECT sh
|
||||
FROM App\Entity\SongHistory sh
|
||||
LEFT JOIN sh.song s
|
||||
WHERE sh.station_id = :station_id
|
||||
AND sh.timestamp_start >= :timestamp
|
||||
AND sh.listeners_start IS NOT NULL
|
||||
|
@ -241,11 +231,7 @@ class OverviewController
|
|||
$a = $a_arr['stat_delta'];
|
||||
$b = $b_arr['stat_delta'];
|
||||
|
||||
if ($a == $b) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ($a > $b) ? 1 : -1;
|
||||
return $a <=> $b;
|
||||
});
|
||||
|
||||
return $request->getView()->renderToResponse($response, 'stations/reports/overview', [
|
||||
|
|
|
@ -24,10 +24,9 @@ class RequestsController
|
|||
$station = $request->getStation();
|
||||
|
||||
$requests = $this->em->createQuery(/** @lang DQL */ 'SELECT
|
||||
sr, sm, s
|
||||
sr, sm
|
||||
FROM App\Entity\StationRequest sr
|
||||
JOIN sr.track sm
|
||||
JOIN sm.song s
|
||||
WHERE sr.station_id = :station_id
|
||||
ORDER BY sr.timestamp DESC')
|
||||
->setParameter('station_id', $station->getId())
|
||||
|
|
|
@ -69,7 +69,7 @@ class SoundExchangeController
|
|||
}
|
||||
|
||||
$history_rows = $this->em->createQuery(/** @lang DQL */ 'SELECT
|
||||
sh.song_id AS song_id, COUNT(sh.id) AS plays, SUM(sh.unique_listeners) AS unique_listeners
|
||||
sh.song_id AS song_id, sh.text, sh.artist, sh.title, COUNT(sh.id) AS plays, SUM(sh.unique_listeners) AS unique_listeners
|
||||
FROM App\Entity\SongHistory sh
|
||||
WHERE sh.station_id = :station_id
|
||||
AND sh.timestamp_start <= :time_end
|
||||
|
@ -86,25 +86,9 @@ class SoundExchangeController
|
|||
}
|
||||
|
||||
// Remove any reference to the "Stream Offline" song.
|
||||
$offline_song_hash = Entity\Song::getSongHash(['text' => 'stream_offline']);
|
||||
$offline_song_hash = Entity\Song::getSongHash('stream_offline');
|
||||
unset($history_rows_by_id[$offline_song_hash]);
|
||||
|
||||
// Get all songs not found in the StationMedia library
|
||||
$not_found_songs = array_diff_key($history_rows_by_id, $media_by_id);
|
||||
|
||||
if (!empty($not_found_songs)) {
|
||||
|
||||
$songs_raw = $this->em->createQuery(/** @lang DQL */ 'SELECT s
|
||||
FROM App\Entity\Song s
|
||||
WHERE s.id IN (:song_ids)')
|
||||
->setParameter('song_ids', array_keys($not_found_songs))
|
||||
->getArrayResult();
|
||||
|
||||
foreach ($songs_raw as $song_row) {
|
||||
$media_by_id[$song_row['id']] = $song_row;
|
||||
}
|
||||
}
|
||||
|
||||
// Assemble report items
|
||||
$station_name = $station->getName();
|
||||
|
||||
|
@ -117,7 +101,7 @@ class SoundExchangeController
|
|||
|
||||
foreach ($history_rows_by_id as $song_id => $history_row) {
|
||||
|
||||
$song_row = $media_by_id[$song_id];
|
||||
$song_row = $media_by_id[$song_id] ?? $history_row;
|
||||
|
||||
// Try to find the ISRC if it's not already listed.
|
||||
if (array_key_exists('isrc', $song_row) && $song_row['isrc'] === null) {
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Migration;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20201003021913 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Songs denormalization, part 1';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE song_history ADD text VARCHAR(150) DEFAULT NULL AFTER station_id, ADD artist VARCHAR(150) DEFAULT NULL AFTER text, ADD title VARCHAR(150) DEFAULT NULL AFTER artist');
|
||||
$this->addSql('ALTER TABLE station_media ADD text VARCHAR(150) DEFAULT NULL AFTER song_id, CHANGE title title VARCHAR(150) DEFAULT NULL, CHANGE artist artist VARCHAR(150) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE station_queue ADD text VARCHAR(150) DEFAULT NULL AFTER request_id, ADD artist VARCHAR(150) DEFAULT NULL AFTER text, ADD title VARCHAR(150) DEFAULT NULL AFTER artist');
|
||||
|
||||
$this->addSql('UPDATE song_history sh JOIN songs s ON sh.song_id = s.id SET sh.text=s.text, sh.artist=s.artist, sh.title=s.title');
|
||||
$this->addSql('UPDATE station_queue sq JOIN songs s ON sq.song_id = s.id SET sq.text=s.text, sq.artist=s.artist, sq.title=s.title');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE song_history DROP text, DROP artist, DROP title');
|
||||
$this->addSql('ALTER TABLE station_media DROP text, CHANGE artist artist VARCHAR(200) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_general_ci`, CHANGE title title VARCHAR(200) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_general_ci`');
|
||||
$this->addSql('ALTER TABLE station_queue DROP text, DROP artist, DROP title');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Migration;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20201003023117 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Songs denormalization, part 2';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE song_history DROP FOREIGN KEY FK_2AD16164A0BDB2F3');
|
||||
$this->addSql('DROP INDEX IDX_2AD16164A0BDB2F3 ON song_history');
|
||||
|
||||
$this->addSql('ALTER TABLE station_media DROP FOREIGN KEY FK_32AADE3AA0BDB2F3');
|
||||
$this->addSql('DROP INDEX IDX_32AADE3AA0BDB2F3 ON station_media');
|
||||
|
||||
$this->addSql('ALTER TABLE station_queue DROP FOREIGN KEY FK_277B0055A0BDB2F3');
|
||||
$this->addSql('DROP INDEX IDX_277B0055A0BDB2F3 ON station_queue');
|
||||
|
||||
$this->addSql('DROP TABLE songs');
|
||||
|
||||
$this->addSql('ALTER TABLE station_media CHANGE song_id song_id VARCHAR(50) NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE station_media DROP CHANGE song_id song_id VARCHAR(50) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_general_ci`');
|
||||
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE songs (id VARCHAR(50) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_general_ci`, text VARCHAR(150) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_general_ci`, artist VARCHAR(150) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_general_ci`, title VARCHAR(150) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_general_ci`, created INT NOT NULL, play_count INT NOT NULL, last_played INT NOT NULL, INDEX search_idx (text, artist, title), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
||||
|
||||
$this->addSql('ALTER TABLE song_history ADD CONSTRAINT FK_2AD16164A0BDB2F3 FOREIGN KEY (song_id) REFERENCES songs (id) ON DELETE CASCADE');
|
||||
$this->addSql('CREATE INDEX IDX_2AD16164A0BDB2F3 ON song_history (song_id)');
|
||||
|
||||
$this->addSql('ALTER TABLE station_media ADD CONSTRAINT FK_32AADE3AA0BDB2F3 FOREIGN KEY (song_id) REFERENCES songs (id) ON DELETE SET NULL');
|
||||
$this->addSql('CREATE INDEX IDX_32AADE3AA0BDB2F3 ON station_media (song_id)');
|
||||
|
||||
$this->addSql('ALTER TABLE station_queue ADD CONSTRAINT FK_277B0055A0BDB2F3 FOREIGN KEY (song_id) REFERENCES songs (id) ON DELETE CASCADE');
|
||||
$this->addSql('CREATE INDEX IDX_277B0055A0BDB2F3 ON station_queue (song_id)');
|
||||
}
|
||||
}
|
|
@ -49,9 +49,8 @@ class SongHistoryRepository extends Repository
|
|||
return [];
|
||||
}
|
||||
|
||||
$history = $this->em->createQuery(/** @lang DQL */ 'SELECT sh, s
|
||||
$history = $this->em->createQuery(/** @lang DQL */ 'SELECT sh
|
||||
FROM App\Entity\SongHistory sh
|
||||
JOIN sh.song s
|
||||
LEFT JOIN sh.media sm
|
||||
WHERE sh.station_id = :station_id
|
||||
AND sh.timestamp_end != 0
|
||||
|
@ -76,16 +75,16 @@ class SongHistoryRepository extends Repository
|
|||
CarbonInterface $now,
|
||||
int $rows
|
||||
): array {
|
||||
$recentlyPlayed = $this->em->createQuery(/** @lang DQL */ 'SELECT sq, s
|
||||
FROM App\Entity\StationQueue sq JOIN sq.song s
|
||||
$recentlyPlayed = $this->em->createQuery(/** @lang DQL */ 'SELECT sq
|
||||
FROM App\Entity\StationQueue sq
|
||||
WHERE sq.station = :station
|
||||
ORDER BY sq.timestamp_cued DESC')
|
||||
->setParameter('station', $station)
|
||||
->setMaxResults($rows)
|
||||
->getArrayResult();
|
||||
|
||||
$recentHistory = $this->em->createQuery(/** @lang DQL */ 'SELECT sh, s
|
||||
FROM App\Entity\SongHistory sh JOIN sh.song s
|
||||
$recentHistory = $this->em->createQuery(/** @lang DQL */ 'SELECT sh
|
||||
FROM App\Entity\SongHistory sh
|
||||
WHERE sh.station = :station
|
||||
AND (sh.timestamp_start != 0 AND sh.timestamp_start IS NOT NULL)
|
||||
AND sh.timestamp_start >= :threshold
|
||||
|
@ -107,8 +106,8 @@ class SongHistoryRepository extends Repository
|
|||
$timeRangeInSeconds = $minutes * 60;
|
||||
$threshold = $now->getTimestamp() - $timeRangeInSeconds;
|
||||
|
||||
$recentlyPlayed = $this->em->createQuery(/** @lang DQL */ 'SELECT sq, s
|
||||
FROM App\Entity\StationQueue sq JOIN sq.song s
|
||||
$recentlyPlayed = $this->em->createQuery(/** @lang DQL */ 'SELECT sq
|
||||
FROM App\Entity\StationQueue sq
|
||||
WHERE sq.station = :station
|
||||
AND sq.timestamp_cued >= :threshold
|
||||
ORDER BY sq.timestamp_cued DESC')
|
||||
|
@ -116,8 +115,8 @@ class SongHistoryRepository extends Repository
|
|||
->setParameter('threshold', $threshold)
|
||||
->getArrayResult();
|
||||
|
||||
$recentHistory = $this->em->createQuery(/** @lang DQL */ 'SELECT sh, s
|
||||
FROM App\Entity\SongHistory sh JOIN sh.song s
|
||||
$recentHistory = $this->em->createQuery(/** @lang DQL */ 'SELECT sh
|
||||
FROM App\Entity\SongHistory sh
|
||||
WHERE sh.station = :station
|
||||
AND (sh.timestamp_start != 0 AND sh.timestamp_start IS NOT NULL)
|
||||
AND sh.timestamp_start >= :threshold
|
||||
|
@ -140,7 +139,7 @@ class SongHistoryRepository extends Repository
|
|||
$listeners = (int)$np->listeners->current;
|
||||
|
||||
if ($last_sh instanceof Entity\SongHistory) {
|
||||
if ($last_sh->getSong() === $song) {
|
||||
if ($last_sh->getSongId() === $song->getSongId()) {
|
||||
// Updating the existing SongHistory item with a new data point.
|
||||
$last_sh->addDeltaPoint($listeners);
|
||||
|
||||
|
@ -197,7 +196,7 @@ class SongHistoryRepository extends Repository
|
|||
$this->em->remove($sq);
|
||||
} else {
|
||||
// Processing a new SongHistory item.
|
||||
$sh = new Entity\SongHistory($song, $station);
|
||||
$sh = new Entity\SongHistory($station, $song);
|
||||
|
||||
$currentStreamer = $station->getCurrentStreamer();
|
||||
if ($currentStreamer instanceof Entity\StationStreamer) {
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
<?php
|
||||
namespace App\Entity\Repository;
|
||||
|
||||
use App\Doctrine\Repository;
|
||||
use App\Entity;
|
||||
use NowPlaying\Result\CurrentSong;
|
||||
|
||||
class SongRepository extends Repository
|
||||
{
|
||||
/**
|
||||
* Retrieve an existing Song entity or create a new one.
|
||||
*
|
||||
* @param CurrentSong|array|string $song_info
|
||||
* @param bool $is_radio_play
|
||||
*
|
||||
* @return Entity\Song
|
||||
*/
|
||||
public function getOrCreate($song_info, $is_radio_play = false): Entity\Song
|
||||
{
|
||||
if ($song_info instanceof CurrentSong) {
|
||||
$song_info = [
|
||||
'text' => $song_info->text,
|
||||
'artist' => $song_info->artist,
|
||||
'title' => $song_info->title,
|
||||
];
|
||||
} elseif (!is_array($song_info)) {
|
||||
$song_info = ['text' => $song_info];
|
||||
}
|
||||
|
||||
$song_hash = Entity\Song::getSongHash($song_info);
|
||||
|
||||
$obj = $this->repository->find($song_hash);
|
||||
|
||||
if (!($obj instanceof Entity\Song)) {
|
||||
$obj = new Entity\Song($song_info);
|
||||
}
|
||||
|
||||
if ($is_radio_play) {
|
||||
$obj->played();
|
||||
}
|
||||
|
||||
$this->em->persist($obj);
|
||||
$this->em->flush();
|
||||
|
||||
return $obj;
|
||||
}
|
||||
}
|
|
@ -13,6 +13,7 @@ use Doctrine\ORM\EntityManagerInterface;
|
|||
use Exception;
|
||||
use getid3_exception;
|
||||
use InvalidArgumentException;
|
||||
use NowPlaying\Result\CurrentSong;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Serializer\Serializer;
|
||||
use voku\helper\UTF8;
|
||||
|
@ -24,8 +25,6 @@ class StationMediaRepository extends Repository
|
|||
{
|
||||
protected Filesystem $filesystem;
|
||||
|
||||
protected SongRepository $songRepo;
|
||||
|
||||
protected CustomFieldRepository $customFieldRepo;
|
||||
|
||||
public function __construct(
|
||||
|
@ -34,11 +33,9 @@ class StationMediaRepository extends Repository
|
|||
Settings $settings,
|
||||
LoggerInterface $logger,
|
||||
Filesystem $filesystem,
|
||||
SongRepository $songRepo,
|
||||
CustomFieldRepository $customFieldRepo
|
||||
) {
|
||||
$this->filesystem = $filesystem;
|
||||
$this->songRepo = $songRepo;
|
||||
$this->customFieldRepo = $customFieldRepo;
|
||||
|
||||
parent::__construct($em, $serializer, $settings, $logger);
|
||||
|
@ -95,37 +92,99 @@ class StationMediaRepository extends Repository
|
|||
|
||||
/**
|
||||
* @param Entity\Station $station
|
||||
* @param string $tmp_path
|
||||
* @param string $dest
|
||||
* @param string $path
|
||||
* @param string|null $uploadedFrom The original uploaded path (if this is a new upload).
|
||||
*
|
||||
* @return Entity\StationMedia
|
||||
* @throws Exception
|
||||
*/
|
||||
public function uploadFile(Entity\Station $station, $tmp_path, $dest): Entity\StationMedia
|
||||
{
|
||||
[, $dest_path] = explode('://', $dest, 2);
|
||||
public function getOrCreate(
|
||||
Entity\Station $station,
|
||||
string $path,
|
||||
?string $uploadedFrom = null
|
||||
): Entity\StationMedia {
|
||||
if (strpos($path, '://') !== false) {
|
||||
[, $path] = explode('://', $path, 2);
|
||||
}
|
||||
|
||||
$record = $this->repository->findOneBy([
|
||||
'station_id' => $station->getId(),
|
||||
'path' => $dest_path,
|
||||
'path' => $path,
|
||||
]);
|
||||
|
||||
$created = false;
|
||||
if (!($record instanceof Entity\StationMedia)) {
|
||||
$record = new Entity\StationMedia($station, $dest_path);
|
||||
$record = new Entity\StationMedia($station, $path);
|
||||
$created = true;
|
||||
}
|
||||
|
||||
$this->loadFromFile($record, $tmp_path);
|
||||
$reprocessed = $this->processMedia($record, $created, $uploadedFrom);
|
||||
|
||||
$fs = $this->filesystem->getForStation($station);
|
||||
$fs->upload($tmp_path, $dest);
|
||||
|
||||
$record->setMtime(time() + 5);
|
||||
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
if ($created || $reprocessed) {
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run media through the "processing" steps: loading from file and setting up any missing metadata.
|
||||
*
|
||||
* @param Entity\StationMedia $media
|
||||
* @param bool $force
|
||||
* @param string|null $uploadedPath The uploaded path (if this is a new upload).
|
||||
*
|
||||
* @return bool Whether reprocessing was required for this file.
|
||||
*/
|
||||
public function processMedia(
|
||||
Entity\StationMedia $media,
|
||||
bool $force = false,
|
||||
?string $uploadedPath = null
|
||||
): bool {
|
||||
$fs = $this->filesystem->getForStation($media->getStation(), false);
|
||||
|
||||
$tmp_uri = null;
|
||||
$media_uri = $media->getPathUri();
|
||||
|
||||
if (null !== $uploadedPath) {
|
||||
$tmp_path = $uploadedPath;
|
||||
|
||||
$media_mtime = time();
|
||||
} else {
|
||||
if (!$fs->has($media_uri)) {
|
||||
throw new MediaProcessingException(sprintf('Media path "%s" not found.', $media_uri));
|
||||
}
|
||||
|
||||
$media_mtime = (int)$fs->getTimestamp($media_uri);
|
||||
|
||||
// No need to update if all of these conditions are true.
|
||||
if (!$force && !$media->needsReprocessing($media_mtime)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$tmp_path = $fs->getFullPath($media_uri);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$tmp_uri = $fs->copyToTemp($media_uri);
|
||||
$tmp_path = $fs->getFullPath($tmp_uri);
|
||||
}
|
||||
}
|
||||
|
||||
$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);
|
||||
$this->em->persist($media);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process metadata information from media file.
|
||||
*
|
||||
|
@ -198,26 +257,22 @@ class StationMediaRepository extends Repository
|
|||
}
|
||||
|
||||
// Attempt to derive title and artist from filename.
|
||||
if (empty($media->getTitle())) {
|
||||
$artist = $media->getArtist();
|
||||
$title = $media->getTitle();
|
||||
|
||||
if (null === $artist || null === $title) {
|
||||
$filename = pathinfo($media->getPath(), PATHINFO_FILENAME);
|
||||
$filename = str_replace('_', ' ', $filename);
|
||||
|
||||
$string_parts = explode('-', $filename);
|
||||
|
||||
// If not normally delimited, return "text" only.
|
||||
if (1 === count($string_parts)) {
|
||||
$media->setTitle(trim($filename));
|
||||
$media->setArtist('');
|
||||
} else {
|
||||
$media->setTitle(trim(array_pop($string_parts)));
|
||||
$media->setArtist(trim(implode('-', $string_parts)));
|
||||
}
|
||||
$songObj = new CurrentSong($filename);
|
||||
$media->setSong($songObj);
|
||||
}
|
||||
|
||||
$media->setSong($this->songRepo->getOrCreate([
|
||||
'artist' => $media->getArtist(),
|
||||
'title' => $media->getTitle(),
|
||||
]));
|
||||
// Force a text property to auto-generate from artist/title
|
||||
$media->setText($media->getText());
|
||||
|
||||
// Generate a song_id hash based on the track
|
||||
$media->updateSongId();
|
||||
}
|
||||
|
||||
protected function cleanUpString(string $original): string
|
||||
|
@ -234,6 +289,25 @@ class StationMediaRepository extends Repository
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the contents of the album art from storage (if it exists).
|
||||
*
|
||||
* @param Entity\StationMedia $media
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function readAlbumArt(Entity\StationMedia $media): ?string
|
||||
{
|
||||
$album_art_path = $media->getArtPath();
|
||||
$fs = $this->filesystem->getForStation($media->getStation());
|
||||
|
||||
if (!$fs->has($album_art_path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $fs->read($album_art_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crop album art and write the resulting image to storage.
|
||||
*
|
||||
|
@ -251,7 +325,6 @@ class StationMediaRepository extends Repository
|
|||
|
||||
$media->setArtUpdatedAt(time());
|
||||
$this->em->persist($media);
|
||||
$this->em->flush();
|
||||
|
||||
return $fs->put($albumArtPath, $albumArt);
|
||||
}
|
||||
|
@ -269,86 +342,6 @@ class StationMediaRepository extends Repository
|
|||
$this->em->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Entity\Station $station
|
||||
* @param string $path
|
||||
*
|
||||
* @return Entity\StationMedia
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getOrCreate(Entity\Station $station, $path): Entity\StationMedia
|
||||
{
|
||||
if (strpos($path, '://') !== false) {
|
||||
[, $path] = explode('://', $path, 2);
|
||||
}
|
||||
|
||||
$record = $this->repository->findOneBy([
|
||||
'station_id' => $station->getId(),
|
||||
'path' => $path,
|
||||
]);
|
||||
|
||||
$created = false;
|
||||
if (!($record instanceof Entity\StationMedia)) {
|
||||
$record = new Entity\StationMedia($station, $path);
|
||||
$created = true;
|
||||
}
|
||||
|
||||
$this->processMedia($record);
|
||||
|
||||
if ($created) {
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run media through the "processing" steps: loading from file and setting up any missing metadata.
|
||||
*
|
||||
* @param Entity\StationMedia $media
|
||||
* @param bool $force
|
||||
*
|
||||
* @return bool Whether reprocessing was required for this file.
|
||||
*/
|
||||
public function processMedia(Entity\StationMedia $media, $force = false): bool
|
||||
{
|
||||
$media_uri = $media->getPathUri();
|
||||
|
||||
$fs = $this->filesystem->getForStation($media->getStation());
|
||||
if (!$fs->has($media_uri)) {
|
||||
throw new MediaProcessingException(sprintf('Media path "%s" not found.', $media_uri));
|
||||
}
|
||||
|
||||
$media_mtime = (int)$fs->getTimestamp($media_uri);
|
||||
|
||||
// No need to update if all of these conditions are true.
|
||||
if (!$force && !$media->needsReprocessing($media_mtime)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
$this->loadFromFile($media, $tmp_path);
|
||||
$this->writeWaveform($media, $tmp_path);
|
||||
|
||||
if (null !== $tmp_uri) {
|
||||
$fs->delete($tmp_uri);
|
||||
}
|
||||
|
||||
$media->setMtime($media_mtime);
|
||||
$this->em->persist($media);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write modified metadata directly to the file as ID3 information.
|
||||
*
|
||||
|
@ -437,25 +430,6 @@ class StationMediaRepository extends Repository
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the contents of the album art from storage (if it exists).
|
||||
*
|
||||
* @param Entity\StationMedia $media
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function readAlbumArt(Entity\StationMedia $media): ?string
|
||||
{
|
||||
$album_art_path = $media->getArtPath();
|
||||
$fs = $this->filesystem->getForStation($media->getStation());
|
||||
|
||||
if (!$fs->has($album_art_path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $fs->read($album_art_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the full path associated with a media entity.
|
||||
*
|
||||
|
|
|
@ -52,8 +52,8 @@ class StationQueueRepository extends Repository
|
|||
public function getUpcomingQueue(Entity\Station $station): array
|
||||
{
|
||||
return $this->getUpcomingBaseQuery($station)
|
||||
->andWhere('sq.sent_to_autodj = 0')
|
||||
->getQuery()
|
||||
->andWhere('sq.sent_to_autodj = 0')
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
|
||||
|
@ -69,8 +69,8 @@ class StationQueueRepository extends Repository
|
|||
public function getUpcomingFromSong(Entity\Station $station, Entity\Song $song): ?Entity\StationQueue
|
||||
{
|
||||
return $this->getUpcomingBaseQuery($station)
|
||||
->andWhere('sq.song = :song')
|
||||
->setParameter('song', $song)
|
||||
->andWhere('sq.song_id = :song_id')
|
||||
->setParameter('song_id', $song->getSongId())
|
||||
->getQuery()
|
||||
->setMaxResults(1)
|
||||
->getOneOrNullResult();
|
||||
|
@ -79,10 +79,9 @@ class StationQueueRepository extends Repository
|
|||
protected function getUpcomingBaseQuery(Entity\Station $station): QueryBuilder
|
||||
{
|
||||
return $this->em->createQueryBuilder()
|
||||
->select('sq, sm, sp, s')
|
||||
->select('sq, sm, sp')
|
||||
->from(Entity\StationQueue::class, 'sq')
|
||||
->leftJoin('sq.media', 'sm')
|
||||
->leftJoin('sq.song', 's')
|
||||
->leftJoin('sq.playlist', 'sp')
|
||||
->where('sq.station = :station')
|
||||
->setParameter('station', $station)
|
||||
|
|
|
@ -162,9 +162,8 @@ class StationRequestRepository extends Repository
|
|||
|
||||
$lastPlayThreshold = time() - ($lastPlayThresholdMins * 60);
|
||||
|
||||
$recentTracks = $this->em->createQuery(/** @lang DQL */ 'SELECT sh.id, s.title, s.artist
|
||||
$recentTracks = $this->em->createQuery(/** @lang DQL */ 'SELECT sh.id, sh.title, sh.artist
|
||||
FROM App\Entity\SongHistory sh
|
||||
JOIN sh.song s
|
||||
WHERE sh.station = :station
|
||||
AND sh.timestamp_start >= :threshold
|
||||
ORDER BY sh.timestamp_start DESC')
|
||||
|
@ -172,12 +171,11 @@ class StationRequestRepository extends Repository
|
|||
->setParameter('threshold', $lastPlayThreshold)
|
||||
->getArrayResult();
|
||||
|
||||
$song = $media->getSong();
|
||||
|
||||
$eligibleTracks = [
|
||||
[
|
||||
'title' => $song->getTitle(),
|
||||
'artist' => $song->getArtist(),
|
||||
'title' => $media->getTitle(),
|
||||
'artist' => $media->getArtist(),
|
||||
],
|
||||
];
|
||||
|
||||
|
|
|
@ -2,31 +2,19 @@
|
|||
namespace App\Entity;
|
||||
|
||||
use App\ApiUtilities;
|
||||
use App\Exception;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use NowPlaying\Result\CurrentSong;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
|
||||
/**
|
||||
* @ORM\Table(name="songs", indexes={
|
||||
* @ORM\Index(name="search_idx", columns={"text", "artist", "title"})
|
||||
* })
|
||||
* @ORM\Entity()
|
||||
*/
|
||||
class Song
|
||||
{
|
||||
use Traits\TruncateStrings;
|
||||
|
||||
public const SYNC_THRESHOLD = 604800; // 604800 = 1 week
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="id", type="string", length=50)
|
||||
* @ORM\Id
|
||||
* @ORM\Column(name="song_id", type="string", length=50)
|
||||
* @var string
|
||||
*/
|
||||
protected $id;
|
||||
protected $song_id;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="text", type="string", length=150, nullable=true)
|
||||
|
@ -47,111 +35,80 @@ class Song
|
|||
protected $title;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="created", type="integer")
|
||||
* @var int
|
||||
* @param self|Api\Song|CurrentSong|array|string|null $song
|
||||
*/
|
||||
protected $created;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="play_count", type="integer")
|
||||
* @var int
|
||||
*/
|
||||
protected $play_count = 0;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="last_played", type="integer")
|
||||
* @var int
|
||||
*/
|
||||
protected $last_played = 0;
|
||||
|
||||
/**
|
||||
* @ORM\OneToMany(targetEntity="SongHistory", mappedBy="song")
|
||||
* @ORM\OrderBy({"timestamp" = "DESC"})
|
||||
* @var Collection
|
||||
*/
|
||||
protected $history;
|
||||
|
||||
public function __construct(array $song_info)
|
||||
public function __construct($song)
|
||||
{
|
||||
$this->created = time();
|
||||
$this->history = new ArrayCollection;
|
||||
$this->update($song_info);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an array of song information possibly containing artist, title, text
|
||||
* or any combination of those, update this entity to reflect this metadata.
|
||||
*
|
||||
* @param array $song_info
|
||||
*/
|
||||
public function update(array $song_info): void
|
||||
{
|
||||
if (empty($song_info['text'])) {
|
||||
if (!empty($song_info['artist'])) {
|
||||
$song_info['text'] = $song_info['artist'] . ' - ' . $song_info['title'];
|
||||
} else {
|
||||
$song_info['text'] = $song_info['title'];
|
||||
}
|
||||
}
|
||||
|
||||
$this->text = $this->truncateString($song_info['text'], 150);
|
||||
$this->title = $this->truncateString($song_info['title'], 150);
|
||||
$this->artist = $this->truncateString($song_info['artist'], 150);
|
||||
|
||||
$new_song_hash = self::getSongHash($song_info);
|
||||
|
||||
if (null === $this->id) {
|
||||
$this->id = $new_song_hash;
|
||||
} elseif ($this->id !== $new_song_hash) {
|
||||
throw new Exception('New song data supplied would not produce the same song ID.');
|
||||
if (null !== $song) {
|
||||
$this->setSong($song);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array|object|string $song_info
|
||||
*
|
||||
* @return string
|
||||
* @param self|Api\Song|CurrentSong|array|string $song
|
||||
*/
|
||||
public static function getSongHash($song_info): string
|
||||
public function setSong($song): void
|
||||
{
|
||||
// Handle various input types.
|
||||
if ($song_info instanceof self) {
|
||||
$song_info = [
|
||||
'text' => $song_info->getText(),
|
||||
'artist' => $song_info->getArtist(),
|
||||
'title' => $song_info->getTitle(),
|
||||
];
|
||||
} elseif ($song_info instanceof CurrentSong) {
|
||||
$song_info = [
|
||||
'text' => $song_info->text,
|
||||
'artist' => $song_info->artist,
|
||||
'title' => $song_info->title,
|
||||
];
|
||||
} elseif (!is_array($song_info)) {
|
||||
$song_info = [
|
||||
'text' => $song_info,
|
||||
];
|
||||
if ($song instanceof self) {
|
||||
$this->setText($song->getText());
|
||||
$this->setTitle($song->getTitle());
|
||||
$this->setArtist($song->getArtist());
|
||||
$this->song_id = $song->getSongId();
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate hash.
|
||||
if (!empty($song_info['text'])) {
|
||||
$song_text = $song_info['text'];
|
||||
} elseif (!empty($song_info['artist'])) {
|
||||
$song_text = $song_info['artist'] . ' - ' . $song_info['title'];
|
||||
} else {
|
||||
$song_text = $song_info['title'];
|
||||
if ($song instanceof Api\Song) {
|
||||
$this->setText($song->text);
|
||||
$this->setTitle($song->title);
|
||||
$this->setArtist($song->artist);
|
||||
$this->song_id = $song->id;
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip non-alphanumeric characters
|
||||
$song_text = mb_substr($song_text, 0, 150, 'UTF-8');
|
||||
$hash_base = mb_strtolower(str_replace([' ', '-'], ['', ''], $song_text), 'UTF-8');
|
||||
if (is_array($song)) {
|
||||
$song = new CurrentSong(
|
||||
$song['text'] ?? null,
|
||||
$song['title'] ?? null,
|
||||
$song['artist'] ?? null
|
||||
);
|
||||
} elseif (is_string($song)) {
|
||||
$song = new CurrentSong($song);
|
||||
}
|
||||
|
||||
return md5($hash_base);
|
||||
if ($song instanceof CurrentSong) {
|
||||
$this->setText($song->text);
|
||||
$this->setTitle($song->title);
|
||||
$this->setArtist($song->artist);
|
||||
$this->updateSongId();
|
||||
return;
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException('$song must be an array or an instance of ' . CurrentSong::class . '.');
|
||||
}
|
||||
|
||||
public function getSong(): self
|
||||
{
|
||||
return new self($this);
|
||||
}
|
||||
|
||||
public function getSongId(): string
|
||||
{
|
||||
return $this->song_id;
|
||||
}
|
||||
|
||||
public function updateSongId(): void
|
||||
{
|
||||
$this->song_id = self::getSongHash($this->getText());
|
||||
}
|
||||
|
||||
public function getText(): ?string
|
||||
{
|
||||
return $this->text;
|
||||
return $this->text ?? $this->artist . ' - ' . $this->title;
|
||||
}
|
||||
|
||||
public function setText(?string $text): void
|
||||
{
|
||||
$this->text = $this->truncateString($text, 150);
|
||||
}
|
||||
|
||||
public function getArtist(): ?string
|
||||
|
@ -159,48 +116,24 @@ class Song
|
|||
return $this->artist;
|
||||
}
|
||||
|
||||
public function setArtist(?string $artist): void
|
||||
{
|
||||
$this->artist = $this->truncateString($artist, 150);
|
||||
}
|
||||
|
||||
public function getTitle(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
public function setTitle(?string $title): void
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCreated(): int
|
||||
{
|
||||
return $this->created;
|
||||
}
|
||||
|
||||
public function getPlayCount(): int
|
||||
{
|
||||
return $this->play_count;
|
||||
}
|
||||
|
||||
public function getLastPlayed(): int
|
||||
{
|
||||
return $this->last_played;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the play counter and last-played items.
|
||||
*/
|
||||
public function played(): void
|
||||
{
|
||||
++$this->play_count;
|
||||
$this->last_played = time();
|
||||
}
|
||||
|
||||
public function getHistory(): Collection
|
||||
{
|
||||
return $this->history;
|
||||
$this->title = $this->truncateString($title, 150);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'Song ' . $this->id . ': ' . $this->artist . ' - ' . $this->title;
|
||||
return 'Song ' . $this->song_id . ': ' . $this->artist . ' - ' . $this->title;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -212,13 +145,13 @@ class Song
|
|||
*
|
||||
* @return Api\Song
|
||||
*/
|
||||
public function api(
|
||||
public function getSongApi(
|
||||
ApiUtilities $api_utils,
|
||||
?Station $station = null,
|
||||
?UriInterface $base_url = null
|
||||
): Api\Song {
|
||||
$response = new Api\Song;
|
||||
$response->id = (string)$this->id;
|
||||
$response->id = (string)$this->song_id;
|
||||
$response->text = (string)$this->text;
|
||||
$response->artist = (string)$this->artist;
|
||||
$response->title = (string)$this->title;
|
||||
|
@ -228,4 +161,33 @@ class Song
|
|||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array|CurrentSong|self|string $songText
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function getSongHash($songText): string
|
||||
{
|
||||
// Handle various input types.
|
||||
if ($songText instanceof self) {
|
||||
return self::getSongHash($songText->getText());
|
||||
}
|
||||
if ($songText instanceof CurrentSong) {
|
||||
return self::getSongHash($songText->text);
|
||||
}
|
||||
if (is_array($songText)) {
|
||||
return self::getSongHash($songText['text'] ?? '');
|
||||
}
|
||||
|
||||
if (!is_string($songText)) {
|
||||
throw new \InvalidArgumentException('$songText parameter must be a string, array, or instance of ' . self::class . ' or ' . CurrentSong::class . '.');
|
||||
}
|
||||
|
||||
// Strip non-alphanumeric characters
|
||||
$song_text = mb_substr($songText, 0, 150, 'UTF-8');
|
||||
$hash_base = mb_strtolower(str_replace([' ', '-'], ['', ''], $song_text), 'UTF-8');
|
||||
|
||||
return md5($hash_base);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ use Psr\Http\Message\UriInterface;
|
|||
* })
|
||||
* @ORM\Entity()
|
||||
*/
|
||||
class SongHistory
|
||||
class SongHistory extends Song
|
||||
{
|
||||
use Traits\TruncateInts;
|
||||
|
||||
|
@ -30,21 +30,6 @@ class SongHistory
|
|||
*/
|
||||
protected $id;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="song_id", type="string", length=50)
|
||||
* @var string
|
||||
*/
|
||||
protected $song_id;
|
||||
|
||||
/**
|
||||
* @ORM\ManyToOne(targetEntity="Song", inversedBy="history")
|
||||
* @ORM\JoinColumns({
|
||||
* @ORM\JoinColumn(name="song_id", referencedColumnName="id", onDelete="CASCADE")
|
||||
* })
|
||||
* @var Song
|
||||
*/
|
||||
protected $song;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="station_id", type="integer")
|
||||
* @var int
|
||||
|
@ -180,9 +165,12 @@ class SongHistory
|
|||
*/
|
||||
protected $delta_points;
|
||||
|
||||
public function __construct(Song $song, Station $station)
|
||||
{
|
||||
$this->song = $song;
|
||||
public function __construct(
|
||||
Station $station,
|
||||
Song $song
|
||||
) {
|
||||
parent::__construct($song);
|
||||
|
||||
$this->station = $station;
|
||||
|
||||
$this->timestamp_start = 0;
|
||||
|
@ -203,11 +191,6 @@ class SongHistory
|
|||
return $this->id;
|
||||
}
|
||||
|
||||
public function getSong(): Song
|
||||
{
|
||||
return $this->song;
|
||||
}
|
||||
|
||||
public function getStation(): Station
|
||||
{
|
||||
return $this->station;
|
||||
|
@ -424,21 +407,23 @@ class SongHistory
|
|||
|
||||
$response->song = ($this->media)
|
||||
? $this->media->api($api, $base_url)
|
||||
: $this->song->api($api, $this->station, $base_url);
|
||||
: $this->getSongApi($api, $this->station, $base_url);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function __toString()
|
||||
public function __toString(): string
|
||||
{
|
||||
return (null !== $this->media)
|
||||
? (string)$this->media
|
||||
: (string)$this->song;
|
||||
if ($this->media instanceof StationMedia) {
|
||||
return (string)$this->media;
|
||||
}
|
||||
|
||||
return parent::__toString();
|
||||
}
|
||||
|
||||
public static function fromQueue(StationQueue $queue): self
|
||||
{
|
||||
$sh = new self($queue->getSong(), $queue->getStation());
|
||||
$sh = new self($queue->getStation(), $queue->getSong());
|
||||
$sh->setMedia($queue->getMedia());
|
||||
$sh->setRequest($queue->getRequest());
|
||||
$sh->setPlaylist($queue->getPlaylist());
|
||||
|
|
|
@ -22,7 +22,7 @@ use Symfony\Component\Serializer\Annotation as Serializer;
|
|||
*
|
||||
* @OA\Schema(type="object")
|
||||
*/
|
||||
class StationMedia
|
||||
class StationMedia extends Song
|
||||
{
|
||||
use Traits\UniqueId, Traits\TruncateStrings;
|
||||
|
||||
|
@ -54,42 +54,6 @@ class StationMedia
|
|||
*/
|
||||
protected $station;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="song_id", type="string", length=50, nullable=true)
|
||||
*
|
||||
* @OA\Property(example="098F6BCD4621D373CADE4E832627B4F6")
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $song_id;
|
||||
|
||||
/**
|
||||
* @ORM\ManyToOne(targetEntity="Song")
|
||||
* @ORM\JoinColumns({
|
||||
* @ORM\JoinColumn(name="song_id", referencedColumnName="id", onDelete="SET NULL")
|
||||
* })
|
||||
* @var Song|null
|
||||
*/
|
||||
protected $song;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="title", type="string", length=200, nullable=true)
|
||||
*
|
||||
* @OA\Property(example="Test Song")
|
||||
*
|
||||
* @var string|null The name of the media file's title.
|
||||
*/
|
||||
protected $title;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="artist", type="string", length=200, nullable=true)
|
||||
*
|
||||
* @OA\Property(example="Test Artist")
|
||||
*
|
||||
* @var string|null The name of the media file's artist.
|
||||
*/
|
||||
protected $artist;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="album", type="string", length=200, nullable=true)
|
||||
*
|
||||
|
@ -243,6 +207,8 @@ class StationMedia
|
|||
|
||||
public function __construct(Station $station, string $path)
|
||||
{
|
||||
parent::__construct(null);
|
||||
|
||||
$this->station = $station;
|
||||
|
||||
$this->playlists = new ArrayCollection;
|
||||
|
@ -262,31 +228,6 @@ class StationMedia
|
|||
return $this->station;
|
||||
}
|
||||
|
||||
public function getSongId(): ?string
|
||||
{
|
||||
return $this->song_id;
|
||||
}
|
||||
|
||||
public function getTitle(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function setTitle(?string $title = null): void
|
||||
{
|
||||
$this->title = $this->truncateString($title, 200);
|
||||
}
|
||||
|
||||
public function getArtist(): ?string
|
||||
{
|
||||
return $this->artist;
|
||||
}
|
||||
|
||||
public function setArtist(?string $artist = null): void
|
||||
{
|
||||
$this->artist = $this->truncateString($artist, 200);
|
||||
}
|
||||
|
||||
public function getAlbum(): ?string
|
||||
{
|
||||
return $this->album;
|
||||
|
@ -546,58 +487,9 @@ class StationMedia
|
|||
$this->custom_fields = $custom_fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate whether this media needs reprocessing given certain factors.
|
||||
*
|
||||
* @param int $current_mtime
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function needsReprocessing($current_mtime = 0): bool
|
||||
{
|
||||
if ($current_mtime > $this->mtime) {
|
||||
return true;
|
||||
}
|
||||
if (!$this->songMatches()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the hash of the associated Song record matches the hash that would be
|
||||
* generated by this record's artist and title metadata. Used to determine if a
|
||||
* record should be reprocessed or not.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function songMatches(): bool
|
||||
{
|
||||
return (null !== $this->song_id)
|
||||
&& ($this->song_id === $this->getExpectedSongHash());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate song hash for the title and artist specified here.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getExpectedSongHash(): string
|
||||
{
|
||||
return Song::getSongHash([
|
||||
'artist' => $this->artist,
|
||||
'title' => $this->title,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getSong(): ?Song
|
||||
{
|
||||
return $this->song;
|
||||
}
|
||||
|
||||
public function setSong(?Song $song = null): void
|
||||
{
|
||||
$this->song = $song;
|
||||
return $current_mtime > $this->mtime;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -641,7 +533,7 @@ class StationMedia
|
|||
{
|
||||
$response = new Api\Song;
|
||||
$response->id = (string)$this->song_id;
|
||||
$response->text = $this->artist . ' - ' . $this->title;
|
||||
$response->text = (string)$this->text;
|
||||
$response->artist = (string)$this->artist;
|
||||
$response->title = (string)$this->title;
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ use Psr\Http\Message\UriInterface;
|
|||
* @ORM\Table(name="station_queue")
|
||||
* @ORM\Entity()
|
||||
*/
|
||||
class StationQueue
|
||||
class StationQueue extends Song
|
||||
{
|
||||
use Traits\TruncateInts;
|
||||
|
||||
|
@ -21,21 +21,6 @@ class StationQueue
|
|||
*/
|
||||
protected $id;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="song_id", type="string", length=50)
|
||||
* @var string
|
||||
*/
|
||||
protected $song_id;
|
||||
|
||||
/**
|
||||
* @ORM\ManyToOne(targetEntity="Song", inversedBy="history")
|
||||
* @ORM\JoinColumns({
|
||||
* @ORM\JoinColumn(name="song_id", referencedColumnName="id", onDelete="CASCADE")
|
||||
* })
|
||||
* @var Song
|
||||
*/
|
||||
protected $song;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="station_id", type="integer")
|
||||
* @var int
|
||||
|
@ -128,9 +113,10 @@ class StationQueue
|
|||
|
||||
public function __construct(Station $station, Song $song)
|
||||
{
|
||||
$this->song = $song;
|
||||
$this->station = $station;
|
||||
parent::__construct($song);
|
||||
|
||||
$this->station = $station;
|
||||
|
||||
$this->sent_to_autodj = false;
|
||||
}
|
||||
|
||||
|
@ -139,11 +125,6 @@ class StationQueue
|
|||
return $this->id;
|
||||
}
|
||||
|
||||
public function getSong(): Song
|
||||
{
|
||||
return $this->song;
|
||||
}
|
||||
|
||||
public function getStation(): Station
|
||||
{
|
||||
return $this->station;
|
||||
|
@ -265,15 +246,15 @@ class StationQueue
|
|||
|
||||
$response->song = ($this->media)
|
||||
? $this->media->api($api, $base_url)
|
||||
: $this->song->api($api, $this->station, $base_url);
|
||||
: $this->getSongApi($api, $this->station, $base_url);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function __toString()
|
||||
public function __toString(): string
|
||||
{
|
||||
return (null !== $this->media)
|
||||
? (string)$this->media
|
||||
: (string)$this->song;
|
||||
: parent::__toString();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,8 +20,6 @@ class Queue implements EventSubscriberInterface
|
|||
|
||||
protected Entity\Repository\StationPlaylistMediaRepository $spmRepo;
|
||||
|
||||
protected Entity\Repository\SongRepository $songRepo;
|
||||
|
||||
protected Entity\Repository\StationRequestRepository $requestRepo;
|
||||
|
||||
protected Entity\Repository\SongHistoryRepository $historyRepo;
|
||||
|
@ -31,7 +29,6 @@ class Queue implements EventSubscriberInterface
|
|||
LoggerInterface $logger,
|
||||
Scheduler $scheduler,
|
||||
Entity\Repository\StationPlaylistMediaRepository $spmRepo,
|
||||
Entity\Repository\SongRepository $songRepo,
|
||||
Entity\Repository\StationRequestRepository $requestRepo,
|
||||
Entity\Repository\SongHistoryRepository $historyRepo
|
||||
) {
|
||||
|
@ -39,7 +36,6 @@ class Queue implements EventSubscriberInterface
|
|||
$this->logger = $logger;
|
||||
$this->scheduler = $scheduler;
|
||||
$this->spmRepo = $spmRepo;
|
||||
$this->songRepo = $songRepo;
|
||||
$this->requestRepo = $requestRepo;
|
||||
$this->historyRepo = $historyRepo;
|
||||
}
|
||||
|
@ -106,7 +102,7 @@ class Queue implements EventSubscriberInterface
|
|||
$logOncePerXSongsSongHistory = [];
|
||||
foreach ($recentSongHistoryForOncePerXSongs as $row) {
|
||||
$logOncePerXSongsSongHistory[] = [
|
||||
'song' => $row['song']['text'],
|
||||
'song' => $row['text'],
|
||||
'cued_at' => (string)(CarbonImmutable::createFromTimestamp($row['timestamp_cued'] ?? $row['timestamp_start'],
|
||||
$now->getTimezone())),
|
||||
'duration' => $row['duration'],
|
||||
|
@ -117,7 +113,7 @@ class Queue implements EventSubscriberInterface
|
|||
$logDuplicatePreventionSongHistory = [];
|
||||
foreach ($recentSongHistoryForDuplicatePrevention as $row) {
|
||||
$logDuplicatePreventionSongHistory[] = [
|
||||
'song' => $row['song']['text'],
|
||||
'song' => $row['text'],
|
||||
'cued_at' => (string)(CarbonImmutable::createFromTimestamp($row['timestamp_cued'] ?? $row['timestamp_start'],
|
||||
$now->getTimezone())),
|
||||
'duration' => $row['duration'],
|
||||
|
@ -268,9 +264,10 @@ class Queue implements EventSubscriberInterface
|
|||
$playlist->setPlayedAt($now->getTimestamp());
|
||||
$this->em->persist($playlist);
|
||||
|
||||
$sh = new Entity\StationQueue($playlist->getStation(), $this->songRepo->getOrCreate([
|
||||
'text' => 'Remote Playlist URL',
|
||||
]));
|
||||
$sh = new Entity\StationQueue(
|
||||
$playlist->getStation(),
|
||||
new Entity\Song('Remote Playlist URL')
|
||||
);
|
||||
|
||||
$sh->setPlaylist($playlist);
|
||||
$sh->setAutodjCustomUri($media_uri);
|
||||
|
@ -440,11 +437,11 @@ class Queue implements EventSubscriberInterface
|
|||
|
||||
foreach ($playedMedia as $history) {
|
||||
$playedTracks[] = [
|
||||
'artist' => $history['song']['artist'],
|
||||
'title' => $history['song']['title'],
|
||||
'artist' => $history['artist'],
|
||||
'title' => $history['title'],
|
||||
];
|
||||
|
||||
$songId = $history['song']['id'];
|
||||
$songId = $history['song_id'];
|
||||
|
||||
if (!isset($latestSongIdsPlayed[$songId])) {
|
||||
$latestSongIdsPlayed[$songId] = $history['timestamp_cued'] ?? $history['timestamp_start'];
|
||||
|
@ -460,8 +457,8 @@ class Queue implements EventSubscriberInterface
|
|||
}
|
||||
|
||||
$eligibleTracks[$media['id']] = [
|
||||
'artist' => $media['song']['artist'],
|
||||
'title' => $media['song']['title'],
|
||||
'artist' => $media['artist'],
|
||||
'title' => $media['title'],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ use Psr\Log\LoggerInterface;
|
|||
use Psr\SimpleCache\CacheInterface;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Lock\LockInterface;
|
||||
use Symfony\Component\Messenger\MessageBus;
|
||||
use Symfony\Component\Messenger\Stamp\DelayStamp;
|
||||
use function DeepCopy\deep_copy;
|
||||
|
@ -46,8 +47,6 @@ class NowPlaying extends AbstractTask implements EventSubscriberInterface
|
|||
|
||||
protected Entity\Repository\StationQueueRepository $queueRepo;
|
||||
|
||||
protected Entity\Repository\SongRepository $song_repo;
|
||||
|
||||
protected Entity\Repository\ListenerRepository $listener_repo;
|
||||
|
||||
protected LockFactory $lockFactory;
|
||||
|
@ -66,7 +65,6 @@ class NowPlaying extends AbstractTask implements EventSubscriberInterface
|
|||
MessageBus $messageBus,
|
||||
LockFactory $lockFactory,
|
||||
Entity\Repository\SongHistoryRepository $historyRepository,
|
||||
Entity\Repository\SongRepository $songRepository,
|
||||
Entity\Repository\ListenerRepository $listenerRepository,
|
||||
Entity\Repository\SettingsRepository $settingsRepository,
|
||||
Entity\Repository\StationQueueRepository $queueRepo
|
||||
|
@ -83,7 +81,6 @@ class NowPlaying extends AbstractTask implements EventSubscriberInterface
|
|||
$this->lockFactory = $lockFactory;
|
||||
|
||||
$this->history_repo = $historyRepository;
|
||||
$this->song_repo = $songRepository;
|
||||
$this->listener_repo = $listenerRepository;
|
||||
$this->queueRepo = $queueRepo;
|
||||
|
||||
|
@ -174,8 +171,7 @@ class NowPlaying extends AbstractTask implements EventSubscriberInterface
|
|||
Entity\Station $station,
|
||||
$standalone = false
|
||||
): Entity\Api\NowPlaying {
|
||||
$lock = $this->lockFactory->createLock('nowplaying_station_' . $station->getId(), 600);
|
||||
|
||||
$lock = $this->getLockForStation($station);
|
||||
$lock->acquire(true);
|
||||
|
||||
try {
|
||||
|
@ -236,11 +232,11 @@ class NowPlaying extends AbstractTask implements EventSubscriberInterface
|
|||
);
|
||||
|
||||
if (empty($npResult->currentSong->text)) {
|
||||
$song_obj = $this->song_repo->getOrCreate(['text' => 'Stream Offline'], true);
|
||||
$song_obj = new Entity\Song('Stream Offline');
|
||||
|
||||
$offline_sh = new Entity\Api\NowPlayingCurrentSong;
|
||||
$offline_sh->sh_id = 0;
|
||||
$offline_sh->song = $song_obj->api(
|
||||
$offline_sh->song = $song_obj->getSongApi(
|
||||
$this->api_utils,
|
||||
$station,
|
||||
$uri_empty
|
||||
|
@ -266,18 +262,16 @@ class NowPlaying extends AbstractTask implements EventSubscriberInterface
|
|||
|
||||
if ($np_old instanceof Entity\Api\NowPlaying &&
|
||||
0 === strcmp($current_song_hash, $np_old->now_playing->song->id)) {
|
||||
/** @var Entity\Song $song_obj */
|
||||
$song_obj = $this->song_repo->getRepository()->find($current_song_hash);
|
||||
$previousHistory = $this->history_repo->getCurrent($station);
|
||||
|
||||
$sh_obj = $this->history_repo->register($song_obj, $station, $np);
|
||||
$sh_obj = $this->history_repo->register($previousHistory, $station, $np);
|
||||
|
||||
$np->song_history = $np_old->song_history;
|
||||
$np->playing_next = $np_old->playing_next;
|
||||
} else {
|
||||
// SongHistory registration must ALWAYS come before the history/nextsong calls
|
||||
// otherwise they will not have up-to-date database info!
|
||||
$song_obj = $this->song_repo->getOrCreate($npResult->currentSong, true);
|
||||
$sh_obj = $this->history_repo->register($song_obj, $station, $np);
|
||||
$sh_obj = $this->history_repo->register(new Entity\Song($npResult->currentSong), $station, $np);
|
||||
|
||||
$np->song_history = $this->history_repo->getHistoryApi(
|
||||
$station,
|
||||
|
@ -316,7 +310,8 @@ class NowPlaying extends AbstractTask implements EventSubscriberInterface
|
|||
}
|
||||
|
||||
// Register a new item in song history.
|
||||
$np->now_playing = $sh_obj->api(new Entity\Api\NowPlayingCurrentSong, $this->api_utils, $uri_empty);
|
||||
$np->now_playing = $sh_obj->api(new Entity\Api\NowPlayingCurrentSong, $this->api_utils,
|
||||
$uri_empty);
|
||||
}
|
||||
|
||||
$np->update();
|
||||
|
@ -353,27 +348,27 @@ class NowPlaying extends AbstractTask implements EventSubscriberInterface
|
|||
*/
|
||||
public function queueStation(Entity\Station $station, array $extra_metadata = []): void
|
||||
{
|
||||
// Stop Now Playing from processing while doing the steps below.
|
||||
$station->setNowPlayingTimestamp(time());
|
||||
$this->em->persist($station);
|
||||
$this->em->flush();
|
||||
$lock = $this->getLockForStation($station);
|
||||
|
||||
// Process extra metadata sent by Liquidsoap (if it exists).
|
||||
if (!empty($extra_metadata['song_id'])) {
|
||||
$song = $this->song_repo->getRepository()->find($extra_metadata['song_id']);
|
||||
if (!$lock->acquire(true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($song instanceof Entity\Song) {
|
||||
$sq = $this->queueRepo->getUpcomingFromSong($station, $song);
|
||||
if (!$sq instanceof Entity\StationQueue) {
|
||||
$sq = new Entity\StationQueue($station, $song);
|
||||
$sq->setTimestampCued(time());
|
||||
try {
|
||||
// Process extra metadata sent by Liquidsoap (if it exists).
|
||||
if (!empty($extra_metadata['media_id'])) {
|
||||
$media = $this->em->find(Entity\StationMedia::class, $extra_metadata['media_id']);
|
||||
if (!$media instanceof Entity\StationMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!empty($extra_metadata['media_id']) && null === $sq->getMedia()) {
|
||||
$media = $this->em->find(Entity\StationMedia::class, $extra_metadata['media_id']);
|
||||
if ($media instanceof Entity\StationMedia) {
|
||||
$sq->setMedia($media);
|
||||
}
|
||||
$sq = $this->queueRepo->getUpcomingFromSong($station, $media->getSong());
|
||||
|
||||
if (!$sq instanceof Entity\StationQueue) {
|
||||
$sq = new Entity\StationQueue($station, $media->getSong());
|
||||
$sq->setTimestampCued(time());
|
||||
} elseif (null === $sq->getMedia()) {
|
||||
$sq->setMedia($media);
|
||||
}
|
||||
|
||||
if (!empty($extra_metadata['playlist_id']) && null === $sq->getPlaylist()) {
|
||||
|
@ -388,15 +383,17 @@ class NowPlaying extends AbstractTask implements EventSubscriberInterface
|
|||
$this->em->persist($sq);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
// Trigger a delayed Now Playing update.
|
||||
$message = new Message\UpdateNowPlayingMessage;
|
||||
$message->station_id = $station->getId();
|
||||
|
||||
$this->messageBus->dispatch($message, [
|
||||
new DelayStamp(2000),
|
||||
]);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
|
||||
// Trigger a delayed Now Playing update.
|
||||
$message = new Message\UpdateNowPlayingMessage;
|
||||
$message->station_id = $station->getId();
|
||||
|
||||
$this->messageBus->dispatch($message, [
|
||||
new DelayStamp(2000),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -460,4 +457,9 @@ class NowPlaying extends AbstractTask implements EventSubscriberInterface
|
|||
|
||||
return $query->getSingleResult();
|
||||
}
|
||||
|
||||
protected function getLockForStation(Station $station): LockInterface
|
||||
{
|
||||
return $this->lockFactory->createLock('nowplaying_station_' . $station->getId(), 600);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -219,9 +219,9 @@ class RadioAutomation extends AbstractTask
|
|||
$mediaQuery = $this->em->createQuery(/** @lang DQL */ 'SELECT
|
||||
sm
|
||||
FROM App\Entity\StationMedia sm
|
||||
WHERE sm.station_id = :station_id
|
||||
WHERE sm.station = :station
|
||||
ORDER BY sm.artist ASC, sm.title ASC')
|
||||
->setParameter('station_id', $station->getId());
|
||||
->setParameter('station', $station);
|
||||
|
||||
$iterator = SimpleBatchIteratorAggregate::fromQuery($mediaQuery, 100);
|
||||
$report = [];
|
||||
|
|
|
@ -2,14 +2,14 @@
|
|||
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary-dark">
|
||||
<h2 class="card-title"><?=__('Song Duplicates') ?></h2>
|
||||
<h2 class="card-title"><?=__('Song Duplicates')?></h2>
|
||||
</div>
|
||||
<?php if (empty($dupes)): ?>
|
||||
<div class="card-body">
|
||||
<p><?=__('No duplicates were found. Nice work!') ?></p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><?=__('No duplicates were found. Nice work!')?></p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?php foreach($dupes as $dupe_row): ?>
|
||||
<?php foreach ($dupes as $dupe_row): ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<colgroup>
|
||||
|
@ -20,21 +20,22 @@
|
|||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?=__('Actions') ?></th>
|
||||
<th><?=__('Title / File Path') ?></th>
|
||||
<th class="text-right"><?=__('Playlists') ?></th>
|
||||
<th class="text-right"><?=__('Length') ?></th>
|
||||
<th><?=__('Actions')?></th>
|
||||
<th><?=__('Title / File Path')?></th>
|
||||
<th class="text-right"><?=__('Playlists')?></th>
|
||||
<th class="text-right"><?=__('Length')?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach($dupe_row as $media_row): ?>
|
||||
<?php foreach ($dupe_row as $media_row): ?>
|
||||
<tr class="align-middle">
|
||||
<td>
|
||||
<a class="btn btn-sm btn-danger" href="<?=$router->fromHere('stations:reports:duplicates:delete', ['media_id' => $media_row['id']]) ?>"><?=__('Delete') ?></a>
|
||||
<a class="btn btn-sm btn-danger" href="<?=$router->fromHere('stations:reports:duplicates:delete',
|
||||
['media_id' => $media_row['id']])?>"><?=__('Delete')?></a>
|
||||
</td>
|
||||
<td>
|
||||
<big><?=$media_row['song']['artist'] ?> - <?=$media_row['song']['title'] ?></big><br>
|
||||
<?=$media_row['path'] ?>
|
||||
<big><?=$media_row['artist']?> - <?=$media_row['title']?></big><br>
|
||||
<?=$media_row['path']?>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<?php if (count($media_row['playlists']) == 0): ?>
|
||||
|
@ -42,14 +43,14 @@
|
|||
<?php else: ?>
|
||||
<?php
|
||||
$playlists = [];
|
||||
foreach($media_row['playlists'] as $playlist) {
|
||||
foreach ($media_row['playlists'] as $playlist) {
|
||||
$playlists[] = $playlist['name'];
|
||||
}
|
||||
?>
|
||||
<abbr title="<?=implode(', ', $playlists) ?>"><?=count($playlists) ?></abbr>
|
||||
<abbr title="<?=implode(', ', $playlists)?>"><?=count($playlists)?></abbr>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-right"><?=$media_row['length_text'] ?></td>
|
||||
<td class="text-right"><?=$media_row['length_text']?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
|
|
|
@ -17,30 +17,30 @@ $assets
|
|||
<div class="card-header">
|
||||
<ul class="nav nav-pills card-header-pills">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" role="tab" data-toggle="tab" aria-expanded="true" aria-controls="listeners-by-day" href="#listeners-by-day"><?=__('Listeners by Day') ?></a>
|
||||
<a class="nav-link active" role="tab" data-toggle="tab" aria-expanded="true" aria-controls="listeners-by-day" href="#listeners-by-day"><?=__('Listeners by Day')?></a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" role="tab" data-toggle="tab" aria-controls="listeners-by-hour" href="#listeners-by-hour"><?=__('Listeners by Hour') ?></a>
|
||||
<a class="nav-link" role="tab" data-toggle="tab" aria-controls="listeners-by-hour" href="#listeners-by-hour"><?=__('Listeners by Hour')?></a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" role="tab" data-toggle="tab" aria-controls="listeners-by-day-of-week" href="#listeners-by-day-of-week"><?=__('Listeners by Day of Week') ?></a>
|
||||
<a class="nav-link" role="tab" data-toggle="tab" aria-controls="listeners-by-day-of-week" href="#listeners-by-day-of-week"><?=__('Listeners by Day of Week')?></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane px-0 card-body active" id="listeners-by-day" role="tabpanel">
|
||||
<canvas id="listeners_by_day" style="width: 100%;" aria-label="<?=__('Listeners by Day') ?>" role="img">
|
||||
<?=$charts['daily_alt'] ?>
|
||||
<canvas id="listeners_by_day" style="width: 100%;" aria-label="<?=__('Listeners by Day')?>" role="img">
|
||||
<?=$charts['daily_alt']?>
|
||||
</canvas>
|
||||
</div>
|
||||
<div class="tab-pane px-0 card-body" id="listeners-by-hour" role="tabpanel">
|
||||
<canvas id="listeners_by_hour" style="width: 100%;" aria-label="<?=__('Listeners by Hour') ?>" role="img">
|
||||
<?=$charts['hourly_alt'] ?>
|
||||
<canvas id="listeners_by_hour" style="width: 100%;" aria-label="<?=__('Listeners by Hour')?>" role="img">
|
||||
<?=$charts['hourly_alt']?>
|
||||
</canvas>
|
||||
</div>
|
||||
<div class="tab-pane px-0 card-body" id="listeners-by-day-of-week" role="tabpanel">
|
||||
<canvas id="listeners_by_day_of_week" style="width: 100%;" aria-label="<?=__('Listeners by Day of Week') ?>" role="img">
|
||||
<?=$charts['day_of_week_alt'] ?>
|
||||
<canvas id="listeners_by_day_of_week" style="width: 100%;" aria-label="<?=__('Listeners by Day of Week')?>" role="img">
|
||||
<?=$charts['day_of_week_alt']?>
|
||||
</canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -53,8 +53,8 @@ $assets
|
|||
<section class="card mb-3" role="region">
|
||||
<div class="card-header bg-primary-dark">
|
||||
<h2 class="card-title">
|
||||
<?=__('Best Performing Songs') ?>
|
||||
<small><?=__('in the last 48 hours') ?></small>
|
||||
<?=__('Best Performing Songs')?>
|
||||
<small><?=__('in the last 48 hours')?></small>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
|
@ -64,24 +64,25 @@ $assets
|
|||
<col width="80%">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?=__('Change') ?></th>
|
||||
<th><?=__('Song') ?></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><?=__('Change')?></th>
|
||||
<th><?=__('Song')?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach($best_performing_songs as $song_row): ?>
|
||||
<?php foreach ($best_performing_songs as $song_row): ?>
|
||||
<tr>
|
||||
<td class="text-center text-success">
|
||||
<i class="material-icons" aria-hidden="true">keyboard_arrow_up</i> <?=abs($song_row['stat_delta']) ?><br>
|
||||
<small><?=$song_row['stat_start'] ?> to <?=$song_row['stat_end'] ?>
|
||||
<i class="material-icons" aria-hidden="true">keyboard_arrow_up</i> <?=abs($song_row['stat_delta'])?>
|
||||
<br>
|
||||
<small><?=$song_row['stat_start']?> to <?=$song_row['stat_end']?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($song_row['song']['title']): ?>
|
||||
<b><?=$song_row['song']['title'] ?></b><br>
|
||||
<?=$song_row['song']['artist'] ?>
|
||||
<?php if ($song_row['title']): ?>
|
||||
<b><?=$song_row['title']?></b><br>
|
||||
<?=$song_row['artist']?>
|
||||
<?php else: ?>
|
||||
<?=$song_row['song']['text'] ?>
|
||||
<?=$song_row['text']?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -95,8 +96,8 @@ $assets
|
|||
<section class="card mb-3" role="region">
|
||||
<div class="card-header bg-primary-dark">
|
||||
<h2 class="card-title">
|
||||
<?=__('Worst Performing Songs') ?>
|
||||
<small><?=__('in the last 48 hours') ?></small>
|
||||
<?=__('Worst Performing Songs')?>
|
||||
<small><?=__('in the last 48 hours')?></small>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
|
@ -106,24 +107,25 @@ $assets
|
|||
<col width="80%">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?=__('Change') ?></th>
|
||||
<th><?=__('Song') ?></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><?=__('Change')?></th>
|
||||
<th><?=__('Song')?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach($worst_performing_songs as $song_row): ?>
|
||||
<?php foreach ($worst_performing_songs as $song_row): ?>
|
||||
<tr>
|
||||
<td class="text-center text-danger">
|
||||
<i class="material-icons" aria-hidden="true">keyboard_arrow_down</i> <?=abs($song_row['stat_delta']) ?><br>
|
||||
<small><?=$song_row['stat_start'] ?> to <?=$song_row['stat_end'] ?>
|
||||
<i class="material-icons" aria-hidden="true">keyboard_arrow_down</i> <?=abs($song_row['stat_delta'])?>
|
||||
<br>
|
||||
<small><?=$song_row['stat_start']?> to <?=$song_row['stat_end']?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($song_row['song']['title']): ?>
|
||||
<b><?=$song_row['song']['title'] ?></b><br>
|
||||
<?=$song_row['song']['artist'] ?>
|
||||
<?php if ($song_row['title']): ?>
|
||||
<b><?=$song_row['title']?></b><br>
|
||||
<?=$song_row['artist']?>
|
||||
<?php else: ?>
|
||||
<?=$song_row['song']['text'] ?>
|
||||
<?=$song_row['text']?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -140,8 +142,8 @@ $assets
|
|||
<section class="card" role="region">
|
||||
<div class="card-header bg-primary-dark">
|
||||
<h2 class="card-title">
|
||||
<?=__('Most Played Songs') ?>
|
||||
<small><?=__('in the last month') ?></small>
|
||||
<?=__('Most Played Songs')?>
|
||||
<small><?=__('in the last month')?></small>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
|
@ -151,22 +153,22 @@ $assets
|
|||
<col width="90%">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?=__('Plays') ?></th>
|
||||
<th><?=__('Song') ?></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><?=__('Plays')?></th>
|
||||
<th><?=__('Song')?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach($song_totals['played'] as $song_row): ?>
|
||||
<?php foreach ($song_totals['played'] as $song_row): ?>
|
||||
<tr>
|
||||
<td class="text-center"><?=$song_row['records'] ?></td>
|
||||
<td class="text-center"><?=$song_row['records']?></td>
|
||||
<td>
|
||||
<?php if ($song_row['song']['title']): ?>
|
||||
<b><?=$song_row['song']['title'] ?></b><br>
|
||||
<?=$song_row['song']['artist'] ?>
|
||||
<?php else: ?>
|
||||
<?=$song_row['song']['text'] ?>
|
||||
<?php endif; ?>
|
||||
<?php if ($song_row['title']): ?>
|
||||
<b><?=$song_row['title']?></b><br>
|
||||
<?=$song_row['artist']?>
|
||||
<?php else: ?>
|
||||
<?=$song_row['text']?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
|
Loading…
Reference in New Issue