4
0
mirror of https://github.com/AzuraCast/AzuraCast.git synced 2024-06-14 05:06:37 +00:00

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 APPLICATION_ENV=development
LOG_LEVEL=debug
ENABLE_ADVANCED_FEATURES=true ENABLE_ADVANCED_FEATURES=true
COMPOSER_PLUGIN_MODE=false COMPOSER_PLUGIN_MODE=false

View File

@ -6,6 +6,13 @@
# Valid options: production, development, testing # Valid options: production, development, testing
APPLICATION_ENV=production 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 # Enable certain advanced features inside the web interface, including
# advanced playlist coniguration, station port assignment, changing base # advanced playlist coniguration, station port assignment, changing base
# media directory, and other functionality that should only be used by # 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), Doctrine\ORM\EntityManagerInterface::class => DI\Get(App\Doctrine\DecoratedEntityManager::class),
// Cache // Cache
Psr\Cache\CacheItemPoolInterface::class => function (App\Settings $settings, Psr\Container\ContainerInterface $di) { Psr\Cache\CacheItemPoolInterface::class => DI\autowire(Cache\Adapter\Redis\RedisCachePool::class),
// 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\SimpleCache\CacheInterface::class => DI\get(Psr\Cache\CacheItemPoolInterface::class), Psr\SimpleCache\CacheInterface::class => DI\get(Psr\Cache\CacheItemPoolInterface::class),
// Doctrine cache // Doctrine cache
@ -209,15 +202,35 @@ return [
// Monolog Logger // Monolog Logger
Monolog\Logger::class => function (App\Settings $settings) { Monolog\Logger::class => function (App\Settings $settings) {
$logger = new Monolog\Logger($settings[App\Settings::APP_NAME] ?? 'app'); $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]) { 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); $logger->pushHandler($log_stderr);
} }
$log_file = new Monolog\Handler\StreamHandler($settings[App\Settings::TEMP_DIR] . '/app.log', $log_file = new Monolog\Handler\StreamHandler($settings[App\Settings::TEMP_DIR] . '/app.log',
$logging_level, true); $loggingLevel, true);
$logger->pushHandler($log_file); $logger->pushHandler($log_file);
return $logger; return $logger;
@ -275,7 +288,9 @@ return [
Psr\Log\LoggerInterface $logger Psr\Log\LoggerInterface $logger
) { ) {
$redisStore = new Symfony\Component\Lock\Store\RedisStore($redis); $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 = new Symfony\Component\Lock\LockFactory($retryStore);
$lockFactory->setLogger($logger); $lockFactory->setLogger($logger);

View File

@ -5,6 +5,7 @@ use App\Entity;
use App\Flysystem\Filesystem; use App\Flysystem\Filesystem;
use App\Http\Response; use App\Http\Response;
use App\Http\ServerRequest; use App\Http\ServerRequest;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UploadedFileInterface; use Psr\Http\Message\UploadedFileInterface;
@ -15,6 +16,7 @@ class PostArtAction
Response $response, Response $response,
Filesystem $filesystem, Filesystem $filesystem,
Entity\Repository\StationMediaRepository $mediaRepo, Entity\Repository\StationMediaRepository $mediaRepo,
EntityManagerInterface $em,
$media_id $media_id
): ResponseInterface { ): ResponseInterface {
$station = $request->getStation(); $station = $request->getStation();
@ -32,6 +34,7 @@ class PostArtAction
/** @var UploadedFileInterface $file */ /** @var UploadedFileInterface $file */
if ($file->getError() === UPLOAD_ERR_OK) { if ($file->getError() === UPLOAD_ERR_OK) {
$mediaRepo->writeAlbumArt($media, $file->getStream()->getContents()); $mediaRepo->writeAlbumArt($media, $file->getStream()->getContents());
$em->flush();
} elseif ($file->getError() !== UPLOAD_ERR_NO_FILE) { } elseif ($file->getError() !== UPLOAD_ERR_NO_FILE) {
return $response->withStatus(500) return $response->withStatus(500)
->withJson(new Entity\Api\Error(500, $file->getError())); ->withJson(new Entity\Api\Error(500, $file->getError()));

View File

@ -6,8 +6,6 @@ use App\Http\Response;
use App\Http\ServerRequest; use App\Http\ServerRequest;
use App\Service\Flow; use App\Service\Flow;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Error;
use Exception;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
class FlowUploadAction class FlowUploadAction
@ -27,51 +25,46 @@ class FlowUploadAction
->withJson(new Entity\Api\Error(500, __('This station is out of available storage space.'))); ->withJson(new Entity\Api\Error(500, __('This station is out of available storage space.')));
} }
try { $flowResponse = Flow::process($request, $response, $station->getRadioTempDir());
$flowResponse = Flow::process($request, $response, $station->getRadioTempDir()); if ($flowResponse instanceof ResponseInterface) {
if ($flowResponse instanceof ResponseInterface) { return $flowResponse;
return $flowResponse; }
}
if (is_array($flowResponse)) { if (is_array($flowResponse)) {
$file = $request->getAttribute('file'); $file = $request->getAttribute('file');
$filePath = $request->getAttribute('file_path'); $filePath = $request->getAttribute('file_path');
$sanitizedName = $flowResponse['filename']; $sanitizedName = $flowResponse['filename'];
$finalPath = empty($file) $finalPath = empty($file)
? $filePath . $sanitizedName ? $filePath . $sanitizedName
: $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 the user is looking at a playlist's contents, add uploaded media to that playlist.
if (!empty($params['searchPhrase'])) { if (!empty($params['searchPhrase'])) {
$search_phrase = $params['searchPhrase']; $search_phrase = $params['searchPhrase'];
if (0 === strpos($search_phrase, 'playlist:')) { if (0 === strpos($search_phrase, 'playlist:')) {
$playlist_name = substr($search_phrase, 9); $playlist_name = substr($search_phrase, 9);
$playlist = $em->getRepository(Entity\StationPlaylist::class)->findOneBy([ $playlist = $em->getRepository(Entity\StationPlaylist::class)->findOneBy([
'station_id' => $station->getId(), 'station_id' => $station->getId(),
'name' => $playlist_name, 'name' => $playlist_name,
]); ]);
if ($playlist instanceof Entity\StationPlaylist) { if ($playlist instanceof Entity\StationPlaylist) {
$spmRepo->addMediaToPlaylist($station_media, $playlist); $spmRepo->addMediaToPlaylist($station_media, $playlist);
$em->flush(); $em->flush();
}
} }
} }
$station->addStorageUsed($flowResponse['size']);
$em->flush();
return $response->withJson(new Entity\Api\Status);
} }
} catch (Exception | Error $e) {
return $response->withStatus(500) $station->addStorageUsed($flowResponse['size']);
->withJson(new Entity\Api\Error(500, $e->getMessage())); $em->flush();
return $response->withJson(new Entity\Api\Status);
} }
return $response->withJson(['success' => false]); 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\CustomFieldRepository $custom_fields_repo;
protected Entity\Repository\SongRepository $song_repo;
protected Entity\Repository\StationMediaRepository $media_repo; protected Entity\Repository\StationMediaRepository $media_repo;
protected Entity\Repository\StationPlaylistMediaRepository $playlist_media_repo; protected Entity\Repository\StationPlaylistMediaRepository $playlist_media_repo;
@ -45,7 +43,6 @@ class FilesController extends AbstractStationApiCrudController
Adapters $adapters, Adapters $adapters,
MessageBus $messageBus, MessageBus $messageBus,
Entity\Repository\CustomFieldRepository $custom_fields_repo, Entity\Repository\CustomFieldRepository $custom_fields_repo,
Entity\Repository\SongRepository $song_repo,
Entity\Repository\StationMediaRepository $media_repo, Entity\Repository\StationMediaRepository $media_repo,
Entity\Repository\StationPlaylistMediaRepository $playlist_media_repo Entity\Repository\StationPlaylistMediaRepository $playlist_media_repo
) { ) {
@ -57,7 +54,6 @@ class FilesController extends AbstractStationApiCrudController
$this->custom_fields_repo = $custom_fields_repo; $this->custom_fields_repo = $custom_fields_repo;
$this->media_repo = $media_repo; $this->media_repo = $media_repo;
$this->song_repo = $song_repo;
$this->playlist_media_repo = $playlist_media_repo; $this->playlist_media_repo = $playlist_media_repo;
} }
@ -124,7 +120,7 @@ class FilesController extends AbstractStationApiCrudController
$sanitized_path = Filesystem::PREFIX_MEDIA . '://' . $api_record->getSanitizedPath(); $sanitized_path = Filesystem::PREFIX_MEDIA . '://' . $api_record->getSanitizedPath();
// Process temp path as regular media record. // 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); $return = $this->viewRecord($record, $request);
@ -257,16 +253,7 @@ class FilesController extends AbstractStationApiCrudController
$this->em->flush(); $this->em->flush();
if ($this->media_repo->writeToFile($record)) { if ($this->media_repo->writeToFile($record)) {
$song_info = [ $record->updateSongId();
'title' => $record->getTitle(),
'artist' => $record->getArtist(),
];
$song = $this->song_repo->getOrCreate($song_info);
$song->update($song_info);
$this->em->persist($song);
$record->setSong($song);
} }
if (null !== $custom_fields) { if (null !== $custom_fields) {

View File

@ -78,12 +78,11 @@ class HistoryController
$qb = $this->em->createQueryBuilder(); $qb = $this->em->createQueryBuilder();
$qb->select('sh, sr, sp, ss, s') $qb->select('sh, sr, sp, ss')
->from(Entity\SongHistory::class, 'sh') ->from(Entity\SongHistory::class, 'sh')
->leftJoin('sh.request', 'sr') ->leftJoin('sh.request', 'sr')
->leftJoin('sh.playlist', 'sp') ->leftJoin('sh.playlist', 'sp')
->leftJoin('sh.streamer', 'ss') ->leftJoin('sh.streamer', 'ss')
->leftJoin('sh.song', 's')
->where('sh.station_id = :station_id') ->where('sh.station_id = :station_id')
->andWhere('sh.timestamp_start >= :start AND sh.timestamp_start <= :end') ->andWhere('sh.timestamp_start >= :start AND sh.timestamp_start <= :end')
->andWhere('sh.listeners_start IS NOT NULL') ->andWhere('sh.listeners_start IS NOT NULL')
@ -113,8 +112,8 @@ class HistoryController
$datetime->format('g:ia'), $datetime->format('g:ia'),
$song_row['listeners_start'], $song_row['listeners_start'],
$song_row['delta_total'], $song_row['delta_total'],
$song_row['song']['title'] ?: $song_row['song']['text'], $song_row['title'] ?: $song_row['text'],
$song_row['song']['artist'], $song_row['artist'],
$song_row['playlist']['name'] ?? '', $song_row['playlist']['name'] ?? '',
$song_row['streamer']['display_name'] ?? $song_row['streamer']['streamer_username'] ?? '', $song_row['streamer']['display_name'] ?? $song_row['streamer']['streamer_username'] ?? '',
]; ];
@ -130,7 +129,7 @@ class HistoryController
$search_phrase = trim($params['searchPhrase']); $search_phrase = trim($params['searchPhrase']);
if (!empty($search_phrase)) { 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 . '%'); ->setParameter('query', '%' . $search_phrase . '%');
} }

