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:
Buster "Silver Eagle" Neece 2020-10-04 17:35:41 -05:00 committed by GitHub
parent c4b065b044
commit c81ff62b5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 548 additions and 741 deletions

View File

@ -3,6 +3,7 @@
#
APPLICATION_ENV=development
LOG_LEVEL=debug
ENABLE_ADVANCED_FEATURES=true
COMPOSER_PLUGIN_MODE=false

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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