Move DuplicatePrevention to standalone class and build tests around it.

This commit is contained in:
Buster "Silver Eagle" Neece 2022-03-20 19:40:09 -05:00
parent 2c168156d8
commit 89bf2c1bc0
No known key found for this signature in database
GPG Key ID: 9FC8B9E008872109
4 changed files with 253 additions and 183 deletions

View File

@ -29,7 +29,8 @@ class StationRequestRepository extends Repository
LoggerInterface $logger,
protected StationMediaRepository $mediaRepo,
protected DeviceDetector $deviceDetector,
protected BlocklistParser $blocklistParser
protected BlocklistParser $blocklistParser,
protected AutoDJ\DuplicatePrevention $duplicatePrevention,
) {
parent::__construct($em, $serializer, $environment, $logger);
}
@ -242,7 +243,7 @@ class StationRequestRepository extends Repository
$eligibleTrack->title = $media->getTitle() ?? '';
$eligibleTrack->artist = $media->getArtist() ?? '';
$isDuplicate = (null === AutoDJ\Queue::getDistinctTrack([$eligibleTrack], $recentTracks));
$isDuplicate = (null === $this->duplicatePrevention->getDistinctTrack([$eligibleTrack], $recentTracks));
if ($isDuplicate) {
throw new Exception(

View File

@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Radio\AutoDJ;
use App\Entity;
use Monolog\Logger;
class DuplicatePrevention
{
public const ARTIST_SEPARATORS = [
', ',
' feat ',
' feat. ',
' & ',
' vs. ',
];
public function __construct(
protected Logger $logger
) {
}
/**
* @param Entity\Api\StationPlaylistQueue[] $eligibleTracks
* @param array $playedTracks
* @param bool $allowDuplicates Whether to return a media ID even if duplicates can't be prevented.
*/
public function preventDuplicates(
array $eligibleTracks = [],
array $playedTracks = [],
bool $allowDuplicates = false
): ?Entity\Api\StationPlaylistQueue {
if (empty($eligibleTracks)) {
$this->logger->debug('Eligible song queue is empty!');
return null;
}
$latestSongIdsPlayed = [];
foreach ($playedTracks as $playedTrack) {
$songId = $playedTrack['song_id'];
if (!isset($latestSongIdsPlayed[$songId])) {
$latestSongIdsPlayed[$songId] = $playedTrack['timestamp_played'];
}
}
/** @var Entity\Api\StationPlaylistQueue[] $notPlayedEligibleTracks */
$notPlayedEligibleTracks = [];
foreach ($eligibleTracks as $mediaId => $track) {
$songId = $track->song_id;
if (isset($latestSongIdsPlayed[$songId])) {
continue;
}
$notPlayedEligibleTracks[$mediaId] = $track;
}
$validTrack = $this->getDistinctTrack($notPlayedEligibleTracks, $playedTracks);
if (null !== $validTrack) {
$this->logger->info(
'Found track that avoids duplicate title and artist.',
[
'media_id' => $validTrack->media_id,
'title' => $validTrack->title,
'artist' => $validTrack->artist,
]
);
return $validTrack;
}
// If we reach this point, there's no way to avoid a duplicate title and artist.
if ($allowDuplicates) {
/** @var Entity\Api\StationPlaylistQueue[] $mediaIdsByTimePlayed */
$mediaIdsByTimePlayed = [];
// For each piece of eligible media, get its latest played timestamp.
foreach ($eligibleTracks as $track) {
$songId = $track->song_id;
$trackKey = $latestSongIdsPlayed[$songId] ?? 0;
$mediaIdsByTimePlayed[$trackKey] = $track;
}
ksort($mediaIdsByTimePlayed);
$validTrack = array_shift($mediaIdsByTimePlayed);
// Pull the lowest value, which corresponds to the least recently played song.
if (null !== $validTrack) {
$this->logger->warning(
'No way to avoid same title OR same artist; using least recently played song.',
[
'media_id' => $validTrack->media_id,
'title' => $validTrack->title,
'artist' => $validTrack->artist,
]
);
return $validTrack;
}
}
return null;
}
/**
* Given an array of eligible tracks, return the first ID that doesn't have a duplicate artist/
* title with any of the previously played tracks.
*
* Both should be in the form of an array, i.e.:
* [ 'id' => ['artist' => 'Foo', 'title' => 'Fighters'] ]
*
* @param Entity\Api\StationPlaylistQueue[] $eligibleTracks
* @param array $playedTracks
*
*/
public function getDistinctTrack(
array $eligibleTracks,
array $playedTracks
): ?Entity\Api\StationPlaylistQueue {
$artists = [];
$titles = [];
foreach ($playedTracks as $playedTrack) {
$title = $this->prepareStringForMatching($playedTrack['title']);
$titles[$title] = $title;
foreach ($this->getArtistParts($playedTrack['artist']) as $artist) {
$artists[$artist] = $artist;
}
}
foreach ($eligibleTracks as $track) {
// Avoid all direct title matches.
$title = $this->prepareStringForMatching($track->title);
if (isset($titles[$title])) {
continue;
}
// Attempt to avoid an artist match, if possible.
$compareArtists = [];
foreach ($this->getArtistParts($track->artist) as $compareArtist) {
$compareArtists[$compareArtist] = $compareArtist;
}
if (empty(array_intersect_key($compareArtists, $artists))) {
return $track;
}
}
return null;
}
protected function getArtistParts(string $artists): array
{
$dividerString = chr(7);
$artistParts = explode(
$dividerString,
str_replace(self::ARTIST_SEPARATORS, $dividerString, trim($artists))
);
return array_filter(
array_map(
[$this, 'prepareStringForMatching'],
$artistParts
)
);
}
protected function prepareStringForMatching(string $string): string
{
return mb_strtolower(trim($string));
}
}

View File

@ -23,6 +23,7 @@ class Queue implements EventSubscriberInterface
protected EntityManagerInterface $em,
protected Logger $logger,
protected Scheduler $scheduler,
protected DuplicatePrevention $duplicatePrevention,
protected CacheInterface $cache,
protected EventDispatcherInterface $dispatcher,
protected Entity\Repository\StationPlaylistMediaRepository $spmRepo,
@ -567,7 +568,7 @@ class Queue implements EventSubscriberInterface
$mediaQueue = $this->spmRepo->getQueue($playlist);
if ($playlist->getAvoidDuplicates()) {
return $this->preventDuplicates($mediaQueue, $recentSongHistory, $allowDuplicates);
return $this->duplicatePrevention->preventDuplicates($mediaQueue, $recentSongHistory, $allowDuplicates);
}
return array_shift($mediaQueue);
@ -603,98 +604,12 @@ class Queue implements EventSubscriberInterface
$mediaQueue = $this->spmRepo->resetQueue($playlist);
}
return $this->preventDuplicates($mediaQueue, $recentSongHistory, $allowDuplicates);
return $this->duplicatePrevention->preventDuplicates($mediaQueue, $recentSongHistory, $allowDuplicates);
}
return array_shift($mediaQueue);
}
/**
* @param Entity\Api\StationPlaylistQueue[] $eligibleTracks
* @param array $playedTracks
* @param bool $allowDuplicates Whether to return a media ID even if duplicates can't be prevented.
*/
protected function preventDuplicates(
array $eligibleTracks = [],
array $playedTracks = [],
bool $allowDuplicates = false
): ?Entity\Api\StationPlaylistQueue {
if (empty($eligibleTracks)) {
$this->logger->debug('Eligible song queue is empty!');
return null;
}
$latestSongIdsPlayed = [];
foreach ($playedTracks as $playedTrack) {
$songId = $playedTrack['song_id'];
if (!isset($latestSongIdsPlayed[$songId])) {
$latestSongIdsPlayed[$songId] = $playedTrack['timestamp_played'];
}
}
/** @var Entity\Api\StationPlaylistQueue[] $notPlayedEligibleTracks */
$notPlayedEligibleTracks = [];
foreach ($eligibleTracks as $mediaId => $track) {
$songId = $track->song_id;
if (isset($latestSongIdsPlayed[$songId])) {
continue;
}
$notPlayedEligibleTracks[$mediaId] = $track;
}
$validTrack = self::getDistinctTrack($notPlayedEligibleTracks, $playedTracks);
if (null !== $validTrack) {
$this->logger->info(
'Found track that avoids duplicate title and artist.',
[
'media_id' => $validTrack->media_id,
'title' => $validTrack->title,
'artist' => $validTrack->artist,
]
);
return $validTrack;
}
// If we reach this point, there's no way to avoid a duplicate title and artist.
if ($allowDuplicates) {
/** @var Entity\Api\StationPlaylistQueue[] $mediaIdsByTimePlayed */
$mediaIdsByTimePlayed = [];
// For each piece of eligible media, get its latest played timestamp.
foreach ($eligibleTracks as $track) {
$songId = $track->song_id;
$trackKey = $latestSongIdsPlayed[$songId] ?? 0;
$mediaIdsByTimePlayed[$trackKey] = $track;
}
ksort($mediaIdsByTimePlayed);
$validTrack = array_shift($mediaIdsByTimePlayed);
// Pull the lowest value, which corresponds to the least recently played song.
if (null !== $validTrack) {
$this->logger->warning(
'No way to avoid same title OR same artist; using least recently played song.',
[
'media_id' => $validTrack->media_id,
'title' => $validTrack->title,
'artist' => $validTrack->artist,
]
);
return $validTrack;
}
}
return null;
}
/**
* @param BuildQueue $event
*/
@ -718,99 +633,6 @@ class Queue implements EventSubscriberInterface
$event->setNextSong($stationQueueEntry);
}
/**
* Given an array of eligible tracks, return the first ID that doesn't have a duplicate artist/
* title with any of the previously played tracks.
*
* Both should be in the form of an array, i.e.:
* [ 'id' => ['artist' => 'Foo', 'title' => 'Fighters'] ]
*
* @param Entity\Api\StationPlaylistQueue[] $eligibleTracks
* @param array $playedTracks
*
*/
public static function getDistinctTrack(
array $eligibleTracks,
array $playedTracks
): ?Entity\Api\StationPlaylistQueue {
$artistSeparators = [
', ',
' feat ',
' feat. ',
' & ',
' vs. ',
];
$dividerString = chr(7);
$artists = [];
$titles = [];
$latestSongIdsPlayed = [];
foreach ($playedTracks as $playedTrack) {
$title = trim($playedTrack['title']);
$titles[$title] = $title;
$artistParts = explode(
$dividerString,
str_replace($artistSeparators, $dividerString, $playedTrack['artist'])
);
foreach ($artistParts as $artist) {
$artist = trim($artist);
if (!empty($artist)) {
$artists[$artist] = $artist;
}
}
$songId = $playedTrack['song_id'];
if (!isset($latestSongIdsPlayed[$songId])) {
$latestSongIdsPlayed[$songId] = $playedTrack['timestamp_played'] ?? $playedTrack['timestamp_end'];
}
}
/** @var Entity\Api\StationPlaylistQueue[] $eligibleTracksWithoutSameTitle */
$eligibleTracksWithoutSameTitle = [];
foreach ($eligibleTracks as $track) {
// Avoid all direct title matches.
$title = trim($track->title);
if (isset($titles[$title])) {
continue;
}
// Attempt to avoid an artist match, if possible.
$artist = trim($track->artist);
$artistMatchFound = false;
if (!empty($artist)) {
$artistParts = explode($dividerString, str_replace($artistSeparators, $dividerString, $artist));
foreach ($artistParts as $artist) {
$artist = trim($artist);
if (empty($artist)) {
continue;
}
if (isset($artists[$artist])) {
$artistMatchFound = true;
break;
}
}
}
if (!$artistMatchFound) {
return $track;
}
$songId = $track->song_id;
$trackKey = $latestSongIdsPlayed[$songId] ?? 0;
$eligibleTracksWithoutSameTitle[$trackKey] = $track;
}
ksort($eligibleTracksWithoutSameTitle);
return array_shift($eligibleTracksWithoutSameTitle);
}
protected function logRecentSongHistory(
array $recentPlaylistHistory,
array $recentSongHistoryForDuplicatePrevention

View File

@ -0,0 +1,69 @@
<?php
namespace Unit;
use App\Entity\Api\StationPlaylistQueue;
use App\Radio\AutoDJ\DuplicatePrevention;
use App\Tests\Module;
use Codeception\Test\Unit;
class DuplicatePreventionTest extends Unit
{
protected \UnitTester $tester;
protected DuplicatePrevention $duplicatePrevention;
protected function _inject(Module $tests_module): void
{
$di = $tests_module->container;
$this->duplicatePrevention = $di->get(DuplicatePrevention::class);
}
public function testDistinctTracks(): void
{
$eligibleTrack = new StationPlaylistQueue();
$eligibleTrack->artist = 'Foo Fighters feat. AzuraCast Testers';
$eligibleTrack->title = 'Best of You';
$eligibleTracks = [$eligibleTrack];
$fullDuplicateTest = [
[
'title' => 'Best of You',
'artist' => 'Foo Fighters',
],
];
$fullDuplicateResult = $this->duplicatePrevention->getDistinctTrack($eligibleTracks, $fullDuplicateTest);
$this->assertNull($fullDuplicateResult);
$artistDuplicateTest = [
[
'title' => 'Everlong',
'artist' => 'Foo Fighters',
],
];
$artistDuplicateResult = $this->duplicatePrevention->getDistinctTrack($eligibleTracks, $artistDuplicateTest);
$this->assertNull($artistDuplicateResult);
$partialDuplicateTest = [
[
'title' => 'Testing Song',
'artist' => 'Foo Fighters feat. Fall Out Boy',
],
];
$partialDuplicateResult = $this->duplicatePrevention->getDistinctTrack($eligibleTracks, $partialDuplicateTest);
$this->assertNull($partialDuplicateResult);
$noDuplicatesTest = [
[
'title' => 'Testing Song 1',
'artist' => 'Panic! at the Disco',
],
[
'title' => 'Lost Memory',
'artist' => '削除',
],
];
$noDuplicatesResult = $this->duplicatePrevention->getDistinctTrack($eligibleTracks, $noDuplicatesTest);
$this->assertNotNull($noDuplicatesResult);
}
}