From c81ff62b5c8a89276aa4adaa4a5b5b75d0dc1a42 Mon Sep 17 00:00:00 2001 From: "Buster \"Silver Eagle\" Neece" Date: Sun, 4 Oct 2020 17:35:41 -0500 Subject: [PATCH] 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. --- azuracast.dev.env | 1 + azuracast.sample.env | 7 + config/services.php | 39 ++- .../Api/Stations/Art/PostArtAction.php | 3 + .../Api/Stations/Files/FlowUploadAction.php | 65 +++-- .../Api/Stations/FilesController.php | 17 +- .../Api/Stations/HistoryController.php | 9 +- .../Api/Stations/QueueController.php | 3 +- .../Api/Stations/RequestsController.php | 3 +- .../Stations/Reports/DuplicatesController.php | 3 +- .../Stations/Reports/OverviewController.php | 20 +- .../Stations/Reports/RequestsController.php | 3 +- .../Reports/SoundExchangeController.php | 22 +- .../Migration/Version20201003021913.php | 38 +++ .../Migration/Version20201003023117.php | 53 ++++ .../Repository/SongHistoryRepository.php | 23 +- src/Entity/Repository/SongRepository.php | 47 ---- .../Repository/StationMediaRepository.php | 242 ++++++++---------- .../Repository/StationQueueRepository.php | 11 +- .../Repository/StationRequestRepository.php | 8 +- src/Entity/Song.php | 234 +++++++---------- src/Entity/SongHistory.php | 45 ++-- src/Entity/StationMedia.php | 118 +-------- src/Entity/StationQueue.php | 33 +-- src/Radio/AutoDJ/Queue.php | 25 +- src/Sync/Task/NowPlaying.php | 80 +++--- src/Sync/Task/RadioAutomation.php | 4 +- templates/stations/reports/duplicates.phtml | 33 +-- templates/stations/reports/overview.phtml | 100 ++++---- 29 files changed, 548 insertions(+), 741 deletions(-) create mode 100644 src/Entity/Migration/Version20201003021913.php create mode 100644 src/Entity/Migration/Version20201003023117.php delete mode 100644 src/Entity/Repository/SongRepository.php diff --git a/azuracast.dev.env b/azuracast.dev.env index 69f1f3d24..5d87223e1 100644 --- a/azuracast.dev.env +++ b/azuracast.dev.env @@ -3,6 +3,7 @@ # APPLICATION_ENV=development +LOG_LEVEL=debug ENABLE_ADVANCED_FEATURES=true COMPOSER_PLUGIN_MODE=false diff --git a/azuracast.sample.env b/azuracast.sample.env index f0c1f4e82..9c4a7acc9 100644 --- a/azuracast.sample.env +++ b/azuracast.sample.env @@ -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 diff --git a/config/services.php b/config/services.php index de61375b2..34a166d3f 100644 --- a/config/services.php +++ b/config/services.php @@ -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); diff --git a/src/Controller/Api/Stations/Art/PostArtAction.php b/src/Controller/Api/Stations/Art/PostArtAction.php index fb5ef2a95..83990946e 100644 --- a/src/Controller/Api/Stations/Art/PostArtAction.php +++ b/src/Controller/Api/Stations/Art/PostArtAction.php @@ -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())); diff --git a/src/Controller/Api/Stations/Files/FlowUploadAction.php b/src/Controller/Api/Stations/Files/FlowUploadAction.php index f9243c5c9..eead16d39 100644 --- a/src/Controller/Api/Stations/Files/FlowUploadAction.php +++ b/src/Controller/Api/Stations/Files/FlowUploadAction.php @@ -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]); diff --git a/src/Controller/Api/Stations/FilesController.php b/src/Controller/Api/Stations/FilesController.php index 29262d0a1..60584addf 100644 --- a/src/Controller/Api/Stations/FilesController.php +++ b/src/Controller/Api/Stations/FilesController.php @@ -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) { diff --git a/src/Controller/Api/Stations/HistoryController.php b/src/Controller/Api/Stations/HistoryController.php index 19834fcf1..2674d4723 100644 --- a/src/Controller/Api/Stations/HistoryController.php +++ b/src/Controller/Api/Stations/HistoryController.php @@ -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 . '%'); } diff --git a/src/Controller/Api/Stations/QueueController.php b/src/Controller/Api/Stations/QueueController.php index 985797a23..81e3ff4bf 100644 --- a/src/Controller/Api/Stations/QueueController.php +++ b/src/Controller/Api/Stations/QueueController.php @@ -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 diff --git a/src/Controller/Api/Stations/RequestsController.php b/src/Controller/Api/Stations/RequestsController.php index 972eea038..ca3aa0905 100644 --- a/src/Controller/Api/Stations/RequestsController.php +++ b/src/Controller/Api/Stations/RequestsController.php @@ -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') diff --git a/src/Controller/Stations/Reports/DuplicatesController.php b/src/Controller/Stations/Reports/DuplicatesController.php index 18aca0431..7e5767a31 100644 --- a/src/Controller/Stations/Reports/DuplicatesController.php +++ b/src/Controller/Stations/Reports/DuplicatesController.php @@ -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 diff --git a/src/Controller/Stations/Reports/OverviewController.php b/src/Controller/Stations/Reports/OverviewController.php index 14af32596..9384e25ee 100644 --- a/src/Controller/Stations/Reports/OverviewController.php +++ b/src/Controller/Stations/Reports/OverviewController.php @@ -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', [ diff --git a/src/Controller/Stations/Reports/RequestsController.php b/src/Controller/Stations/Reports/RequestsController.php index ed8d87490..8877ff33e 100644 --- a/src/Controller/Stations/Reports/RequestsController.php +++ b/src/Controller/Stations/Reports/RequestsController.php @@ -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()) diff --git a/src/Controller/Stations/Reports/SoundExchangeController.php b/src/Controller/Stations/Reports/SoundExchangeController.php index d5abc8153..fa0bed529 100644 --- a/src/Controller/Stations/Reports/SoundExchangeController.php +++ b/src/Controller/Stations/Reports/SoundExchangeController.php @@ -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) { diff --git a/src/Entity/Migration/Version20201003021913.php b/src/Entity/Migration/Version20201003021913.php new file mode 100644 index 000000000..7587dd392 --- /dev/null +++ b/src/Entity/Migration/Version20201003021913.php @@ -0,0 +1,38 @@ +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'); + } +} diff --git a/src/Entity/Migration/Version20201003023117.php b/src/Entity/Migration/Version20201003023117.php new file mode 100644 index 000000000..7c354a2ff --- /dev/null +++ b/src/Entity/Migration/Version20201003023117.php @@ -0,0 +1,53 @@ +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)'); + } +} diff --git a/src/Entity/Repository/SongHistoryRepository.php b/src/Entity/Repository/SongHistoryRepository.php index fea18fd8b..141e06af4 100644 --- a/src/Entity/Repository/SongHistoryRepository.php +++ b/src/Entity/Repository/SongHistoryRepository.php @@ -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) { diff --git a/src/Entity/Repository/SongRepository.php b/src/Entity/Repository/SongRepository.php deleted file mode 100644 index 41b50b960..000000000 --- a/src/Entity/Repository/SongRepository.php +++ /dev/null @@ -1,47 +0,0 @@ - $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; - } -} diff --git a/src/Entity/Repository/StationMediaRepository.php b/src/Entity/Repository/StationMediaRepository.php index 32e561542..6c236b191 100644 --- a/src/Entity/Repository/StationMediaRepository.php +++ b/src/Entity/Repository/StationMediaRepository.php @@ -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. * diff --git a/src/Entity/Repository/StationQueueRepository.php b/src/Entity/Repository/StationQueueRepository.php index c3d0c644b..d7593b254 100644 --- a/src/Entity/Repository/StationQueueRepository.php +++ b/src/Entity/Repository/StationQueueRepository.php @@ -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) diff --git a/src/Entity/Repository/StationRequestRepository.php b/src/Entity/Repository/StationRequestRepository.php index 79af550d7..5c658d9e4 100644 --- a/src/Entity/Repository/StationRequestRepository.php +++ b/src/Entity/Repository/StationRequestRepository.php @@ -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(), ], ]; diff --git a/src/Entity/Song.php b/src/Entity/Song.php index 70d49390b..6e5732408 100644 --- a/src/Entity/Song.php +++ b/src/Entity/Song.php @@ -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); + } } diff --git a/src/Entity/SongHistory.php b/src/Entity/SongHistory.php index d5d878a80..59c4119b2 100644 --- a/src/Entity/SongHistory.php +++ b/src/Entity/SongHistory.php @@ -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()); diff --git a/src/Entity/StationMedia.php b/src/Entity/StationMedia.php index fc3a476d2..7fa62e986 100644 --- a/src/Entity/StationMedia.php +++ b/src/Entity/StationMedia.php @@ -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; diff --git a/src/Entity/StationQueue.php b/src/Entity/StationQueue.php index 48ebeea7e..84285a795 100644 --- a/src/Entity/StationQueue.php +++ b/src/Entity/StationQueue.php @@ -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(); } } diff --git a/src/Radio/AutoDJ/Queue.php b/src/Radio/AutoDJ/Queue.php index f0d74672f..965325e3f 100644 --- a/src/Radio/AutoDJ/Queue.php +++ b/src/Radio/AutoDJ/Queue.php @@ -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'], ]; } diff --git a/src/Sync/Task/NowPlaying.php b/src/Sync/Task/NowPlaying.php index 5c68caded..5a6b9aa11 100644 --- a/src/Sync/Task/NowPlaying.php +++ b/src/Sync/Task/NowPlaying.php @@ -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); + } } diff --git a/src/Sync/Task/RadioAutomation.php b/src/Sync/Task/RadioAutomation.php index 5dcab3b62..37c3f803b 100644 --- a/src/Sync/Task/RadioAutomation.php +++ b/src/Sync/Task/RadioAutomation.php @@ -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 = []; diff --git a/templates/stations/reports/duplicates.phtml b/templates/stations/reports/duplicates.phtml index 44750cbf2..58a21f15e 100644 --- a/templates/stations/reports/duplicates.phtml +++ b/templates/stations/reports/duplicates.phtml @@ -2,14 +2,14 @@
-

+

-
-

-
+
+

+
- +
@@ -20,21 +20,22 @@ - - - - + + + + - + - + diff --git a/templates/stations/reports/overview.phtml b/templates/stations/reports/overview.phtml index d7a2ceeee..f91c4a8d9 100644 --- a/templates/stations/reports/overview.phtml +++ b/templates/stations/reports/overview.phtml @@ -17,30 +17,30 @@ $assets
- - + +
- - + +
- - + +
@@ -53,8 +53,8 @@ $assets

- - + +

@@ -64,24 +64,25 @@ $assets
- - - - + + + + - + @@ -95,8 +96,8 @@ $assets

- - + +

@@ -106,24 +107,25 @@ $assets
- - - - + + + + - + @@ -140,8 +142,8 @@ $assets

- - + +

@@ -151,22 +153,22 @@ $assets
- - - - + + + + - + - +
- + - -
- + -
+
@@ -42,14 +43,14 @@ - +
-
- to + +
+ to
- -
- + +
+ - +
-
- to + +
+ to
- -
- + +
+ - +
- -
- - - - + +
+ + + +