View File

@ -51,9 +51,8 @@ class QueueController extends AbstractStationApiCrudController
{ {
$station = $request->getStation(); $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 FROM App\Entity\StationQueue sq
LEFT JOIN sq.song s
LEFT JOIN sq.media sm LEFT JOIN sq.media sm
LEFT JOIN sq.playlist sp LEFT JOIN sq.playlist sp
WHERE sq.station = :station WHERE sq.station = :station

View File

@ -67,9 +67,8 @@ class RequestsController
$qb = $this->em->createQueryBuilder(); $qb = $this->em->createQueryBuilder();
$qb->select('sm, s, spm, sp') $qb->select('sm, spm, sp')
->from(Entity\StationMedia::class, 'sm') ->from(Entity\StationMedia::class, 'sm')
->join('sm.song', 's')
->leftJoin('sm.playlists', 'spm') ->leftJoin('sm.playlists', 'spm')
->leftJoin('spm.playlist', 'sp') ->leftJoin('spm.playlist', 'sp')
->where('sm.station_id = :station_id') ->where('sm.station_id = :station_id')

View File

@ -32,9 +32,8 @@ class DuplicatesController
$station = $request->getStation(); $station = $request->getStation();
$dupesRaw = $this->em->createQuery(/** @lang DQL */ 'SELECT $dupesRaw = $this->em->createQuery(/** @lang DQL */ 'SELECT
sm, s, spm, sp sm, spm, sp
FROM App\Entity\StationMedia sm FROM App\Entity\StationMedia sm
JOIN sm.song s
LEFT JOIN sm.playlists spm LEFT JOIN sm.playlists spm
LEFT JOIN spm.playlist sp LEFT JOIN spm.playlist sp
WHERE sm.station = :station WHERE sm.station = :station

View File

@ -176,7 +176,7 @@ class OverviewController
$song_totals_raw = []; $song_totals_raw = [];
$song_totals_raw['played'] = $this->em->createQuery(/** @lang DQL */ 'SELECT $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 FROM App\Entity\SongHistory sh
WHERE sh.station_id = :station_id AND sh.timestamp_start >= :timestamp WHERE sh.station_id = :station_id AND sh.timestamp_start >= :timestamp
GROUP BY sh.song_id GROUP BY sh.song_id
@ -189,17 +189,8 @@ class OverviewController
// Compile the above data. // Compile the above data.
$song_totals = []; $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 ($song_totals_raw as $total_type => $total_records) {
foreach ($total_records as $total_record) { 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; $song_totals[$total_type][] = $total_record;
} }
@ -210,9 +201,8 @@ class OverviewController
$songPerformanceThreshold = CarbonImmutable::parse('-2 days', $station_tz)->getTimestamp(); $songPerformanceThreshold = CarbonImmutable::parse('-2 days', $station_tz)->getTimestamp();
// Get all songs played in timeline. // 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 FROM App\Entity\SongHistory sh
LEFT JOIN sh.song s
WHERE sh.station_id = :station_id WHERE sh.station_id = :station_id
AND sh.timestamp_start >= :timestamp AND sh.timestamp_start >= :timestamp
AND sh.listeners_start IS NOT NULL AND sh.listeners_start IS NOT NULL
@ -241,11 +231,7 @@ class OverviewController
$a = $a_arr['stat_delta']; $a = $a_arr['stat_delta'];
$b = $b_arr['stat_delta']; $b = $b_arr['stat_delta'];
if ($a == $b) { return $a <=> $b;
return 0;
}
return ($a > $b) ? 1 : -1;
}); });
return $request->getView()->renderToResponse($response, 'stations/reports/overview', [ return $request->getView()->renderToResponse($response, 'stations/reports/overview', [

View File

@ -24,10 +24,9 @@ class RequestsController
$station = $request->getStation(); $station = $request->getStation();
$requests = $this->em->createQuery(/** @lang DQL */ 'SELECT $requests = $this->em->createQuery(/** @lang DQL */ 'SELECT
sr, sm, s sr, sm
FROM App\Entity\StationRequest sr FROM App\Entity\StationRequest sr
JOIN sr.track sm JOIN sr.track sm
JOIN sm.song s
WHERE sr.station_id = :station_id WHERE sr.station_id = :station_id
ORDER BY sr.timestamp DESC') ORDER BY sr.timestamp DESC')
->setParameter('station_id', $station->getId()) ->setParameter('station_id', $station->getId())

View File

@ -69,7 +69,7 @@ class SoundExchangeController
} }
$history_rows = $this->em->createQuery(/** @lang DQL */ 'SELECT $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 FROM App\Entity\SongHistory sh
WHERE sh.station_id = :station_id WHERE sh.station_id = :station_id
AND sh.timestamp_start <= :time_end AND sh.timestamp_start <= :time_end
@ -86,25 +86,9 @@ class SoundExchangeController
} }
// Remove any reference to the "Stream Offline" song. // 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]); 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 // Assemble report items
$station_name = $station->getName(); $station_name = $station->getName();
@ -117,7 +101,7 @@ class SoundExchangeController
foreach ($history_rows_by_id as $song_id => $history_row) { 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. // Try to find the ISRC if it's not already listed.
if (array_key_exists('isrc', $song_row) && $song_row['isrc'] === null) { 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 []; return [];
} }
$history = $this->em->createQuery(/** @lang DQL */ 'SELECT sh, s $history = $this->em->createQuery(/** @lang DQL */ 'SELECT sh
FROM App\Entity\SongHistory sh FROM App\Entity\SongHistory sh
JOIN sh.song s
LEFT JOIN sh.media sm LEFT JOIN sh.media sm
WHERE sh.station_id = :station_id WHERE sh.station_id = :station_id
AND sh.timestamp_end != 0 AND sh.timestamp_end != 0
@ -76,16 +75,16 @@ class SongHistoryRepository extends Repository
CarbonInterface $now, CarbonInterface $now,
int $rows int $rows
): array { ): array {
$recentlyPlayed = $this->em->createQuery(/** @lang DQL */ 'SELECT sq, s $recentlyPlayed = $this->em->createQuery(/** @lang DQL */ 'SELECT sq
FROM App\Entity\StationQueue sq JOIN sq.song s FROM App\Entity\StationQueue sq
WHERE sq.station = :station WHERE sq.station = :station
ORDER BY sq.timestamp_cued DESC') ORDER BY sq.timestamp_cued DESC')
->setParameter('station', $station) ->setParameter('station', $station)
->setMaxResults($rows) ->setMaxResults($rows)
->getArrayResult(); ->getArrayResult();
$recentHistory = $this->em->createQuery(/** @lang DQL */ 'SELECT sh, s $recentHistory = $this->em->createQuery(/** @lang DQL */ 'SELECT sh
FROM App\Entity\SongHistory sh JOIN sh.song s FROM App\Entity\SongHistory sh
WHERE sh.station = :station WHERE sh.station = :station
AND (sh.timestamp_start != 0 AND sh.timestamp_start IS NOT NULL) AND (sh.timestamp_start != 0 AND sh.timestamp_start IS NOT NULL)
AND sh.timestamp_start >= :threshold AND sh.timestamp_start >= :threshold
@ -107,8 +106,8 @@ class SongHistoryRepository extends Repository
$timeRangeInSeconds = $minutes * 60; $timeRangeInSeconds = $minutes * 60;
$threshold = $now->getTimestamp() - $timeRangeInSeconds; $threshold = $now->getTimestamp() - $timeRangeInSeconds;
$recentlyPlayed = $this->em->createQuery(/** @lang DQL */ 'SELECT sq, s $recentlyPlayed = $this->em->createQuery(/** @lang DQL */ 'SELECT sq
FROM App\Entity\StationQueue sq JOIN sq.song s FROM App\Entity\StationQueue sq
WHERE sq.station = :station WHERE sq.station = :station
AND sq.timestamp_cued >= :threshold AND sq.timestamp_cued >= :threshold
ORDER BY sq.timestamp_cued DESC') ORDER BY sq.timestamp_cued DESC')
@ -116,8 +115,8 @@ class SongHistoryRepository extends Repository
->setParameter('threshold', $threshold) ->setParameter('threshold', $threshold)
->getArrayResult(); ->getArrayResult();
$recentHistory = $this->em->createQuery(/** @lang DQL */ 'SELECT sh, s $recentHistory = $this->em->createQuery(/** @lang DQL */ 'SELECT sh
FROM App\Entity\SongHistory sh JOIN sh.song s FROM App\Entity\SongHistory sh
WHERE sh.station = :station WHERE sh.station = :station
AND (sh.timestamp_start != 0 AND sh.timestamp_start IS NOT NULL) AND (sh.timestamp_start != 0 AND sh.timestamp_start IS NOT NULL)
AND sh.timestamp_start >= :threshold AND sh.timestamp_start >= :threshold
@ -140,7 +139,7 @@ class SongHistoryRepository extends Repository
$listeners = (int)$np->listeners->current; $listeners = (int)$np->listeners->current;
if ($last_sh instanceof Entity\SongHistory) { 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. // Updating the existing SongHistory item with a new data point.
$last_sh->addDeltaPoint($listeners); $last_sh->addDeltaPoint($listeners);
@ -197,7 +196,7 @@ class SongHistoryRepository extends Repository
$this->em->remove($sq); $this->em->remove($sq);
} else { } else {
// Processing a new SongHistory item. // Processing a new SongHistory item.
$sh = new Entity\SongHistory($song, $station); $sh = new Entity\SongHistory($station, $song);
$currentStreamer = $station->getCurrentStreamer(); $currentStreamer = $station->getCurrentStreamer();
if ($currentStreamer instanceof Entity\StationStreamer) { 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 Exception;
use getid3_exception; use getid3_exception;
use InvalidArgumentException; use InvalidArgumentException;
use NowPlaying\Result\CurrentSong;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\Serializer;
use voku\helper\UTF8; use voku\helper\UTF8;
@ -24,8 +25,6 @@ class StationMediaRepository extends Repository
{ {
protected Filesystem $filesystem; protected Filesystem $filesystem;
protected SongRepository $songRepo;
protected CustomFieldRepository $customFieldRepo; protected CustomFieldRepository $customFieldRepo;
public function __construct( public function __construct(
@ -34,11 +33,9 @@ class StationMediaRepository extends Repository
Settings $settings, Settings $settings,
LoggerInterface $logger, LoggerInterface $logger,
Filesystem $filesystem, Filesystem $filesystem,
SongRepository $songRepo,
CustomFieldRepository $customFieldRepo CustomFieldRepository $customFieldRepo
) { ) {
$this->filesystem = $filesystem; $this->filesystem = $filesystem;
$this->songRepo = $songRepo;
$this->customFieldRepo = $customFieldRepo; $this->customFieldRepo = $customFieldRepo;
parent::__construct($em, $serializer, $settings, $logger); parent::__construct($em, $serializer, $settings, $logger);
@ -95,37 +92,99 @@ class StationMediaRepository extends Repository
/** /**
* @param Entity\Station $station * @param Entity\Station $station
* @param string $tmp_path * @param string $path
* @param string $dest * @param string|null $uploadedFrom The original uploaded path (if this is a new upload).
* *
* @return Entity\StationMedia * @return Entity\StationMedia
* @throws Exception
*/ */
public function uploadFile(Entity\Station $station, $tmp_path, $dest): Entity\StationMedia public function getOrCreate(
{ Entity\Station $station,
[, $dest_path] = explode('://', $dest, 2); string $path,
?string $uploadedFrom = null
): Entity\StationMedia {
if (strpos($path, '://') !== false) {
[, $path] = explode('://', $path, 2);
}
$record = $this->repository->findOneBy([ $record = $this->repository->findOneBy([
'station_id' => $station->getId(), 'station_id' => $station->getId(),
'path' => $dest_path, 'path' => $path,
]); ]);
$created = false;
if (!($record instanceof Entity\StationMedia)) { 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); if ($created || $reprocessed) {
$fs->upload($tmp_path, $dest); $this->em->flush();
}
$record->setMtime(time() + 5);
$this->em->persist($record);
$this->em->flush();
return $record; 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. * Process metadata information from media file.
* *
@ -198,26 +257,22 @@ class StationMediaRepository extends Repository
} }
// Attempt to derive title and artist from filename. // 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 = pathinfo($media->getPath(), PATHINFO_FILENAME);
$filename = str_replace('_', ' ', $filename); $filename = str_replace('_', ' ', $filename);
$string_parts = explode('-', $filename); $songObj = new CurrentSong($filename);
$media->setSong($songObj);
// 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)));
}
} }
$media->setSong($this->songRepo->getOrCreate([ // Force a text property to auto-generate from artist/title
'artist' => $media->getArtist(), $media->setText($media->getText());
'title' => $media->getTitle(),
])); // Generate a song_id hash based on the track
$media->updateSongId();
} }
protected function cleanUpString(string $original): string 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. * Crop album art and write the resulting image to storage.
* *
@ -251,7 +325,6 @@ class StationMediaRepository extends Repository
$media->setArtUpdatedAt(time()); $media->setArtUpdatedAt(time());
$this->em->persist($media); $this->em->persist($media);
$this->em->flush();
return $fs->put($albumArtPath, $albumArt); return $fs->put($albumArtPath, $albumArt);
} }
@ -269,86 +342,6 @@ class StationMediaRepository extends Repository
$this->em->flush(); $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. * 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. * 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 public function getUpcomingQueue(Entity\Station $station): array
{ {
return $this->getUpcomingBaseQuery($station) return $this->getUpcomingBaseQuery($station)
->andWhere('sq.sent_to_autodj = 0') ->andWhere('sq.sent_to_autodj = 0')
->getQuery() ->getQuery()
->execute(); ->execute();
} }
@ -69,8 +69,8 @@ class StationQueueRepository extends Repository
public function getUpcomingFromSong(Entity\Station $station, Entity\Song $song): ?Entity\StationQueue public function getUpcomingFromSong(Entity\Station $station, Entity\Song $song): ?Entity\StationQueue
{ {
return $this->getUpcomingBaseQuery($station) return $this->getUpcomingBaseQuery($station)
->andWhere('sq.song = :song') ->andWhere('sq.song_id = :song_id')
->setParameter('song', $song) ->setParameter('song_id', $song->getSongId())
->getQuery() ->getQuery()
->setMaxResults(1) ->setMaxResults(1)
->getOneOrNullResult(); ->getOneOrNullResult();
@ -79,10 +79,9 @@ class StationQueueRepository extends Repository
protected function getUpcomingBaseQuery(Entity\Station $station): QueryBuilder protected function getUpcomingBaseQuery(Entity\Station $station): QueryBuilder
{ {
return $this->em->createQueryBuilder() return $this->em->createQueryBuilder()
->select('sq, sm, sp, s') ->select('sq, sm, sp')
->from(Entity\StationQueue::class, 'sq') ->from(Entity\StationQueue::class, 'sq')
->leftJoin('sq.media', 'sm') ->leftJoin('sq.media', 'sm')
->leftJoin('sq.song', 's')
->leftJoin('sq.playlist', 'sp') ->leftJoin('sq.playlist', 'sp')
->where('sq.station = :station') ->where('sq.station = :station')
->setParameter('station', $station) ->setParameter('station', $station)

View File

@ -162,9 +162,8 @@ class StationRequestRepository extends Repository
$lastPlayThreshold = time() - ($lastPlayThresholdMins * 60); $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 FROM App\Entity\SongHistory sh
JOIN sh.song s
WHERE sh.station = :station WHERE sh.station = :station
AND sh.timestamp_start >= :threshold AND sh.timestamp_start >= :threshold
ORDER BY sh.timestamp_start DESC') ORDER BY sh.timestamp_start DESC')
@ -172,12 +171,11 @@ class StationRequestRepository extends Repository
->setParameter('threshold', $lastPlayThreshold) ->setParameter('threshold', $lastPlayThreshold)
->getArrayResult(); ->getArrayResult();
$song = $media->getSong();
$eligibleTracks = [ $eligibleTracks = [
[ [
'title' => $song->getTitle(), 'title' => $media->getTitle(),
'artist' => $song->getArtist(), 'artist' => $media->getArtist(),
], ],
]; ];

View File

@ -2,31 +2,19 @@
namespace App\Entity; namespace App\Entity;
use App\ApiUtilities; use App\ApiUtilities;
use App\Exception;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use NowPlaying\Result\CurrentSong; use NowPlaying\Result\CurrentSong;
use Psr\Http\Message\UriInterface; use Psr\Http\Message\UriInterface;
/**
* @ORM\Table(name="songs", indexes={
* @ORM\Index(name="search_idx", columns={"text", "artist", "title"})
* })
* @ORM\Entity()
*/
class Song class Song
{ {
use Traits\TruncateStrings; use Traits\TruncateStrings;
public const SYNC_THRESHOLD = 604800; // 604800 = 1 week
/** /**
* @ORM\Column(name="id", type="string", length=50) * @ORM\Column(name="song_id", type="string", length=50)
* @ORM\Id
* @var string * @var string
*/ */
protected $id; protected $song_id;
/** /**
* @ORM\Column(name="text", type="string", length=150, nullable=true) * @ORM\Column(name="text", type="string", length=150, nullable=true)
@ -47,111 +35,80 @@ class Song
protected $title; protected $title;
/** /**
* @ORM\Column(name="created", type="integer") * @param self|Api\Song|CurrentSong|array|string|null $song
* @var int
*/ */
protected $created; public function __construct($song)
/**
* @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)
{ {
$this->created = time(); if (null !== $song) {
$this->history = new ArrayCollection; $this->setSong($song);
$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.');
} }
} }
/** /**
* @param array|object|string $song_info * @param self|Api\Song|CurrentSong|array|string $song
*
* @return string
*/ */
public static function getSongHash($song_info): string public function setSong($song): void
{ {
// Handle various input types. if ($song instanceof self) {
if ($song_info instanceof self) { $this->setText($song->getText());
$song_info = [ $this->setTitle($song->getTitle());
'text' => $song_info->getText(), $this->setArtist($song->getArtist());
'artist' => $song_info->getArtist(), $this->song_id = $song->getSongId();
'title' => $song_info->getTitle(), return;
];
} 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,
];
} }
// Generate hash. if ($song instanceof Api\Song) {
if (!empty($song_info['text'])) { $this->setText($song->text);
$song_text = $song_info['text']; $this->setTitle($song->title);
} elseif (!empty($song_info['artist'])) { $this->setArtist($song->artist);
$song_text = $song_info['artist'] . ' - ' . $song_info['title']; $this->song_id = $song->id;
} else { return;
$song_text = $song_info['title'];
} }
// Strip non-alphanumeric characters if (is_array($song)) {
$song_text = mb_substr($song_text, 0, 150, 'UTF-8'); $song = new CurrentSong(
$hash_base = mb_strtolower(str_replace([' ', '-'], ['', ''], $song_text), 'UTF-8'); $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 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 public function getArtist(): ?string
@ -159,48 +116,24 @@ class Song
return $this->artist; return $this->artist;
} }
public function setArtist(?string $artist): void
{
$this->artist = $this->truncateString($artist, 150);
}
public function getTitle(): ?string public function getTitle(): ?string
{ {
return $this->title; return $this->title;
} }
public function getId(): string public function setTitle(?string $title): void
{ {
return $this->id; $this->title = $this->truncateString($title, 150);
}
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;
} }
public function __toString(): string 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 * @return Api\Song
*/ */
public function api( public function getSongApi(
ApiUtilities $api_utils, ApiUtilities $api_utils,
?Station $station = null, ?Station $station = null,
?UriInterface $base_url = null ?UriInterface $base_url = null
): Api\Song { ): Api\Song {
$response = new Api\Song; $response = new Api\Song;
$response->id = (string)$this->id; $response->id = (string)$this->song_id;
$response->text = (string)$this->text; $response->text = (string)$this->text;
$response->artist = (string)$this->artist; $response->artist = (string)$this->artist;
$response->title = (string)$this->title; $response->title = (string)$this->title;
@ -228,4 +161,33 @@ class Song
return $response; 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() * @ORM\Entity()
*/ */
class SongHistory class SongHistory extends Song
{ {
use Traits\TruncateInts; use Traits\TruncateInts;
@ -30,21 +30,6 @@ class SongHistory
*/ */
protected $id; 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") * @ORM\Column(name="station_id", type="integer")
* @var int * @var int
@ -180,9 +165,12 @@ class SongHistory
*/ */
protected $delta_points; protected $delta_points;
public function __construct(Song $song, Station $station) public function __construct(
{ Station $station,
$this->song = $song; Song $song
) {
parent::__construct($song);
$this->station = $station; $this->station = $station;
$this->timestamp_start = 0; $this->timestamp_start = 0;
@ -203,11 +191,6 @@ class SongHistory
return $this->id; return $this->id;
} }
public function getSong(): Song
{
return $this->song;
}
public function getStation(): Station public function getStation(): Station
{ {
return $this->station; return $this->station;
@ -424,21 +407,23 @@ class SongHistory
$response->song = ($this->media) $response->song = ($this->media)
? $this->media->api($api, $base_url) ? $this->media->api($api, $base_url)
: $this->song->api($api, $this->station, $base_url); : $this->getSongApi($api, $this->station, $base_url);
return $response; return $response;
} }
public function __toString() public function __toString(): string
{ {
return (null !== $this->media) if ($this->media instanceof StationMedia) {
? (string)$this->media return (string)$this->media;
: (string)$this->song; }
return parent::__toString();
} }
public static function fromQueue(StationQueue $queue): self 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->setMedia($queue->getMedia());
$sh->setRequest($queue->getRequest()); $sh->setRequest($queue->getRequest());
$sh->setPlaylist($queue->getPlaylist()); $sh->setPlaylist($queue->getPlaylist());

View File

@ -22,7 +22,7 @@ use Symfony\Component\Serializer\Annotation as Serializer;
* *
* @OA\Schema(type="object") * @OA\Schema(type="object")
*/ */
class StationMedia class StationMedia extends Song
{ {
use Traits\UniqueId, Traits\TruncateStrings; use Traits\UniqueId, Traits\TruncateStrings;
@ -54,42 +54,6 @@ class StationMedia
*/ */
protected $station; 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) * @ORM\Column(name="album", type="string", length=200, nullable=true)
* *
@ -243,6 +207,8 @@ class StationMedia
public function __construct(Station $station, string $path) public function __construct(Station $station, string $path)
{ {
parent::__construct(null);
$this->station = $station; $this->station = $station;
$this->playlists = new ArrayCollection; $this->playlists = new ArrayCollection;
@ -262,31 +228,6 @@ class StationMedia
return $this->station; 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 public function getAlbum(): ?string
{ {
return $this->album; return $this->album;
@ -546,58 +487,9 @@ class StationMedia
$this->custom_fields = $custom_fields; $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 public function needsReprocessing($current_mtime = 0): bool
{ {
if ($current_mtime > $this->mtime) { return $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;
} }
/** /**
@ -641,7 +533,7 @@ class StationMedia
{ {
$response = new Api\Song; $response = new Api\Song;
$response->id = (string)$this->song_id; $response->id = (string)$this->song_id;
$response->text = $this->artist . ' - ' . $this->title; $response->text = (string)$this->text;
$response->artist = (string)$this->artist; $response->artist = (string)$this->artist;
$response->title = (string)$this->title; $response->title = (string)$this->title;

View File

@ -9,7 +9,7 @@ use Psr\Http\Message\UriInterface;
* @ORM\Table(name="station_queue") * @ORM\Table(name="station_queue")
* @ORM\Entity() * @ORM\Entity()
*/ */
class StationQueue class StationQueue extends Song
{ {
use Traits\TruncateInts; use Traits\TruncateInts;
@ -21,21 +21,6 @@ class StationQueue
*/ */
protected $id; 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") * @ORM\Column(name="station_id", type="integer")
* @var int * @var int
@ -128,9 +113,10 @@ class StationQueue
public function __construct(Station $station, Song $song) public function __construct(Station $station, Song $song)
{ {
$this->song = $song; parent::__construct($song);
$this->station = $station;
$this->station = $station;
$this->sent_to_autodj = false; $this->sent_to_autodj = false;
} }
@ -139,11 +125,6 @@ class StationQueue
return $this->id; return $this->id;
} }
public function getSong(): Song
{
return $this->song;
}
public function getStation(): Station public function getStation(): Station
{ {
return $this->station; return $this->station;
@ -265,15 +246,15 @@ class StationQueue
$response->song = ($this->media) $response->song = ($this->media)
? $this->media->api($api, $base_url) ? $this->media->api($api, $base_url)
: $this->song->api($api, $this->station, $base_url); : $this->getSongApi($api, $this->station, $base_url);
return $response; return $response;
} }
public function __toString() public function __toString(): string
{ {
return (null !== $this->media) return (null !== $this->media)
? (string)$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\StationPlaylistMediaRepository $spmRepo;
protected Entity\Repository\SongRepository $songRepo;
protected Entity\Repository\StationRequestRepository $requestRepo; protected Entity\Repository\StationRequestRepository $requestRepo;
protected Entity\Repository\SongHistoryRepository $historyRepo; protected Entity\Repository\SongHistoryRepository $historyRepo;
@ -31,7 +29,6 @@ class Queue implements EventSubscriberInterface
LoggerInterface $logger, LoggerInterface $logger,
Scheduler $scheduler, Scheduler $scheduler,
Entity\Repository\StationPlaylistMediaRepository $spmRepo, Entity\Repository\StationPlaylistMediaRepository $spmRepo,
Entity\Repository\SongRepository $songRepo,
Entity\Repository\StationRequestRepository $requestRepo, Entity\Repository\StationRequestRepository $requestRepo,
Entity\Repository\SongHistoryRepository $historyRepo Entity\Repository\SongHistoryRepository $historyRepo
) { ) {
@ -39,7 +36,6 @@ class Queue implements EventSubscriberInterface
$this->logger = $logger; $this->logger = $logger;
$this->scheduler = $scheduler; $this->scheduler = $scheduler;
$this->spmRepo = $spmRepo; $this->spmRepo = $spmRepo;
$this->songRepo = $songRepo;
$this->requestRepo = $requestRepo; $this->requestRepo = $requestRepo;
$this->historyRepo = $historyRepo; $this->historyRepo = $historyRepo;
} }
@ -106,7 +102,7 @@ class Queue implements EventSubscriberInterface
$logOncePerXSongsSongHistory = []; $logOncePerXSongsSongHistory = [];
foreach ($recentSongHistoryForOncePerXSongs as $row) { foreach ($recentSongHistoryForOncePerXSongs as $row) {
$logOncePerXSongsSongHistory[] = [ $logOncePerXSongsSongHistory[] = [
'song' => $row['song']['text'], 'song' => $row['text'],
'cued_at' => (string)(CarbonImmutable::createFromTimestamp($row['timestamp_cued'] ?? $row['timestamp_start'], 'cued_at' => (string)(CarbonImmutable::createFromTimestamp($row['timestamp_cued'] ?? $row['timestamp_start'],
$now->getTimezone())), $now->getTimezone())),
'duration' => $row['duration'], 'duration' => $row['duration'],
@ -117,7 +113,7 @@ class Queue implements EventSubscriberInterface
$logDuplicatePreventionSongHistory = []; $logDuplicatePreventionSongHistory = [];
foreach ($recentSongHistoryForDuplicatePrevention as $row) { foreach ($recentSongHistoryForDuplicatePrevention as $row) {
$logDuplicatePreventionSongHistory[] = [ $logDuplicatePreventionSongHistory[] = [
'song' => $row['song']['text'], 'song' => $row['text'],
'cued_at' => (string)(CarbonImmutable::createFromTimestamp($row['timestamp_cued'] ?? $row['timestamp_start'], 'cued_at' => (string)(CarbonImmutable::createFromTimestamp($row['timestamp_cued'] ?? $row['timestamp_start'],
$now->getTimezone())), $now->getTimezone())),
'duration' => $row['duration'], 'duration' => $row['duration'],
@ -268,9 +264,10 @@ class Queue implements EventSubscriberInterface
$playlist->setPlayedAt($now->getTimestamp()); $playlist->setPlayedAt($now->getTimestamp());
$this->em->persist($playlist); $this->em->persist($playlist);
$sh = new Entity\StationQueue($playlist->getStation(), $this->songRepo->getOrCreate([ $sh = new Entity\StationQueue(
'text' => 'Remote Playlist URL', $playlist->getStation(),
])); new Entity\Song('Remote Playlist URL')
);
$sh->setPlaylist($playlist); $sh->setPlaylist($playlist);
$sh->setAutodjCustomUri($media_uri); $sh->setAutodjCustomUri($media_uri);
@ -440,11 +437,11 @@ class Queue implements EventSubscriberInterface
foreach ($playedMedia as $history) { foreach ($playedMedia as $history) {
$playedTracks[] = [ $playedTracks[] = [
'artist' => $history['song']['artist'], 'artist' => $history['artist'],
'title' => $history['song']['title'], 'title' => $history['title'],
]; ];
$songId = $history['song']['id']; $songId = $history['song_id'];
if (!isset($latestSongIdsPlayed[$songId])) { if (!isset($latestSongIdsPlayed[$songId])) {
$latestSongIdsPlayed[$songId] = $history['timestamp_cued'] ?? $history['timestamp_start']; $latestSongIdsPlayed[$songId] = $history['timestamp_cued'] ?? $history['timestamp_start'];
@ -460,8 +457,8 @@ class Queue implements EventSubscriberInterface
} }
$eligibleTracks[$media['id']] = [ $eligibleTracks[$media['id']] = [
'artist' => $media['song']['artist'], 'artist' => $media['artist'],
'title' => $media['song']['title'], 'title' => $media['title'],
]; ];
} }

View File

@ -22,6 +22,7 @@ use Psr\Log\LoggerInterface;
use Psr\SimpleCache\CacheInterface; use Psr\SimpleCache\CacheInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\Messenger\MessageBus; use Symfony\Component\Messenger\MessageBus;
use Symfony\Component\Messenger\Stamp\DelayStamp; use Symfony\Component\Messenger\Stamp\DelayStamp;
use function DeepCopy\deep_copy; use function DeepCopy\deep_copy;
@ -46,8 +47,6 @@ class NowPlaying extends AbstractTask implements EventSubscriberInterface
protected Entity\Repository\StationQueueRepository $queueRepo; protected Entity\Repository\StationQueueRepository $queueRepo;
protected Entity\Repository\SongRepository $song_repo;
protected Entity\Repository\ListenerRepository $listener_repo; protected Entity\Repository\ListenerRepository $listener_repo;
protected LockFactory $lockFactory; protected LockFactory $lockFactory;
@ -66,7 +65,6 @@ class NowPlaying extends AbstractTask implements EventSubscriberInterface
MessageBus $messageBus, MessageBus $messageBus,
LockFactory $lockFactory, LockFactory $lockFactory,
Entity\Repository\SongHistoryRepository $historyRepository, Entity\Repository\SongHistoryRepository $historyRepository,
Entity\Repository\SongRepository $songRepository,
Entity\Repository\ListenerRepository $listenerRepository, Entity\Repository\ListenerRepository $listenerRepository,
Entity\Repository\SettingsRepository $settingsRepository, Entity\Repository\SettingsRepository $settingsRepository,
Entity\Repository\StationQueueRepository $queueRepo Entity\Repository\StationQueueRepository $queueRepo
@ -83,7 +81,6 @@ class NowPlaying extends AbstractTask implements EventSubscriberInterface
$this->lockFactory = $lockFactory; $this->lockFactory = $lockFactory;
$this->history_repo = $historyRepository; $this->history_repo = $historyRepository;
$this->song_repo = $songRepository;
$this->listener_repo = $listenerRepository; $this->listener_repo = $listenerRepository;
$this->queueRepo = $queueRepo; $this->queueRepo = $queueRepo;
@ -174,8 +171,7 @@ class NowPlaying extends AbstractTask implements EventSubscriberInterface
Entity\Station $station, Entity\Station $station,
$standalone = false $standalone = false
): Entity\Api\NowPlaying { ): Entity\Api\NowPlaying {
$lock = $this->lockFactory->createLock('nowplaying_station_' . $station->getId(), 600); $lock = $this->getLockForStation($station);
$lock->acquire(true); $lock->acquire(true);
try { try {
@ -236,11 +232,11 @@ class NowPlaying extends AbstractTask implements EventSubscriberInterface
); );
if (empty($npResult->currentSong->text)) { 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 = new Entity\Api\NowPlayingCurrentSong;
$offline_sh->sh_id = 0; $offline_sh->sh_id = 0;
$offline_sh->song = $song_obj->api( $offline_sh->song = $song_obj->getSongApi(
$this->api_utils, $this->api_utils,
$station, $station,
$uri_empty $uri_empty
@ -266,18 +262,16 @@ class NowPlaying extends AbstractTask implements EventSubscriberInterface
if ($np_old instanceof Entity\Api\NowPlaying && if ($np_old instanceof Entity\Api\NowPlaying &&
0 === strcmp($current_song_hash, $np_old->now_playing->song->id)) { 0 === strcmp($current_song_hash, $np_old->now_playing->song->id)) {
/** @var Entity\Song $song_obj */ $previousHistory = $this->history_repo->getCurrent($station);
$song_obj = $this->song_repo->getRepository()->find($current_song_hash);
$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->song_history = $np_old->song_history;
$np->playing_next = $np_old->playing_next; $np->playing_next = $np_old->playing_next;
} else { } else {
// SongHistory registration must ALWAYS come before the history/nextsong calls // SongHistory registration must ALWAYS come before the history/nextsong calls
// otherwise they will not have up-to-date database info! // 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(new Entity\Song($npResult->currentSong), $station, $np);
$sh_obj = $this->history_repo->register($song_obj, $station, $np);
$np->song_history = $this->history_repo->getHistoryApi( $np->song_history = $this->history_repo->getHistoryApi(
$station, $station,
@ -316,7 +310,8 @@ class NowPlaying extends AbstractTask implements EventSubscriberInterface
} }
// Register a new item in song history. // 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(); $np->update();
@ -353,27 +348,27 @@ class NowPlaying extends AbstractTask implements EventSubscriberInterface
*/ */
public function queueStation(Entity\Station $station, array $extra_metadata = []): void public function queueStation(Entity\Station $station, array $extra_metadata = []): void
{ {
// Stop Now Playing from processing while doing the steps below. $lock = $this->getLockForStation($station);
$station->setNowPlayingTimestamp(time());
$this->em->persist($station);
$this->em->flush();
// Process extra metadata sent by Liquidsoap (if it exists). if (!$lock->acquire(true)) {
if (!empty($extra_metadata['song_id'])) { return;
$song = $this->song_repo->getRepository()->find($extra_metadata['song_id']); }
if ($song instanceof Entity\Song) { try {
$sq = $this->queueRepo->getUpcomingFromSong($station, $song); // Process extra metadata sent by Liquidsoap (if it exists).
if (!$sq instanceof Entity\StationQueue) { if (!empty($extra_metadata['media_id'])) {
$sq = new Entity\StationQueue($station, $song); $media = $this->em->find(Entity\StationMedia::class, $extra_metadata['media_id']);
$sq->setTimestampCued(time()); if (!$media instanceof Entity\StationMedia) {
return;
} }
if (!empty($extra_metadata['media_id']) && null === $sq->getMedia()) { $sq = $this->queueRepo->getUpcomingFromSong($station, $media->getSong());
$media = $this->em->find(Entity\StationMedia::class, $extra_metadata['media_id']);
if ($media instanceof Entity\StationMedia) { if (!$sq instanceof Entity\StationQueue) {
$sq->setMedia($media); $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()) { 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->persist($sq);
$this->em->flush(); $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(); 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 $mediaQuery = $this->em->createQuery(/** @lang DQL */ 'SELECT
sm sm
FROM App\Entity\StationMedia sm FROM App\Entity\StationMedia sm
WHERE sm.station_id = :station_id WHERE sm.station = :station
ORDER BY sm.artist ASC, sm.title ASC') ORDER BY sm.artist ASC, sm.title ASC')
->setParameter('station_id', $station->getId()); ->setParameter('station', $station);
$iterator = SimpleBatchIteratorAggregate::fromQuery($mediaQuery, 100); $iterator = SimpleBatchIteratorAggregate::fromQuery($mediaQuery, 100);
$report = []; $report = [];

View File

@ -2,14 +2,14 @@
<div class="card"> <div class="card">
<div class="card-header bg-primary-dark"> <div class="card-header bg-primary-dark">
<h2 class="card-title"><?=__('Song Duplicates') ?></h2> <h2 class="card-title"><?=__('Song Duplicates')?></h2>
</div> </div>
<?php if (empty($dupes)): ?> <?php if (empty($dupes)): ?>
<div class="card-body"> <div class="card-body">
<p><?=__('No duplicates were found. Nice work!') ?></p> <p><?=__('No duplicates were found. Nice work!')?></p>
</div> </div>
<?php else: ?> <?php else: ?>
<?php foreach($dupes as $dupe_row): ?> <?php foreach ($dupes as $dupe_row): ?>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped"> <table class="table table-striped">
<colgroup> <colgroup>
@ -20,21 +20,22 @@
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th><?=__('Actions') ?></th> <th><?=__('Actions')?></th>
<th><?=__('Title / File Path') ?></th> <th><?=__('Title / File Path')?></th>
<th class="text-right"><?=__('Playlists') ?></th> <th class="text-right"><?=__('Playlists')?></th>
<th class="text-right"><?=__('Length') ?></th> <th class="text-right"><?=__('Length')?></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach($dupe_row as $media_row): ?> <?php foreach ($dupe_row as $media_row): ?>
<tr class="align-middle"> <tr class="align-middle">
<td> <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>
<td> <td>
<big><?=$media_row['song']['artist'] ?> - <?=$media_row['song']['title'] ?></big><br> <big><?=$media_row['artist']?> - <?=$media_row['title']?></big><br>
<?=$media_row['path'] ?> <?=$media_row['path']?>
</td> </td>
<td class="text-right"> <td class="text-right">
<?php if (count($media_row['playlists']) == 0): ?> <?php if (count($media_row['playlists']) == 0): ?>
@ -42,14 +43,14 @@
<?php else: ?> <?php else: ?>
<?php <?php
$playlists = []; $playlists = [];
foreach($media_row['playlists'] as $playlist) { foreach ($media_row['playlists'] as $playlist) {
$playlists[] = $playlist['name']; $playlists[] = $playlist['name'];
} }
?> ?>
<abbr title="<?=implode(', ', $playlists) ?>"><?=count($playlists) ?></abbr> <abbr title="<?=implode(', ', $playlists)?>"><?=count($playlists)?></abbr>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td class="text-right"><?=$media_row['length_text'] ?></td> <td class="text-right"><?=$media_row['length_text']?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>

View File

@ -17,30 +17,30 @@ $assets
<div class="card-header"> <div class="card-header">
<ul class="nav nav-pills card-header-pills"> <ul class="nav nav-pills card-header-pills">
<li class="nav-item"> <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>
<li class="nav-item"> <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>
<li class="nav-item"> <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> </li>
</ul> </ul>
</div> </div>
<div class="tab-content"> <div class="tab-content">
<div class="tab-pane px-0 card-body active" id="listeners-by-day" role="tabpanel"> <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"> <canvas id="listeners_by_day" style="width: 100%;" aria-label="<?=__('Listeners by Day')?>" role="img">
<?=$charts['daily_alt'] ?> <?=$charts['daily_alt']?>
</canvas> </canvas>
</div> </div>
<div class="tab-pane px-0 card-body" id="listeners-by-hour" role="tabpanel"> <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"> <canvas id="listeners_by_hour" style="width: 100%;" aria-label="<?=__('Listeners by Hour')?>" role="img">
<?=$charts['hourly_alt'] ?> <?=$charts['hourly_alt']?>
</canvas> </canvas>
</div> </div>
<div class="tab-pane px-0 card-body" id="listeners-by-day-of-week" role="tabpanel"> <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"> <canvas id="listeners_by_day_of_week" style="width: 100%;" aria-label="<?=__('Listeners by Day of Week')?>" role="img">
<?=$charts['day_of_week_alt'] ?> <?=$charts['day_of_week_alt']?>
</canvas> </canvas>
</div> </div>
</div> </div>
@ -53,8 +53,8 @@ $assets
<section class="card mb-3" role="region"> <section class="card mb-3" role="region">
<div class="card-header bg-primary-dark"> <div class="card-header bg-primary-dark">
<h2 class="card-title"> <h2 class="card-title">
<?=__('Best Performing Songs') ?> <?=__('Best Performing Songs')?>
<small><?=__('in the last 48 hours') ?></small> <small><?=__('in the last 48 hours')?></small>
</h2> </h2>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
@ -64,24 +64,25 @@ $assets
<col width="80%"> <col width="80%">
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th><?=__('Change') ?></th> <th><?=__('Change')?></th>
<th><?=__('Song') ?></th> <th><?=__('Song')?></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach($best_performing_songs as $song_row): ?> <?php foreach ($best_performing_songs as $song_row): ?>
<tr> <tr>
<td class="text-center text-success"> <td class="text-center text-success">
<i class="material-icons" aria-hidden="true">keyboard_arrow_up</i> <?=abs($song_row['stat_delta']) ?><br> <i class="material-icons" aria-hidden="true">keyboard_arrow_up</i> <?=abs($song_row['stat_delta'])?>
<small><?=$song_row['stat_start'] ?> to <?=$song_row['stat_end'] ?> <br>
<small><?=$song_row['stat_start']?> to <?=$song_row['stat_end']?>
</td> </td>
<td> <td>
<?php if ($song_row['song']['title']): ?> <?php if ($song_row['title']): ?>
<b><?=$song_row['song']['title'] ?></b><br> <b><?=$song_row['title']?></b><br>
<?=$song_row['song']['artist'] ?> <?=$song_row['artist']?>
<?php else: ?> <?php else: ?>
<?=$song_row['song']['text'] ?> <?=$song_row['text']?>
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
@ -95,8 +96,8 @@ $assets
<section class="card mb-3" role="region"> <section class="card mb-3" role="region">
<div class="card-header bg-primary-dark"> <div class="card-header bg-primary-dark">
<h2 class="card-title"> <h2 class="card-title">
<?=__('Worst Performing Songs') ?> <?=__('Worst Performing Songs')?>
<small><?=__('in the last 48 hours') ?></small> <small><?=__('in the last 48 hours')?></small>
</h2> </h2>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
@ -106,24 +107,25 @@ $assets
<col width="80%"> <col width="80%">
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th><?=__('Change') ?></th> <th><?=__('Change')?></th>
<th><?=__('Song') ?></th> <th><?=__('Song')?></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach($worst_performing_songs as $song_row): ?> <?php foreach ($worst_performing_songs as $song_row): ?>
<tr> <tr>
<td class="text-center text-danger"> <td class="text-center text-danger">
<i class="material-icons" aria-hidden="true">keyboard_arrow_down</i> <?=abs($song_row['stat_delta']) ?><br> <i class="material-icons" aria-hidden="true">keyboard_arrow_down</i> <?=abs($song_row['stat_delta'])?>
<small><?=$song_row['stat_start'] ?> to <?=$song_row['stat_end'] ?> <br>
<small><?=$song_row['stat_start']?> to <?=$song_row['stat_end']?>
</td> </td>
<td> <td>
<?php if ($song_row['song']['title']): ?> <?php if ($song_row['title']): ?>
<b><?=$song_row['song']['title'] ?></b><br> <b><?=$song_row['title']?></b><br>
<?=$song_row['song']['artist'] ?> <?=$song_row['artist']?>
<?php else: ?> <?php else: ?>
<?=$song_row['song']['text'] ?> <?=$song_row['text']?>
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
@ -140,8 +142,8 @@ $assets
<section class="card" role="region"> <section class="card" role="region">
<div class="card-header bg-primary-dark"> <div class="card-header bg-primary-dark">
<h2 class="card-title"> <h2 class="card-title">
<?=__('Most Played Songs') ?> <?=__('Most Played Songs')?>
<small><?=__('in the last month') ?></small> <small><?=__('in the last month')?></small>
</h2> </h2>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
@ -151,22 +153,22 @@ $assets
<col width="90%"> <col width="90%">
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th><?=__('Plays') ?></th> <th><?=__('Plays')?></th>
<th><?=__('Song') ?></th> <th><?=__('Song')?></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach($song_totals['played'] as $song_row): ?> <?php foreach ($song_totals['played'] as $song_row): ?>
<tr> <tr>
<td class="text-center"><?=$song_row['records'] ?></td> <td class="text-center"><?=$song_row['records']?></td>
<td> <td>
<?php if ($song_row['song']['title']): ?> <?php if ($song_row['title']): ?>
<b><?=$song_row['song']['title'] ?></b><br> <b><?=$song_row['title']?></b><br>
<?=$song_row['song']['artist'] ?> <?=$song_row['artist']?>
<?php else: ?> <?php else: ?>
<?=$song_row['song']['text'] ?> <?=$song_row['text']?>
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>